diff --git a/.gitignore b/.gitignore index 429f000b1..a0c4ff586 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ installer/ .venv/ .claude/ .clitests/ +test-*/ test_results/ *.pid *.log diff --git a/Makefile b/Makefile index c357685dc..3e2894c4c 100644 --- a/Makefile +++ b/Makefile @@ -202,17 +202,34 @@ PARALLEL_WIDTH ?= 2 WARMUP_FILTER = ImageWarmup CONCURRENT_TEST_SUITES ?= \ - TestCLIStop \ - TestCLIRmRaceCondition \ - TestCLIExportCommand + TestCLIAnonymousVolumes/ \ + TestCLIExportCommand/ \ + TestCLIImagesCommand/ \ + TestCLINotFound/ \ + TestCLIRmRaceCondition/ \ + TestCLIStop/ \ + TestCLIVolumes/ CONCURRENT_FILTER = $(subst $(space),|,$(strip $(CONCURRENT_TEST_SUITES))) -GLOBAL_FILTER = DemoGlobalTests +GLOBAL_TEST_SUITES ?= \ + TestCLIBuilderEnvOnlySerial/ \ + TestCLIBuilderLifecycleSerial/ \ + TestCLIBuilderLocalOutputSerial/ \ + TestCLIBuilderSerial/ \ + TestCLIBuilderTarExportSerial/ \ + TestCLIKernelSetSerial/ \ + TestCLISystemDFSerial/ \ + TestCLIVolumesSerial/ +GLOBAL_FILTER = $(subst $(space),|,$(strip $(GLOBAL_TEST_SUITES))) INTEGRATION_SWIFT_EXTRA ?= INTEGRATION_POST_TEST ?= PRESERVE_KERNELS ?= false +# Default scratch root under the project directory so container build can access context +# subdirectories (macOS restricts access to /var/folders from the container binary). +# Override with SCRATCH_ROOT=/your/path on the command line. +SCRATCH_ROOT ?= $(ROOT_DIR)/.test-scratch define RUN_INTEGRATION @echo Ensuring apiserver stopped before the CLI integration tests... @@ -231,13 +248,14 @@ define RUN_INTEGRATION @bin/container --debug system start --timeout 60 --enable-kernel-install $(SYSTEM_START_OPTS) && \ { \ CLITEST_LOG_ROOT=$(LOG_ROOT) && export CLITEST_LOG_ROOT ; \ + CLITEST_SCRATCH_ROOT=$(SCRATCH_ROOT) && export CLITEST_SCRATCH_ROOT ; \ CONTAINER_CLI_PATH=$(ROOT_DIR)/bin/container && export CONTAINER_CLI_PATH ; \ echo "==> Warmup pass" && \ $(SWIFT) test $(INTEGRATION_SWIFT_EXTRA) -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter "$(WARMUP_FILTER)" && \ echo "==> Concurrent pass (width=$(PARALLEL_WIDTH))" && \ $(SWIFT) test $(INTEGRATION_SWIFT_EXTRA) -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --experimental-maximum-parallelization-width $(PARALLEL_WIDTH) --filter "$(CONCURRENT_FILTER)" && \ echo "==> Global pass (serial)" && \ - $(SWIFT) test $(INTEGRATION_SWIFT_EXTRA) -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter "$(GLOBAL_FILTER)" ; \ + $(SWIFT) test $(INTEGRATION_SWIFT_EXTRA) -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --experimental-maximum-parallelization-width 1 --filter "$(GLOBAL_FILTER)" ; \ exit_code=$$? ; \ $(INTEGRATION_POST_TEST) \ echo Ensuring apiserver stopped after the CLI integration tests ; \ @@ -272,15 +290,10 @@ INTEGRATION_TEST_SUITES ?= \ TestCLIPruneCommand \ TestCLIRegistry \ TestCLIStatsCommand \ - TestCLIImagesCommand \ TestCLIRunBase \ TestCLIRunInitImage \ TestCLIBuildBase \ - TestCLIVolumes \ - TestCLIKernelSet \ - TestCLIAnonymousVolumes \ TestCLINotFound \ - TestCLISystemDF \ TestCLIMachineCommand \ TestCLIMachineRuntime \ TestCLINoParallelCases \ diff --git a/Package.swift b/Package.swift index 077b74474..a2e1f836d 100644 --- a/Package.swift +++ b/Package.swift @@ -87,7 +87,10 @@ let package = Package( .product(name: "SystemPackage", package: "swift-system"), .product(name: "ContainerizationArchive", package: "containerization"), .product(name: "ContainerizationExtras", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), + "ContainerAPIClient", "ContainerLog", + "ContainerPersistence", "ContainerResource", "Yams", ], diff --git a/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift b/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift deleted file mode 100644 index 5fd12ce6b..000000000 --- a/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift +++ /dev/null @@ -1,336 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025-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 Foundation -import Testing - -@testable import ContainerBuild - -/* CLIBuildBase is the base class used for creating builder tests. Subtests classes -// for these tests are nested in extensions of CLIBuildBase so that we can set -// the serialized parallelization attribute across all builder tests. -*/ -@Suite(.serialSuites, .serialized) -class TestCLIBuildBase: CLITest { - override init() throws { - try super.init() - - try? builderDelete(force: true) - try builderStart() - try waitForBuilderRunning() - } - - deinit { - try? builderDelete(force: true) - } - - func waitForBuilderRunning() throws { - let buildkitName = "buildkit" - try waitForContainerRunning(buildkitName, 10) - - // exec into buildkit and check if builder-shim is running - var attempt = 3 - while attempt > 0 { - attempt -= 1 - do { - let response = try doExec(name: buildkitName, cmd: ["pidof", "-s", "container-builder-shim"]) - if !response.isEmpty { - // found the init process running - return - } - } catch { - print("container-builder-shim check failed with \(error)") - } - sleep(1) - } - throw CLIError.executionFailed("failed to wait for container-builder-shim process on \(buildkitName)") - } - - func createTempDir() throws -> URL { - let tempDir = testDir.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - return tempDir - } - - func createTempFile(suffix: String, contents: Data) throws -> URL { - let tempFile = testDir.appendingPathComponent(UUID().uuidString + suffix) - try contents.write(to: tempFile, options: .atomic) - return tempFile - } - - func createContext(tempDir: URL, dockerfile: String, context: [FileSystemEntry]? = nil) throws { - let dockerfileBytes = dockerfile.data(using: .utf8)! - try dockerfileBytes.write(to: tempDir.appendingPathComponent("Dockerfile"), options: .atomic) - - let contextDir: URL = tempDir.appendingPathComponent("context").absoluteURL - try FileManager.default.createDirectory(at: contextDir, withIntermediateDirectories: true, attributes: nil) - - if let context { - for entry in context { - try createEntry(entry, contextDir) - } - } - } - - @discardableResult - func build( - tag: String, - tempDir: URL, - buildArgs: [String] = [], - otherArgs: [String] = [] - ) throws -> String { - try buildWithPaths( - tags: [tag], - tempContext: tempDir, - tempDockerfileContext: tempDir, - buildArgs: buildArgs, - otherArgs: otherArgs - ) - } - - @discardableResult - func build( - tags: [String], - tempDir: URL, - buildArgs: [String] = [], - otherArgs: [String] = [] - ) throws -> String { - try buildWithPaths( - tags: tags, - tempContext: tempDir, - tempDockerfileContext: tempDir, - buildArgs: buildArgs, - otherArgs: otherArgs - ) - } - - // buildWithPaths is a helper function for calling build with different paths for the build context and - // the dockerfile path. If both paths are the same, use `build` func above. - @discardableResult - func buildWithPaths( - tags: [String], - tempContext: URL, - tempDockerfileContext: URL, - buildArgs: [String] = [], - otherArgs: [String] = [] - ) throws -> String { - let contextDir: URL = tempContext.appendingPathComponent("context") - let contextDirPath = contextDir.absoluteURL.path - var args = [ - "build", - "-f", - tempDockerfileContext.appendingPathComponent("Dockerfile").path, - ] - for tag in tags { - args.append("-t") - args.append(tag) - } - for arg in buildArgs { - args.append("--build-arg") - args.append(arg) - } - args.append(contextDirPath) - - args.append(contentsOf: otherArgs) - - let response = try run(arguments: args) - if response.status != 0 { - throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") - } - - return response.output - } - - @discardableResult - func buildWithStdin( - tags: [String], - tempContext: URL, - dockerfileContents: String, - buildArgs: [String] = [], - otherArgs: [String] = [] - ) throws -> String { - let contextDir: URL = tempContext.appendingPathComponent("context") - let contextDirPath = contextDir.absoluteURL.path - var args = [ - "build", - "-f", - "-", - ] - for tag in tags { - args.append("-t") - args.append(tag) - } - for arg in buildArgs { - args.append("--build-arg") - args.append(arg) - } - args.append(contextDirPath) - - args.append(contentsOf: otherArgs) - - let stdinData = Data(dockerfileContents.utf8) - let response = try run(arguments: args, stdin: stdinData) - if response.status != 0 { - throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") - } - - return response.output - } - - enum FileSystemEntry { - case file( - _ path: String, - content: FileEntryContent, - permissions: FilePermissions = [.r, .w, .gr, .gw, .or, .ow], - uid: uid_t = 0, - gid: gid_t = 0 - ) - case directory( - _ path: String, - permissions: FilePermissions = [.r, .w, .x, .gr, .gw, .gx, .or, .ow, .ox], - uid: uid_t = 0, - gid: gid_t = 0 - ) - case symbolicLink( - _ path: String, - target: String, - uid: uid_t = 0, - gid: gid_t = 0 - ) - } - - func createEntry(_ entry: FileSystemEntry, _ contextDir: URL) throws { - switch entry { - // last 2 params are uid and gid - case .file(let path, let content, let permissions, _, _): - let fullPath = contextDir.appending(path: path) - // not using .absoluteURL deletes the last component from fullPath - let directory: URL = fullPath.absoluteURL.deletingLastPathComponent() - let contentPath = fullPath.path - - try FileManager.default.createDirectory( - atPath: directory.path, - withIntermediateDirectories: true, - attributes: nil - ) - - switch content { - case .data(let data): - try data.write(to: fullPath) - case .zeroFilled(let size): - let fd = open(contentPath, O_CREAT | O_WRONLY, permissions.rawValue) - if fd == -1 { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) } - defer { close(fd) } - ftruncate(fd, off_t(size)) - } - - // TODO: figure out why this block fails - // try FileManager.default.setAttributes( - // [ - // .posixPermissions: Int(permissions.rawValue), - // .ownerAccountID: uid, - // .groupOwnerAccountID: gid, - // ], - // ofItemAtPath: fullPath.absoluteURL.absoluteString - // ) - - case .directory(let path, let permissions, let uid, let gid): - let fullPath = contextDir.appendingPathComponent(path).absoluteURL - try FileManager.default.createDirectory( - atPath: fullPath.path, - withIntermediateDirectories: true, - attributes: [ - .posixPermissions: Int(permissions.rawValue), - .ownerAccountID: uid, - .groupOwnerAccountID: gid, - ] - ) - - case .symbolicLink(let path, let target, let uid, let gid): - let fullPath = contextDir.appendingPathComponent(path).absoluteURL - let directory: URL = fullPath.deletingLastPathComponent() - try FileManager.default.createDirectory( - atPath: directory.path, - withIntermediateDirectories: true, - attributes: nil - ) - let targetURL = contextDir.appendingPathComponent(target) - try FileManager.default.createSymbolicLink( - atPath: fullPath.path, - withDestinationPath: targetURL.relativePathFrom(from: fullPath) - ) - lchown(fullPath.path, uid, gid) - } - } - - struct FilePermissions: OptionSet { - let rawValue: UInt16 - - static let r = FilePermissions(rawValue: 0o400) - static let w = FilePermissions(rawValue: 0o200) - static let x = FilePermissions(rawValue: 0o100) - - static let gr = FilePermissions(rawValue: 0o040) - static let gw = FilePermissions(rawValue: 0o020) - static let gx = FilePermissions(rawValue: 0o010) - - static let or = FilePermissions(rawValue: 0o004) - static let ow = FilePermissions(rawValue: 0o002) - static let ox = FilePermissions(rawValue: 0o001) - } - - enum FileEntryContent { - case zeroFilled(size: Int64) - case data(Data) - } - - func builderStart(cpus: Int64 = 2, memoryInGBs: Int64 = 2) throws { - let (_, _, error, status) = try run(arguments: [ - "builder", - "start", - "-c", - "\(cpus)", - "-m", - "\(memoryInGBs)GB", - ]) - if status != 0 { - throw CLIError.executionFailed("command failed: \(error)") - } - } - - func builderStop() throws { - let (_, _, error, status) = try run(arguments: [ - "builder", - "stop", - ]) - if status != 0 { - throw CLIError.executionFailed("command failed: \(error)") - } - } - - func builderDelete(force: Bool = false) throws { - let (_, _, error, status) = try run( - arguments: [ - "builder", - "delete", - force ? "--force" : nil, - ].compactMap { $0 }) - if status != 0 { - throw CLIError.executionFailed("command failed: \(error)") - } - } - -} diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderEnvOnlyTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderEnvOnlyTest.swift deleted file mode 100644 index 77d103af2..000000000 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderEnvOnlyTest.swift +++ /dev/null @@ -1,163 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025-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 Foundation -import Testing - -extension TestCLIBuildBase { - class CLIBuilderEnvOnlyTest: TestCLIBuildBase { - override init() throws { - try super.init() - } - - deinit { - try? builderDelete(force: true) - } - - @Test func testBuildEnvironmentOnlyImageFromScratch() throws { - let tempDir: URL = try 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 createContext(tempDir: tempDir, dockerfile: dockerfile) - let imageName = "test-env-only:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir, buildArgs: ["BUILD_DATE=2025-01-01", "VERSION=2.0.0"]) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildEnvironmentOnlyImageFromAlpine() throws { - let tempDir: URL = try 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" \\ - description="Test environment-only image" - """ - - try createContext(tempDir: tempDir, dockerfile: dockerfile) - let imageName = "test-alpine-env:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testMultiStageBuildWithEnvOnlyBase() throws { - let tempDir: URL = try createTempDir() - let baseImageName = "test-env-base:\(UUID().uuidString)" - - // First, create an environment-only base image - let baseDockerfile = - """ - FROM scratch - - ARG JOBS=6 - ARG ARCH=amd64 - - ENV MAKEOPTS="-j${JOBS}" \\ - ARCH="${ARCH}" \\ - PATH=/usr/local/bin:/usr/bin - """ - - try createContext(tempDir: tempDir, dockerfile: baseDockerfile) - try self.build(tag: baseImageName, tempDir: tempDir, buildArgs: ["JOBS=8", "ARCH=arm64"]) - #expect(try self.inspectImage(baseImageName) == baseImageName, "expected base image to build successfully") - - // Now create a downstream image that uses it - let downstreamTempDir: URL = try createTempDir() - let downstreamDockerfile = - """ - FROM \(baseImageName) - - # Verify environment is inherited - note: can't use RUN with scratch base - LABEL test="env-inherited" - """ - - try createContext(tempDir: downstreamTempDir, dockerfile: downstreamDockerfile) - let downstreamImageName = "test-env-child:\(UUID().uuidString)" - try self.build(tag: downstreamImageName, tempDir: downstreamTempDir) - #expect( - try self.inspectImage(downstreamImageName) == downstreamImageName, - "expected downstream image to build successfully" - ) - } - - @Test func testComplexArgAndEnvCombinations() throws { - let tempDir: URL = try 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 createContext(tempDir: tempDir, dockerfile: dockerfile) - let imageName = "test-complex-env:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir, buildArgs: ["JOBS=12", "ARCH=arm64"]) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testLabelOnlyDockerfile() throws { - let tempDir: URL = try 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 createContext(tempDir: tempDir, dockerfile: dockerfile) - let imageName = "test-label-only:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - } -} diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderLifecycleTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderLifecycleTest.swift deleted file mode 100644 index 1f87ba993..000000000 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderLifecycleTest.swift +++ /dev/null @@ -1,84 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025-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 Foundation -import Testing - -extension TestCLIBuildBase { - class CLIBuilderLifecycleTest: TestCLIBuildBase { - override init() throws {} - @Test func testBuilderStartStopCommand() throws { - #expect(throws: Never.self) { - try self.builderStart() - try self.waitForBuilderRunning() - let status = try self.getContainerStatus("buildkit") - #expect(status == "running", "BuildKit container is not running") - } - #expect(throws: Never.self) { - try self.builderStop() - let status = try self.getContainerStatus("buildkit") - #expect(status == "stopped", "BuildKit container is not stopped") - } - } - - @Test func testBuilderEnvironmentColors() throws { - let testColors = "run=green:warning=yellow:error=red:cancel=cyan" - let testNoColor = "true" - - let originalColors = ProcessInfo.processInfo.environment["BUILDKIT_COLORS"] - let originalNoColor = ProcessInfo.processInfo.environment["NO_COLOR"] - - defer { - if let originalColors { - setenv("BUILDKIT_COLORS", originalColors, 1) - } else { - unsetenv("BUILDKIT_COLORS") - } - if let originalNoColor { - setenv("NO_COLOR", originalNoColor, 1) - } else { - unsetenv("NO_COLOR") - } - - try? builderStop() - try? builderDelete(force: true) - } - - setenv("BUILDKIT_COLORS", testColors, 1) - setenv("NO_COLOR", testNoColor, 1) - - try? builderStop() - try? builderDelete(force: true) - - let (_, _, err, status) = try run(arguments: ["builder", "start"]) - try #require(status == 0, "builder start failed: \(err)") - - try waitForBuilderRunning() - - let container = try inspectContainer("buildkit") - let envVars = container.configuration.initProcess.environment - - #expect( - envVars.contains("BUILDKIT_COLORS=\(testColors)"), - "Expected BUILDKIT_COLORS to be passed to container, but it was missing from env: \(envVars)" - ) - #expect( - envVars.contains("NO_COLOR=\(testNoColor)"), - "Expected NO_COLOR to be passed to container, but it was missing from env: \(envVars)" - ) - } - } -} diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderLocalOutputTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderLocalOutputTest.swift deleted file mode 100644 index edf0feca9..000000000 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderLocalOutputTest.swift +++ /dev/null @@ -1,262 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025-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 Foundation -import Testing - -extension TestCLIBuildBase { - class CLIBuilderLocalOutputTest: TestCLIBuildBase { - override init() throws { - try super.init() - } - - deinit { - try? builderDelete(force: true) - } - - @Test func testBuildLocalOutputHappyPath() throws { - let tempDir: URL = try createTempDir() - - // Test comprehensive multi-stage build with context and build arguments - let dockerfile: String = - """ - 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: [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 createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - - let outputDir = tempDir.appendingPathComponent("comprehensive-local-output") - let imageName = "local-comprehensive-test:\(UUID().uuidString)" - - let response = try buildWithLocalOutput( - tag: imageName, - tempDir: tempDir, - outputDir: outputDir, - args: ["MESSAGE=Hello from build args"] - ) - - // Verify the build succeeded - #expect(response.contains(outputDir.absolutePath()), "Expected successful local export message") - - // Verify the output directory was created - #expect(FileManager.default.fileExists(atPath: outputDir.path), "Expected local output directory to exist") - - // Verify the output contains expected structure - let contents = try FileManager.default.contentsOfDirectory(atPath: outputDir.path) - #expect(!contents.isEmpty, "Expected local output directory to contain files") - - // Test basic functionality - verify basic local output works - let basicTempDir: URL = try createTempDir() - let basicDockerfile: String = - """ - FROM scratch - - ADD testfile.txt /hello.txt - """ - let basicContext: [FileSystemEntry] = [ - .file("testfile.txt", content: .data("Hello from basic build\n".data(using: .utf8)!)) - ] - try createContext(tempDir: basicTempDir, dockerfile: basicDockerfile, context: basicContext) - - let basicOutputDir = basicTempDir.appendingPathComponent("basic-local-output") - let basicImageName = "local-basic-test:\(UUID().uuidString)" - - let basicResponse = try buildWithLocalOutput(tag: basicImageName, tempDir: basicTempDir, outputDir: basicOutputDir) - - // Verify basic build succeeded - #expect(basicResponse.contains(basicOutputDir.absolutePath()), "Expected successful basic local export message") - #expect(FileManager.default.fileExists(atPath: basicOutputDir.path), "Expected basic local output directory to exist") - - // Test context functionality - verify COPY works with context - let contextTempDir: URL = try createTempDir() - let contextDockerfile: String = - """ - FROM scratch - - COPY testfile.txt /app/testfile.txt - """ - let contextContext: [FileSystemEntry] = [ - .file("testfile.txt", content: .data("Test content for context build\n".data(using: .utf8)!)) - ] - try createContext(tempDir: contextTempDir, dockerfile: contextDockerfile, context: contextContext) - - let contextOutputDir = contextTempDir.appendingPathComponent("context-local-output") - let contextImageName = "local-context-test:\(UUID().uuidString)" - - let contextResponse = try buildWithLocalOutput(tag: contextImageName, tempDir: contextTempDir, outputDir: contextOutputDir) - - // Verify context build succeeded - #expect(contextResponse.contains(contextOutputDir.absolutePath()), "Expected successful context local export message") - #expect(FileManager.default.fileExists(atPath: contextOutputDir.path), "Expected context local output directory to exist") - } - - @Test func testBuildLocalOutputEdgeCases() throws { - // Test building with different context paths - let dockerfileCtxDir: URL = try createTempDir() - let dockerfile: String = - """ - FROM scratch - - COPY . /app - """ - let dockerfileCtx: [FileSystemEntry] = [ - .file("dockerfile-context.txt", content: .data("Dockerfile context file\n".data(using: .utf8)!)) - ] - try createContext(tempDir: dockerfileCtxDir, dockerfile: dockerfile, context: dockerfileCtx) - - let buildContextDir: URL = try createTempDir() - let buildContext: [FileSystemEntry] = [ - .file("build-context.txt", content: .data("Build context file\n".data(using: .utf8)!)) - ] - try createContext(tempDir: buildContextDir, dockerfile: "", context: buildContext) - - let outputDir = dockerfileCtxDir.appendingPathComponent("diffpaths-local-output") - let imageName = "local-diffpaths-test:\(UUID().uuidString)" - - let response = try buildWithPathsAndLocalOutput( - tag: imageName, - tempContext: buildContextDir, - tempDockerfileContext: dockerfileCtxDir, - outputDir: outputDir - ) - - // Verify the build succeeded - #expect(response.contains(outputDir.absolutePath()), "Expected successful local export message") - - // Verify the output directory exists - #expect(FileManager.default.fileExists(atPath: outputDir.path), "Expected local output directory to exist") - - // Test building to existing output directory - let existingTempDir: URL = try createTempDir() - let existingDockerfile: String = - """ - FROM scratch - - ADD newfile.txt /newfile.txt - """ - let existingContext: [FileSystemEntry] = [ - .file("newfile.txt", content: .data("New content from build\n".data(using: .utf8)!)) - ] - try createContext(tempDir: existingTempDir, dockerfile: existingDockerfile, context: existingContext) - - let existingOutputDir = existingTempDir.appendingPathComponent("existing-output") - - // Create the output directory and add some existing files - try FileManager.default.createDirectory(at: existingOutputDir, withIntermediateDirectories: true) - let existingFile = existingOutputDir.appendingPathComponent("existing.txt") - try "Existing file content\n".data(using: .utf8)!.write(to: existingFile) - - let existingImageName = "local-existing-test:\(UUID().uuidString)" - - let existingResponse = try buildWithLocalOutput(tag: existingImageName, tempDir: existingTempDir, outputDir: existingOutputDir) - - // Verify the build succeeded - #expect(existingResponse.contains(existingOutputDir.absolutePath()), "Expected successful local export message") - - // Verify the output directory exists - #expect(FileManager.default.fileExists(atPath: existingOutputDir.path), "Expected local output directory to exist") - - // Verify the existing file is still there (local output should merge/overwrite) - let contents = try FileManager.default.contentsOfDirectory(atPath: existingOutputDir.path) - #expect(!contents.isEmpty, "Expected local output directory to contain files") - - // The behavior may vary - local output might overwrite the directory or merge contents - // This test verifies that the operation completes successfully with an existing directory - } - - @Test func testBuildLocalOutputFailure() throws { - let tempDir: URL = try createTempDir() - let dockerfile: String = - """ - FROM scratch - - ADD test.txt /test.txt - """ - let context: [FileSystemEntry] = [ - .file("test.txt", content: .data("test\n".data(using: .utf8)!)) - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - - // Use a path that doesn't exist and can't be created (invalid parent) - let invalidOutputDir = URL(fileURLWithPath: "/nonexistent/invalid/path") - let imageName = "local-invalid-test:\(UUID().uuidString)" - - #expect(throws: CLIError.self) { - try buildWithLocalOutput(tag: imageName, tempDir: tempDir, outputDir: invalidOutputDir) - } - } - - // Helper function to build with local output - @discardableResult - func buildWithLocalOutput(tag: String, tempDir: URL, outputDir: URL, args: [String]? = nil) throws -> String { - try buildWithPathsAndLocalOutput( - tag: tag, - tempContext: tempDir, - tempDockerfileContext: tempDir, - outputDir: outputDir, - args: args - ) - } - - // Helper function to build with different paths and local output - @discardableResult - func buildWithPathsAndLocalOutput( - tag: String, - tempContext: URL, - tempDockerfileContext: URL, - outputDir: URL, - args: [String]? = nil - ) throws -> String { - let contextDir: URL = tempContext.appendingPathComponent("context") - let contextDirPath = contextDir.absoluteURL.path - var buildArgs = [ - "build", - "-f", - tempDockerfileContext.appendingPathComponent("Dockerfile").path, - "-t", - tag, - "--output", - "type=local,dest=\(outputDir.path)", - ] - if let args = args { - for arg in args { - buildArgs.append("--build-arg") - buildArgs.append(arg) - } - } - buildArgs.append(contextDirPath) - - let response = try run(arguments: buildArgs) - if response.status != 0 { - throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") - } - - return response.output - } - } -} diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderTarExportTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderTarExportTest.swift deleted file mode 100644 index d9ee962a7..000000000 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderTarExportTest.swift +++ /dev/null @@ -1,144 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025-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 Foundation -import Testing - -extension TestCLIBuildBase { - class CLIBuilderTarExportTest: TestCLIBuildBase { - override init() throws { - try super.init() - } - - deinit { - try? builderDelete(force: true) - } - - @Test func testBuildExportTar() throws { - let tempDir: URL = try createTempDir() - let dockerfile: String = - """ - FROM scratch - ADD emptyFile / - """ - let context: [FileSystemEntry] = [ - .file("emptyFile", content: .zeroFilled(size: 1)) - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - - let exportPath = tempDir.appendingPathComponent("export.tar") - let response = try run(arguments: [ - "build", - "-f", tempDir.appendingPathComponent("Dockerfile").path, - "-o", "type=tar,dest=\(exportPath.path)", - tempDir.appendingPathComponent("context").path, - ]) - - #expect(response.status == 0, "build with tar export should succeed") - #expect(FileManager.default.fileExists(atPath: exportPath.path), "tar file should exist at \(exportPath.path)") - #expect(response.output.contains(exportPath.path), "should show export success message") - - let attributes = try FileManager.default.attributesOfItem(atPath: exportPath.path) - let fileSize = attributes[.size] as? Int ?? 0 - #expect(fileSize > 0, "exported tar file should not be empty") - } - - @Test func testBuildExportTarToDirectory() throws { - let tempDir: URL = try createTempDir() - let dockerfile: String = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - RUN echo "test content" > /test.txt - """ - try createContext(tempDir: tempDir, dockerfile: dockerfile) - - let exportDir = tempDir.appendingPathComponent("exports") - try FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true) - - let response = try run(arguments: [ - "build", - "-f", tempDir.appendingPathComponent("Dockerfile").path, - "-o", "type=tar,dest=\(exportDir.path)", - tempDir.appendingPathComponent("context").path, - ]) - - #expect(response.status == 0, "build with tar export to directory should succeed") - - let expectedTar = exportDir.appendingPathComponent("out.tar") - #expect(FileManager.default.fileExists(atPath: expectedTar.path), "tar file should exist at \(expectedTar.path)") - #expect(response.output.contains(expectedTar.path), "should show export success message") - } - - @Test func testBuildExportTarMultipleRuns() throws { - let tempDir: URL = try createTempDir() - let dockerfile: String = - """ - FROM scratch - ADD testFile / - """ - let context: [FileSystemEntry] = [ - .file("testFile", content: .data("test data".data(using: .utf8)!)) - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - - let exportDir = tempDir.appendingPathComponent("exports") - try FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true) - - // First build - var response = try run(arguments: [ - "build", - "-f", tempDir.appendingPathComponent("Dockerfile").path, - "-o", "type=tar,dest=\(exportDir.path)", - tempDir.appendingPathComponent("context").path, - ]) - #expect(response.status == 0, "first build should succeed") - - let firstTar = exportDir.appendingPathComponent("out.tar") - #expect(FileManager.default.fileExists(atPath: firstTar.path), "first tar should exist") - - // Second build - should create out.tar.1 - response = try run(arguments: [ - "build", - "-f", tempDir.appendingPathComponent("Dockerfile").path, - "-o", "type=tar,dest=\(exportDir.path)", - tempDir.appendingPathComponent("context").path, - ]) - #expect(response.status == 0, "second build should succeed") - - let secondTar = exportDir.appendingPathComponent("out.tar.1") - #expect(FileManager.default.fileExists(atPath: secondTar.path), "second tar should exist at out.tar.1") - } - - @Test func testBuildExportTarInvalidDest() throws { - let tempDir: URL = try createTempDir() - let dockerfile: String = - """ - FROM scratch - """ - try createContext(tempDir: tempDir, dockerfile: dockerfile) - - let response = try run(arguments: [ - "build", - "-f", tempDir.appendingPathComponent("Dockerfile").path, - "-o", "type=tar", // Missing dest parameter - tempDir.appendingPathComponent("context").path, - ]) - - #expect(response.status != 0, "build without dest should fail") - #expect(response.error.contains("dest field is required"), "error should mention missing dest") - } - } -} diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift deleted file mode 100644 index da7a2f2fd..000000000 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift +++ /dev/null @@ -1,1560 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025-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 ContainerizationOCI -import Foundation -import Testing - -extension TestCLIBuildBase { - class CLIBuilderTest: TestCLIBuildBase { - override init() throws { - try super.init() - } - - deinit { - try? builderDelete(force: true) - } - - @Test func testBuildDotFileSucceeds() throws { - let tempDir: URL = try createTempDir() - let dockerfile: String = - """ - FROM scratch - - ADD emptyFile / - """ - let context: [FileSystemEntry] = [ - .file("emptyFile", content: .zeroFilled(size: 1)), - .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - let imageName = "registry.local/dot-file:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildFromPreviousStage() throws { - let tempDir: URL = try createTempDir() - let dockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 AS layer1 - RUN sh -c "echo 'layer1' > /layer1.txt" - - FROM layer1 - CMD ["cat", "/layer1.txt"] - """ - - try createContext(tempDir: tempDir, dockerfile: dockerfile) - let imageName = "registry.local/from-previous-layer:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully build \(imageName)") - } - - @Test func testBuildFromLocalImage() throws { - let tempDir: URL = try createTempDir() - let dockerfile: String = - """ - FROM scratch - - ADD emptyFile / - """ - let context: [FileSystemEntry] = [ - .file("emptyFile", content: .zeroFilled(size: 0)), - .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - let imageName = "local-only:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - - let newTempDir: URL = try createTempDir() - let newDockerfile: String = - """ - FROM \(imageName) - """ - let newContext: [FileSystemEntry] = [] - try createContext(tempDir: newTempDir, dockerfile: newDockerfile, context: newContext) - let newImageName = "from-local:\(UUID().uuidString)" - try self.build(tag: newImageName, tempDir: newTempDir) - #expect(try self.inspectImage(newImageName) == newImageName, "expected to have successfully built \(newImageName)") - } - - @Test func testBuildAddFromSpecialDirs() throws { - let tempDir = URL(filePath: "/tmp/container/.clitests/\(testSuite)/\(testName)") - try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile: String = - """ - FROM scratch - - ADD emptyFile / - """ - let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - let imageName = "registry.local/scratch-add-special-dir:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildScratchAdd() throws { - let tempDir: URL = try createTempDir() - let dockerfile: String = - """ - FROM scratch - - ADD emptyFile / - """ - let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - let imageName = "registry.local/scratch-add:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildAddAll() throws { - let tempDir: URL = try createTempDir() - let dockerfile: String = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - - ADD . . - - RUN cat emptyFile - RUN cat Test/testempty - """ - let context: [FileSystemEntry] = [ - .directory("Test"), - .file("Test/testempty", content: .zeroFilled(size: 1)), - .file("emptyFile", content: .zeroFilled(size: 1)), - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - let imageName: String = "registry.local/add-all:\(UUID().uuidString)" - let outputRef = try self.build(tag: imageName, tempDir: tempDir) - #expect(outputRef.contains(imageName), "expected stdout to container image reference") - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildArg() throws { - let tempDir: URL = try createTempDir() - let dockerfile: String = - """ - ARG TAG=unknown - FROM ghcr.io/linuxcontainers/alpine:${TAG} - """ - try createContext(tempDir: tempDir, dockerfile: dockerfile) - let imageName: String = "registry.local/build-arg:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir, buildArgs: ["TAG=3.20"]) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildSecret() throws { - let tempDir: URL = try createTempDir() - let dockerfile: String = - """ - 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 - """ - try createContext(tempDir: tempDir, dockerfile: dockerfile) - setenv("ENV1", "x", 1) - setenv("ENV_VAR", "yy", 1) - setenv("env3", "zzz", 1) - let testData = Data((0..<17).flatMap { _ in Array(0...255) }) - let tempFile: URL = try createTempFile(suffix: " _f,i=l.e+ ", contents: testData) - let tempFile2: URL = try createTempFile(suffix: "file2", contents: Data()) - let imageName: String = "registry.local/secrets:\(UUID().uuidString)" - try self.build( - tag: imageName, tempDir: tempDir, - otherArgs: [ - "--secret", "id=ENV1", - "--secret", "id=env2,env=ENV_VAR", - "--secret", "id=env3,env=env3", - "--secret", "id=file,src=" + tempFile.path, - "--secret", "id=empty,src=" + tempFile2.path, - ]) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildNetworkAccess() throws { - let tempDir: URL = try createTempDir() - let dockerfile: String = - """ - 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 - """ - try createContext(tempDir: tempDir, dockerfile: dockerfile) - let imageName = "registry.local/build-network-access:\(UUID().uuidString)" - - var buildArgs: [String] = [] - for key in ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"] { - if let value = ProcessInfo.processInfo.environment[key] { - buildArgs.append("\(key)=\(value)") - } - } - try self.build(tag: imageName, tempDir: tempDir, buildArgs: buildArgs) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildDockerfileKeywords() throws { - let tempDir: URL = try createTempDir() - let dockerfile = - """ - # stage 1 Meta ARG - ARG TAG=3.20 - FROM ghcr.io/linuxcontainers/alpine:${TAG} - - # stage 2 RUN - FROM ghcr.io/linuxcontainers/alpine:3.20 - RUN echo "Hello, World!" > /hello.txt - - # stage 3 - RUN [] - FROM ghcr.io/linuxcontainers/alpine:3.20 - RUN ["sh", "-c", "echo 'Exec form' > /exec.txt"] - - # stage 4 - CMD - FROM ghcr.io/linuxcontainers/alpine:3.20 - CMD ["echo", "Exec default"] - - # stage 5 - CMD [] - FROM ghcr.io/linuxcontainers/alpine:3.20 - CMD ["echo", "Exec'ing"] - - #stage 6 - LABEL - FROM ghcr.io/linuxcontainers/alpine:3.20 - LABEL version="1.0" description="Test image" - - # stage 7 - EXPOSE - FROM ghcr.io/linuxcontainers/alpine:3.20 - EXPOSE 8080 - - # stage 8 - ENV - FROM ghcr.io/linuxcontainers/alpine:3.20 - ENV MY_ENV=hello - RUN echo $MY_ENV > /env.txt - - # stage 9 - ADD - FROM ghcr.io/linuxcontainers/alpine:3.20 - ADD emptyFile / - - # stage 10 - COPY - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY toCopy /toCopy - - # stage 11 - ENTRYPOINT - FROM ghcr.io/linuxcontainers/alpine:3.20 - ENTRYPOINT ["echo", "entrypoint!"] - - # stage 12 - VOLUME - FROM ghcr.io/linuxcontainers/alpine:3.20 - VOLUME /data - - # stage 13 - USER - FROM ghcr.io/linuxcontainers/alpine:3.20 - RUN adduser -D myuser - USER myuser - CMD whoami - - # stage 14 - WORKDIR - FROM ghcr.io/linuxcontainers/alpine:3.20 - WORKDIR /app - RUN pwd > /pwd.out - - # stage 15 - ARG - FROM ghcr.io/linuxcontainers/alpine:3.20 - ARG MY_VAR=default - RUN echo $MY_VAR > /var.out - - # stage 16 - ONBUILD - # FROM ghcr.io/linuxcontainers/alpine:3.20 - # ONBUILD RUN echo "onbuild triggered" > /onbuild.out - - # stage 17 - STOPSIGNAL - # FROM ghcr.io/linuxcontainers/alpine:3.20 - # STOPSIGNAL SIGTERM - - # stage 18 - HEALTHCHECK - # FROM ghcr.io/linuxcontainers/alpine:3.20 - # HEALTHCHECK CMD echo "healthy" || exit 1 - - # stage 19 - SHELL - # FROM ghcr.io/linuxcontainers/alpine:3.20 - # SHELL ["/bin/sh", "-c"] - # RUN echo $0 > /shell.txt - """ - - let context: [FileSystemEntry] = [ - .file("emptyFile", content: .zeroFilled(size: 1)), - .file("toCopy", content: .zeroFilled(size: 1)), - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - - let imageName = "registry.local/dockerfile-keywords:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildSymlink() throws { - let tempDir: URL = try createTempDir() - let dockerfile: String = - """ - # Test 1: Test basic symlinking - FROM ghcr.io/linuxcontainers/alpine:3.20 - - ADD Test1Source Test1Source - ADD Test1Source2 Test1Source2 - - RUN cat Test1Source2/test.yaml - - # Test2: Test symlinks in nested directories - FROM ghcr.io/linuxcontainers/alpine:3.20 - - ADD Test2Source Test2Source - ADD Test2Source2 Test2Source2 - - RUN cat Test2Source2/Test/test.txt - - # Test 3: Test symlinks to directories work - FROM ghcr.io/linuxcontainers/alpine:3.20 - - ADD Test3Source Test3Source - ADD Test3Source2 Test3Source2 - - RUN cat Test3Source2/Dest/test.txt - """ - let context: [FileSystemEntry] = [ - // test 1 - .directory("Test1Source"), - .directory("Test1Source2"), - .file("Test1Source/test.yaml", content: .zeroFilled(size: 200)), - .symbolicLink("Test1Source2/test.yaml", target: "Test1Source/test.yaml"), - - // test 2 - .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"), - - // test 3 - .directory("Test3Source/Source"), - .directory("Test3Source2"), - .file("Test3Source/Source/test.txt", content: .zeroFilled(size: 1)), - .symbolicLink("Test3Source2/Dest", target: "Test3Source/Source"), - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - let imageName = "registry.local/build-symlinks:\(UUID().uuidString)" - - #expect(throws: Never.self) { - try self.build(tag: imageName, tempDir: tempDir) - } - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildAndRun() throws { - let name: String = "test-build-and-run" - - let tempDir: URL = try createTempDir() - let dockerfile: String = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - RUN echo "foobar" > /file - """ - let context: [FileSystemEntry] = [] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - let imageName = "\(name):latest" - let containerName = "\(name)-container" - try self.build(tag: imageName, tempDir: tempDir) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - // Check if the image we built is actually in the image store, and can be used. - try self.doLongRun(name: containerName, image: imageName) - defer { - try? self.doStop(name: containerName) - } - var output = try doExec(name: containerName, cmd: ["cat", "/file"]) - output = output.trimmingCharacters(in: .whitespacesAndNewlines) - let expected = "foobar" - try self.doStop(name: containerName) - #expect(output == expected, "expected file contents to be \(expected), instead got \(output)") - } - - @Test func testBuildDifferentPaths() throws { - let buildContextDir: URL = try createTempDir() - let dockerfile: String = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - - RUN ls ./ - COPY . /root - - RUN cat /root/Test/test.txt - """ - let buildContext: [FileSystemEntry] = [ - .directory(".git"), - .file(".git/FETCH", content: .zeroFilled(size: 1)), - .directory("Test"), - .file("Test/test.txt", content: .zeroFilled(size: 1)), - ] - try createContext(tempDir: buildContextDir, dockerfile: dockerfile, context: buildContext) - - let imageName = "registry.local/build-diff-context:\(UUID().uuidString)" - #expect(throws: Never.self) { - try self.build(tags: [imageName], tempDir: buildContextDir) - } - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildMultiArch() throws { - let tempDir: URL = try createTempDir() - let dockerfile: String = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - - ADD . . - - RUN cat emptyFile - RUN cat Test/testempty - """ - let context: [FileSystemEntry] = [ - .directory("Test"), - .file("Test/testempty", content: .zeroFilled(size: 1)), - .file("emptyFile", content: .zeroFilled(size: 1)), - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - let imageName: String = "registry.local/multi-arch:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir, otherArgs: ["--arch", "amd64,arm64"]) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - - let output = try doInspectImages(image: imageName) - #expect(output.count == 1, "expected a single image inspect output, got \(output)") - - let expected = Set([ - Platform(arch: "amd64", os: "linux", variant: nil), - Platform(arch: "arm64", os: "linux", variant: nil), - ]) - let actual = Set( - output[0].variants.map { v in - Platform(arch: v.platform.architecture, os: v.platform.os, variant: nil) - }) - #expect( - actual == expected, - "expected platforms \(expected), got \(actual)" - ) - } - - @Test func testBuildMultipleTags() throws { - let tempDir: URL = try createTempDir() - let dockerfile: String = - """ - FROM scratch - - ADD emptyFile / - """ - let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - - 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 outputRef = try self.build(tags: [tag1, tag2, tag3], tempDir: tempDir) - - #expect(outputRef.contains(tag1), "expected tag in output") - #expect(outputRef.contains(tag2), "expected tag in output") - #expect(outputRef.contains(tag3), "expected tag in output") - - // Verify all three tags exist and point to the same image - #expect(try self.inspectImage(tag1) == tag1, "expected to have successfully built \(tag1)") - #expect(try self.inspectImage(tag2) == tag2, "expected to have successfully built \(tag2)") - #expect(try self.inspectImage(tag3) == tag3, "expected to have successfully built \(tag3)") - } - - @Test func testBuildAfterContextChange() throws { - let name = "test-build-context-change" - let tempDir: URL = try createTempDir() - - // Create initial context with file "foo" containing "initial" - let dockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY foo /foo - COPY bar /bar - """ - let initialContent = "initial".data(using: .utf8)! - let context: [FileSystemEntry] = [ - .file("foo", content: .data(Data((0..<4 * 1024 * 1024).map { UInt8($0 % 256) }))), - .file("bar", content: .data(initialContent)), - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - - // Build first image - let imageName1 = "\(name):v1" - let containerName1 = "\(name)-container-v1" - try self.build(tag: imageName1, tempDir: tempDir) - #expect(try self.inspectImage(imageName1) == imageName1, "expected to have successfully built \(imageName1)") - - // Run container and verify content is "initial" - try self.doLongRun(name: containerName1, image: imageName1) - defer { - try? self.doStop(name: containerName1) - } - var output = try doExec(name: containerName1, cmd: ["cat", "/bar"]) - #expect(output == "initial", "expected file contents to be 'initial', instead got '\(output)'") - - // Update the file "foo" to contain "updated" - let updatedContent = "updated".data(using: .utf8)! - let contextDir = tempDir.appendingPathComponent("context") - let barPath = contextDir.appendingPathComponent("bar") - try updatedContent.write(to: barPath, options: .atomic) - - // Build second image - let imageName2 = "\(name):v2" - let containerName2 = "\(name)-container-v2" - try self.build(tag: imageName2, tempDir: tempDir) - #expect(try self.inspectImage(imageName2) == imageName2, "expected to have successfully built \(imageName2)") - - // Run container and verify content is "updated" - try self.doLongRun(name: containerName2, image: imageName2) - defer { - try? self.doStop(name: containerName2) - } - output = try doExec(name: containerName2, cmd: ["cat", "/bar"]) - #expect(output == "updated", "expected file contents to be 'updated', instead got '\(output)'") - } - - @Test func testBuildWithDockerfileFromStdin() throws { - let tempDir: URL = try createTempDir() - let dockerfile = - """ - FROM scratch - - ADD emptyFile / - """ - let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))] - try createContext(tempDir: tempDir, dockerfile: "", context: context) - let imageName = "registry.local/stdin-file:\(UUID().uuidString)" - try buildWithStdin(tags: [imageName], tempContext: tempDir, dockerfileContents: dockerfile) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testLowercaseDockerfile() throws { - // Test 1: COPY with uppercase - let tempDir1: URL = try createTempDir() - let dockerfile1 = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY . /app - RUN test -f /app/testfile.txt - """ - let context1: [FileSystemEntry] = [ - .file("testfile.txt", content: .data("test".data(using: .utf8)!)) - ] - try createContext(tempDir: tempDir1, dockerfile: dockerfile1, context: context1) - let imageName1 = "registry.local/copy-uppercase:\(UUID().uuidString)" - try self.build(tag: imageName1, tempDir: tempDir1) - #expect(try self.inspectImage(imageName1) == imageName1, "expected COPY to work") - - // Test 2: copy with lowercase - let tempDir2: URL = try createTempDir() - let dockerfile2 = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - copy . /app - RUN test -f /app/testfile.txt - """ - let context2: [FileSystemEntry] = [ - .file("testfile.txt", content: .data("test".data(using: .utf8)!)) - ] - try createContext(tempDir: tempDir2, dockerfile: dockerfile2, context: context2) - let imageName2 = "registry.local/copy-lowercase:\(UUID().uuidString)" - try self.build(tag: imageName2, tempDir: tempDir2) - #expect(try self.inspectImage(imageName2) == imageName2, "expected copy to work") - - // Test 3: ADD with uppercase - let tempDir3: URL = try createTempDir() - let dockerfile3 = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - ADD . /app - RUN test -f /app/testfile.txt - """ - let context3: [FileSystemEntry] = [ - .file("testfile.txt", content: .data("test".data(using: .utf8)!)) - ] - try createContext(tempDir: tempDir3, dockerfile: dockerfile3, context: context3) - let imageName3 = "registry.local/add-uppercase:\(UUID().uuidString)" - try self.build(tag: imageName3, tempDir: tempDir3) - #expect(try self.inspectImage(imageName3) == imageName3, "expected ADD to work") - - // Test 4: add with lowercase - let tempDir4: URL = try createTempDir() - let dockerfile4 = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - add . /app - RUN test -f /app/testfile.txt - """ - let context4: [FileSystemEntry] = [ - .file("testfile.txt", content: .data("test".data(using: .utf8)!)) - ] - try createContext(tempDir: tempDir4, dockerfile: dockerfile4, context: context4) - let imageName4 = "registry.local/add-lowercase:\(UUID().uuidString)" - try self.build(tag: imageName4, tempDir: tempDir4) - #expect(try self.inspectImage(imageName4) == imageName4, "expected add to work") - } - - @Test func testRunWithBindMount() throws { - let tempDir: URL = try createTempDir() - let dockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - - # Use bind mount to access build context during RUN - RUN --mount=type=bind,source=.,target=/mnt/context \ - set -e; \ - echo "Checking files in bind mount..."; \ - ls -la /mnt/context/; \ - \ - echo "Verifying files are accessible in mount..."; \ - if [ ! -f /mnt/context/app.py ]; then \ - echo "ERROR: app.py should be in bind mount!"; \ - exit 1; \ - fi; \ - if [ ! -f /mnt/context/config.yaml ]; then \ - echo "ERROR: config.yaml should be in bind mount!"; \ - exit 1; \ - fi; \ - \ - echo "RUN --mount bind check passed!"; \ - cp /mnt/context/app.py /app.py - - RUN cat /app.py - """ - - let context: [FileSystemEntry] = [ - .file("app.py", content: .data("print('Hello from bind mount')".data(using: .utf8)!)), - .file("config.yaml", content: .data("key: value".data(using: .utf8)!)), - ] - - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - let imageName = "registry.local/bind-mount-test:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildDockerIgnore() throws { - let tempDir: URL = try createTempDir() - let dockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - - # Copy all files - should respect .dockerignore - COPY . /app - - # Verify specific files are excluded - RUN set -e; \ - echo "Checking specific file exclusion..."; \ - if [ -f /app/secret.txt ]; then \ - echo "ERROR: secret.txt should be excluded!"; \ - exit 1; \ - fi - - # Verify wildcard *.log files are excluded - RUN set -e; \ - echo "Checking *.log exclusion..."; \ - if [ -f /app/debug.log ]; then \ - echo "ERROR: debug.log should be excluded by *.log pattern!"; \ - exit 1; \ - fi; \ - if ls /app/logs/*.log 2>/dev/null; then \ - echo "ERROR: logs/*.log files should be excluded!"; \ - exit 1; \ - fi - - # Verify exception pattern (!important.log) works - RUN set -e; \ - echo "Checking exception pattern..."; \ - if [ ! -f /app/important.log ]; then \ - echo "ERROR: important.log should be included (exception with !)"; \ - exit 1; \ - fi - - # Verify *.tmp files are excluded - RUN set -e; \ - echo "Checking *.tmp exclusion..."; \ - if find /app -name "*.tmp" | grep .; then \ - echo "ERROR: .tmp files should be excluded!"; \ - exit 1; \ - fi - - # Verify directories are excluded - RUN set -e; \ - echo "Checking directory exclusion..."; \ - if [ -d /app/temp ]; then \ - echo "ERROR: temp/ directory should be excluded!"; \ - exit 1; \ - fi; \ - if [ -d /app/node_modules ]; then \ - echo "ERROR: node_modules/ should be excluded!"; \ - exit 1; \ - fi - - # Verify included files ARE present - RUN set -e; \ - echo "Checking included files..."; \ - if [ ! -f /app/main.go ]; then \ - echo "ERROR: main.go should be included!"; \ - exit 1; \ - fi; \ - if [ ! -f /app/README.md ]; then \ - echo "ERROR: README.md should be included!"; \ - exit 1; \ - fi; \ - if [ ! -f /app/src/app.go ]; then \ - echo "ERROR: src/app.go should be included!"; \ - exit 1; \ - fi; \ - echo "All .dockerignore checks passed!" - """ - - let dockerignore = - """ - # Exclude specific files - secret.txt - - # Exclude all log files - *.log - **/*.log - - # But make an exception for important.log - !important.log - - # Exclude all temporary files - *.tmp - **/*.tmp - - # Exclude directories - temp/ - node_modules/ - """ - - let context: [FileSystemEntry] = [ - .file(".dockerignore", content: .data(dockerignore.data(using: .utf8)!)), - .file("secret.txt", content: .data("secret content".data(using: .utf8)!)), - .file("debug.log", content: .data("debug log content".data(using: .utf8)!)), - .file("important.log", content: .data("important log content".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("temp/cache.tmp", content: .data("temp cache".data(using: .utf8)!)), - .directory("logs"), - .file("logs/app.log", content: .data("app log".data(using: .utf8)!)), - .directory("node_modules"), - .file("node_modules/package.json", content: .data("{}".data(using: .utf8)!)), - .directory("src"), - .file("src/app.go", content: .data("package src".data(using: .utf8)!)), - .file("src/test.tmp", content: .data("temp".data(using: .utf8)!)), - ] - - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - let imageName = "registry.local/dockerignore-test:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - // Test 1: Basic .dockerignore - @Test func testDockerIgnoreBasic() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - WORKDIR /app - COPY . . - """ - let context: [FileSystemEntry] = [ - .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file("included.txt", content: .data("This file should be included in the build context.\n".data(using: .utf8)!)), - .file("ignored.txt", content: .data("This file should be ignored by .dockerignore.\n".data(using: .utf8)!)), - .file(".dockerignore", content: .data("ignored.txt\n".data(using: .utf8)!)), - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - - let contextDir = tempDir.appendingPathComponent("context") - let dockerfilePath = contextDir.appendingPathComponent("Dockerfile") - let imageName = "registry.local/dockerignore-basic:\(UUID().uuidString)" - let args = ["build", "-f", dockerfilePath.path, "-t", imageName, contextDir.path] - let response = try run(arguments: args) - if response.status != 0 { - throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") - } - - let containerName = "dockerignore-basic-\(UUID().uuidString)" - try self.doLongRun(name: containerName, image: imageName) - defer { try? self.doStop(name: containerName) } - - let includedResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/included.txt"]) - #expect(includedResult.status == 0, "included.txt should be present in the image") - - let ignoredResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/ignored.txt"]) - #expect(ignoredResult.status != 0, "ignored.txt should NOT be present in the image") - } - - // Test 2: Dockerfile-specific ignore file (Dockerfile.dockerignore takes precedence over .dockerignore) - @Test func testDockerIgnoreDockerfileSpecific() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - WORKDIR /app - COPY . . - """ - // .dockerignore ignores general.txt; Dockerfile.dockerignore ignores specific.txt. - // When both exist, Dockerfile.dockerignore takes precedence, so general.txt is included. - // Dockerfile and its .dockerignore must be co-located; here both live in the context root. - let context: [FileSystemEntry] = [ - .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file(".dockerignore", content: .data("general.txt\n".data(using: .utf8)!)), - .file("Dockerfile.dockerignore", content: .data("specific.txt\n".data(using: .utf8)!)), - .file("general.txt", content: .data("This file should be included (Dockerfile.dockerignore takes precedence over .dockerignore).\n".data(using: .utf8)!)), - .file("specific.txt", content: .data("This file should be ignored by Dockerfile.dockerignore.\n".data(using: .utf8)!)), - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - - let contextDir = tempDir.appendingPathComponent("context") - let dockerfilePath = contextDir.appendingPathComponent("Dockerfile") - let imageName = "registry.local/dockerignore-specific:\(UUID().uuidString)" - let args = ["build", "-f", dockerfilePath.path, "-t", imageName, contextDir.path] - let response = try run(arguments: args) - if response.status != 0 { - throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") - } - - let containerName = "dockerignore-specific-\(UUID().uuidString)" - try self.doLongRun(name: containerName, image: imageName) - defer { try? self.doStop(name: containerName) } - - let specificResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/specific.txt"]) - #expect(specificResult.status != 0, "specific.txt should NOT be present (ignored by Dockerfile.dockerignore)") - - let generalResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/general.txt"]) - #expect(generalResult.status == 0, "general.txt should be present (only in .dockerignore, not Dockerfile.dockerignore)") - - let listResult = try run(arguments: ["exec", containerName, "ls", "-a"]) - let listFiles = listResult.output.components(separatedBy: "\n").filter { !$0.isEmpty && $0 != "." && $0 != ".." } - #expect(Set(listFiles) == Set(["Dockerfile", ".dockerignore", "Dockerfile.dockerignore", "general.txt"]), "temporary directory must not be detected") - } - - @Test func testDockerIgnoreOutsideContext() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - WORKDIR /app - COPY . . - """ - // .dockerignore ignores general.txt; Dockerfile.dockerignore ignores specific.txt. - // When both exist, Dockerfile.dockerignore takes precedence, so general.txt is included. - // Dockerfile and its .dockerignore must be co-located; here both live in the context root. - let context: [FileSystemEntry] = [ - .file(".dockerignore", content: .data("general.txt\n".data(using: .utf8)!)), - .file("general.txt", content: .data("This file should be included (Dockerfile.dockerignore takes precedence over .dockerignore).\n".data(using: .utf8)!)), - .file("specific.txt", content: .data("This file should be ignored by Dockerfile.dockerignore.\n".data(using: .utf8)!)), - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - - let dockerignore = "specific.txt\n".data(using: .utf8)! - try dockerignore.write(to: tempDir.appendingPathComponent("Dockerfile.dockerignore"), options: .atomic) - - let contextDir = tempDir.appendingPathComponent("context") - let dockerfilePath = tempDir.appendingPathComponent("Dockerfile") - let imageName = "registry.local/dockerignore-specific:\(UUID().uuidString)" - let args = ["build", "-f", dockerfilePath.path, "-t", imageName, contextDir.path] - let response = try run(arguments: args) - if response.status != 0 { - throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") - } - - let containerName = "dockerignore-specific-\(UUID().uuidString)" - try self.doLongRun(name: containerName, image: imageName) - defer { try? self.doStop(name: containerName) } - - let specificResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/specific.txt"]) - #expect(specificResult.status != 0, "specific.txt should NOT be present (ignored by Dockerfile.dockerignore)") - - let generalResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/general.txt"]) - #expect(generalResult.status == 0, "general.txt should be present (only in .dockerignore, not Dockerfile.dockerignore)") - } - - // Test 5: Build succeeds when Dockerfile is listed in .dockerignore - @Test func testDockerIgnoreIgnoredDockerfile() async throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - WORKDIR /app - COPY . . - """ - // Dockerfile is listed in .dockerignore but build must still succeed. - // Dockerfile lives in the context root so the ignore rule applies to it. - let context: [FileSystemEntry] = [ - .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file(".dockerignore", content: .data("Dockerfile\n.dockerignore\n".data(using: .utf8)!)), - .file("test.txt", content: .data("This file should be included even though Dockerfile is ignored.\n".data(using: .utf8)!)), - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - - let contextDir = tempDir.appendingPathComponent("context") - let dockerfilePath = contextDir.appendingPathComponent("Dockerfile") - let imageName = "registry.local/dockerignore-ignored-dockerfile:\(UUID().uuidString)" - let args = ["build", "-f", dockerfilePath.path, "-t", imageName, contextDir.path] - let response = try run(arguments: args) - if response.status != 0 { - throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") - } - - let containerName = "dockerignore-ignored-dockerfile" - try self.doLongRun(name: containerName, image: imageName) - defer { try? self.doStop(name: containerName) } - - let dockerfileResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/Dockerfile"]) - #expect(dockerfileResult.status != 0, "Dockerfile should NOT be present in the image") - - let dockerignoreResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/.dockerignore"]) - #expect(dockerignoreResult.status != 0, ".dockerignore should NOT be present in the image") - - let testFileResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/test.txt"]) - #expect(testFileResult.status == 0, "test.txt should be present in the image") - } - - // Test 8: Dockerfile in nested subdirectory; Dockerfile.dockerignore next to it takes precedence over root .dockerignore - @Test func testDockerIgnoreSubdirDockerfile() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - WORKDIR /app - COPY . . - """ - // Root .dockerignore ignores included.txt; nested Dockerfile.dockerignore ignores secret.txt - // When Dockerfile is in nested/project/, Dockerfile.dockerignore next to it takes precedence - let context: [FileSystemEntry] = [ - .file(".dockerignore", content: .data("included.txt\n".data(using: .utf8)!)), - .file("included.txt", content: .data("This file should be included (Dockerfile.dockerignore takes precedence).\n".data(using: .utf8)!)), - .file("secret.txt", content: .data("This file should be ignored by Dockerfile.dockerignore.\n".data(using: .utf8)!)), - .file("nested/secret.txt", content: .data("This file should be ignored by Dockerfile.dockerignore.\n".data(using: .utf8)!)), - .file("nested/project/Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file("nested/project/Dockerfile.dockerignore", content: .data("secret.txt\n**/secret.txt\n".data(using: .utf8)!)), - .file("nested/project/config.txt", content: .data("This config file should be included.\n".data(using: .utf8)!)), - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - - let contextDir = tempDir.appendingPathComponent("context") - let nestedDockerfile = contextDir.appendingPathComponent("nested/project/Dockerfile") - let imageName = "registry.local/dockerignore-subdir:\(UUID().uuidString)" - let args = ["build", "-f", nestedDockerfile.path, "-t", imageName, contextDir.path] - let response = try run(arguments: args) - if response.status != 0 { - throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") - } - - let containerName = "dockerignore-subdir-\(UUID().uuidString)" - try self.doLongRun(name: containerName, image: imageName) - defer { try? self.doStop(name: containerName) } - - let includedResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/included.txt"]) - #expect(includedResult.status == 0, "included.txt should be present (Dockerfile.dockerignore takes precedence over .dockerignore)") - - let secretResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/secret.txt"]) - #expect(secretResult.status != 0, "secret.txt should NOT be present (ignored by Dockerfile.dockerignore)") - - let nestedSecretResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/nested/secret.txt"]) - #expect(nestedSecretResult.status != 0, "nested/secret.txt should NOT be present (ignored by Dockerfile.dockerignore)") - - let configResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/nested/project/config.txt"]) - #expect(configResult.status == 0, "nested/project/config.txt should be present") - } - - // Test 9: Custom-named Dockerfile (app1.Dockerfile) uses app1.Dockerfile.dockerignore - @Test func testDockerIgnoreCustomDockerfileName() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - WORKDIR /app - COPY . . - """ - // .dockerignore ignores generic.txt; app1.Dockerfile.dockerignore ignores app1-specific.txt - // When building with -f app1.Dockerfile, app1.Dockerfile.dockerignore takes precedence - let context: [FileSystemEntry] = [ - .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file(".dockerignore", content: .data("generic.txt\n".data(using: .utf8)!)), - .file("app1.Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file("app1.Dockerfile.dockerignore", content: .data("app1-specific.txt\n".data(using: .utf8)!)), - .file("app1-specific.txt", content: .data("This file should be ignored by app1.Dockerfile.dockerignore.\n".data(using: .utf8)!)), - .file("generic.txt", content: .data("This file should be included (only in .dockerignore, not app1.Dockerfile.dockerignore).\n".data(using: .utf8)!)), - .file("included.txt", content: .data("This file should always be included.\n".data(using: .utf8)!)), - ] - try createContext(tempDir: tempDir, dockerfile: "", context: context) - - let contextDir = tempDir.appendingPathComponent("context") - let customDockerfile = contextDir.appendingPathComponent("app1.Dockerfile") - let imageName = "registry.local/dockerignore-custom-name:\(UUID().uuidString)" - let args = ["build", "-f", customDockerfile.path, "-t", imageName, contextDir.path] - let response = try run(arguments: args) - if response.status != 0 { - throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") - } - - let containerName = "dockerignore-custom-name-\(UUID().uuidString)" - try self.doLongRun(name: containerName, image: imageName) - defer { try? self.doStop(name: containerName) } - - let app1SpecificResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/app1-specific.txt"]) - #expect(app1SpecificResult.status != 0, "app1-specific.txt should NOT be present (ignored by app1.Dockerfile.dockerignore)") - - let genericResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/generic.txt"]) - #expect(genericResult.status == 0, "generic.txt should be present (only in .dockerignore, not app1.Dockerfile.dockerignore)") - - let includedResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/included.txt"]) - #expect(includedResult.status == 0, "included.txt should be present") - } - - // Test 10: Custom-named Dockerfile in subdirectory uses its co-located .dockerignore - @Test func testDockerIgnoreCustomNameSubdir() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - WORKDIR /app - COPY . . - """ - // Root .dockerignore ignores from-root-ignore.txt - // nested/project/app2.Dockerfile.dockerignore ignores from-app2-ignore.txt - // When building with -f nested/project/app2.Dockerfile, the nested ignore takes precedence - let context: [FileSystemEntry] = [ - .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file(".dockerignore", content: .data("from-root-ignore.txt\n".data(using: .utf8)!)), - .file("from-root-ignore.txt", content: .data("This file should be included (only in .dockerignore, not app2.Dockerfile.dockerignore).\n".data(using: .utf8)!)), - .file("from-app2-ignore.txt", content: .data("This file should be ignored by app2.Dockerfile.dockerignore.\n".data(using: .utf8)!)), - .file("always-included.txt", content: .data("This file should always be included.\n".data(using: .utf8)!)), - .file("nested/project/app2.Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file("nested/project/app2.Dockerfile.dockerignore", content: .data("from-app2-ignore.txt\n".data(using: .utf8)!)), - .file("nested/project/config.yaml", content: .data("Config file in project directory.\n".data(using: .utf8)!)), - ] - try createContext(tempDir: tempDir, dockerfile: "", context: context) - - let contextDir = tempDir.appendingPathComponent("context") - let customDockerfile = contextDir.appendingPathComponent("nested/project/app2.Dockerfile") - let imageName = "registry.local/dockerignore-custom-subdir:\(UUID().uuidString)" - let args = ["build", "-f", customDockerfile.path, "-t", imageName, contextDir.path] - let response = try run(arguments: args) - if response.status != 0 { - throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") - } - - let containerName = "dockerignore-custom-subdir-\(UUID().uuidString)" - try self.doLongRun(name: containerName, image: imageName) - defer { try? self.doStop(name: containerName) } - - let app2IgnoreResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/from-app2-ignore.txt"]) - #expect(app2IgnoreResult.status != 0, "from-app2-ignore.txt should NOT be present (ignored by app2.Dockerfile.dockerignore)") - - let rootIgnoreResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/from-root-ignore.txt"]) - #expect(rootIgnoreResult.status == 0, "from-root-ignore.txt should be present (only in .dockerignore, not app2.Dockerfile.dockerignore)") - - let alwaysIncludedResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/always-included.txt"]) - #expect(alwaysIncludedResult.status == 0, "always-included.txt should be present") - - let configResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/nested/project/config.yaml"]) - #expect(configResult.status == 0, "nested/project/config.yaml should be present") - } - - // Test 11: app.Dockerfile coexists with Dockerfile; app.Dockerfile.dockerignore is used, not Dockerfile.dockerignore - @Test func testDockerIgnoreCoexistingDockerfiles() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let appDockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - WORKDIR /app - COPY . . - """ - let context: [FileSystemEntry] = [ - .file("Dockerfile", content: .data("FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . .\n".data(using: .utf8)!)), - .file("Dockerfile.dockerignore", content: .data("dockerfile-specific.txt\n".data(using: .utf8)!)), - .file("app.Dockerfile", content: .data(appDockerfile.data(using: .utf8)!)), - .file("app.Dockerfile.dockerignore", content: .data("app-specific.txt\n".data(using: .utf8)!)), - .file( - "dockerfile-specific.txt", content: .data("This file should NOT be copied when using Dockerfile, but SHOULD when using app.Dockerfile.\n".data(using: .utf8)!)), - .file("app-specific.txt", content: .data("This file should NOT be copied (ignored by app.Dockerfile.dockerignore).\n".data(using: .utf8)!)), - .file("included.txt", content: .data("This file should be copied.\n".data(using: .utf8)!)), - ] - try createContext(tempDir: tempDir, dockerfile: "", context: context) - - let contextDir = tempDir.appendingPathComponent("context") - let appDockerfilePath = contextDir.appendingPathComponent("app.Dockerfile") - let imageName = "registry.local/dockerignore-coexisting:\(UUID().uuidString)" - let args = ["build", "-f", appDockerfilePath.path, "-t", imageName, contextDir.path] - let response = try run(arguments: args) - if response.status != 0 { - throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") - } - - let containerName = "dockerignore-coexisting-\(UUID().uuidString)" - try self.doLongRun(name: containerName, image: imageName) - defer { try? self.doStop(name: containerName) } - - let appSpecificResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/app-specific.txt"]) - #expect(appSpecificResult.status != 0, "app-specific.txt should NOT be present (ignored by app.Dockerfile.dockerignore)") - - let dockerfileSpecificResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/dockerfile-specific.txt"]) - #expect(dockerfileSpecificResult.status == 0, "dockerfile-specific.txt should be present (Dockerfile.dockerignore was not used)") - - let includedResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/included.txt"]) - #expect(includedResult.status == 0, "included.txt should be present") - } - - // Test: Build context is read-only; Dockerfile and Dockerfile.dockerignore live outside the context - @Test func testDockerIgnoreReadonlyContext() throws { - let tempDir: URL = try createTempDir() - let contextDir = tempDir.appendingPathComponent("context") - defer { - // Restore write permission so the directory can be removed - try? FileManager.default.setAttributes( - [.posixPermissions: 0o755], - ofItemAtPath: contextDir.path - ) - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - WORKDIR /app - COPY . . - """ - // Context contains two files; Dockerfile and Dockerfile.dockerignore are placed outside - // the context directory (co-located in tempDir). - let context: [FileSystemEntry] = [ - .file("included.txt", content: .data("This file should be included.\n".data(using: .utf8)!)), - .file("secret.txt", content: .data("This file should be excluded by Dockerfile.dockerignore.\n".data(using: .utf8)!)), - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - - // Write Dockerfile.dockerignore next to Dockerfile, both outside the context directory - let dockerignoreData = "secret.txt\n".data(using: .utf8)! - try dockerignoreData.write(to: tempDir.appendingPathComponent("Dockerfile.dockerignore"), options: .atomic) - - // Make the context directory read-only before building - try FileManager.default.setAttributes( - [.posixPermissions: 0o555], - ofItemAtPath: contextDir.path - ) - - let dockerfilePath = tempDir.appendingPathComponent("Dockerfile") - let imageName = "registry.local/dockerignore-readonly-context:\(UUID().uuidString.prefix(6))" - let args = ["build", "-f", dockerfilePath.path, "-t", imageName, contextDir.path] - let response = try run(arguments: args) - if response.status != 0 { - throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") - } - - let containerName = "dockerignore-readonly-context-\(UUID().uuidString.prefix(6))" - try self.doLongRun(name: containerName, image: imageName) - defer { try? self.doStop(name: containerName) } - - let includedResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/included.txt"]) - #expect(includedResult.status == 0, "included.txt should be present") - - let secretResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/secret.txt"]) - #expect(secretResult.status != 0, "secret.txt should NOT be present (excluded by Dockerfile.dockerignore)") - } - - @Test func testNonExistingDockerfile() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let imageName = "registry.local/non-existing-dockerfile:\(UUID().uuidString)" - - var args = ["build", "-f", "non-existing-path", "-t", imageName, tempDir.path] - var response = try run(arguments: args) - - #expect(response.status != 0) - - args = ["build", "-t", imageName, tempDir.path] - response = try run(arguments: args) - - #expect(response.status != 0) - } - - @Test func testBuildNoCachePullLatestImage() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile = - """ - FROM \(alpine) - - ADD emptyFile / - """ - let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - - let imageName = "registry.local/no-cache-pull:\(UUID().uuidString)" - try self.build( - tags: [imageName], - tempDir: tempDir, - otherArgs: ["--pull", "--no-cache"] - ) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildQuotedImageDockerfileArg() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile: String = - """ - ARG IMAGE="ghcr.io/linuxcontainers/alpine:3.20" - FROM $IMAGE - RUN test -f /etc/alpine-release - """ - try createContext(tempDir: tempDir, dockerfile: dockerfile) - - let imageName = "registry.local/quoted-image-dockerfile-arg:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildQuotedStringDockerfileArg() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile: String = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - ARG MYSTRING='"Hello, world!"' - RUN test "$MYSTRING" = '"Hello, world!"' - """ - try createContext(tempDir: tempDir, dockerfile: dockerfile) - - let imageName = "registry.local/quoted-string-dockerfile-arg:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildForwardReferencedDockerfileArg() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile: String = - """ - ARG ALPINE="ghcr.io/linuxcontainers/alpine" - ARG IMAGE="${ALPINE}:3.20" - FROM $IMAGE - RUN test -f /etc/alpine-release - """ - try createContext(tempDir: tempDir, dockerfile: dockerfile) - - let imageName = "registry.local/forward-referenced-dockerfile-arg:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir) - #expect( - try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)" - ) - } - - @Test func testBuildQuotedImageBuildArg() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile: String = - """ - ARG IMAGE - FROM $IMAGE - RUN test -f /etc/alpine-release - """ - try createContext(tempDir: tempDir, dockerfile: dockerfile) - - let imageName = "registry.local/quoted-image-build-arg:\(UUID().uuidString)" - try self.build( - tag: imageName, - tempDir: tempDir, - buildArgs: [ - "IMAGE=ghcr.io/linuxcontainers/alpine:3.20" - ] - ) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildQuotedStringBuildArg() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile: String = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - ARG MYSTRING - RUN test "$MYSTRING" = '"Hello, world!"' - """ - try createContext(tempDir: tempDir, dockerfile: dockerfile) - - let imageName = "registry.local/quoted-string-build-arg:\(UUID().uuidString)" - try self.build( - tag: imageName, - tempDir: tempDir, - buildArgs: [ - "MYSTRING=\"Hello, world!\"" - ] - ) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testBuildForwardReferencedBuildArg() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile: String = - """ - ARG ALPINE - ARG IMAGE="$ALPINE:3.20" - FROM $IMAGE - RUN test -f /etc/alpine-release - """ - try createContext(tempDir: tempDir, dockerfile: dockerfile) - - let imageName = "registry.local/forward-referenced-build-arg:\(UUID().uuidString)" - try self.build( - tag: imageName, - tempDir: tempDir, - buildArgs: [ - "ALPINE=ghcr.io/linuxcontainers/alpine" - ] - ) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testCopyFromLocalImage() throws { - let baseTempDir: URL = try createTempDir() - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: baseTempDir) - try! FileManager.default.removeItem(at: tempDir) - } - - let baseImageName = "local-base:\(UUID().uuidString)" - let baseDockerfile = - """ - FROM scratch - ADD hello.txt /hello.txt - """ - let baseContext: [FileSystemEntry] = [ - .file("hello.txt", content: .data("hello\n".data(using: .utf8)!)) - ] - try createContext(tempDir: baseTempDir, dockerfile: baseDockerfile, context: baseContext) - - try self.build(tag: baseImageName, tempDir: baseTempDir) - #expect(try self.inspectImage(baseImageName) == baseImageName, "expected to have successfully built \(baseImageName)") - - let dockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY --from=\(baseImageName) /hello.txt /copied.txt - RUN cat /copied.txt - """ - try createContext(tempDir: tempDir, dockerfile: dockerfile) - - let imageName = "registry.local/copy-from-local:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testCopyFromBuildStage() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let 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 - """ - let context: [FileSystemEntry] = [ - .file("hello.txt", content: .data("hello\n".data(using: .utf8)!)) - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - - let imageName = "registry.local/copy-from-stage:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testCopyRenameFromStage() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let 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 - """ - let context: [FileSystemEntry] = [ - .file("hello.txt", content: .data("hello\n".data(using: .utf8)!)) - ] - try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) - - let imageName = "registry.local/copy-rename:\(UUID().uuidString)" - try self.build(tag: imageName, tempDir: tempDir) - #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") - } - - @Test func testCopyMissingFileFails() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile = - """ - FROM scratch AS builder - - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY --from=builder /does-not-exist.txt /copied.txt - """ - try createContext(tempDir: tempDir, dockerfile: dockerfile) - - let imageName = "registry.local/copy-missing:\(UUID().uuidString)" - #expect(throws: Error.self) { - try self.build(tag: imageName, tempDir: tempDir) - } - } - } - - @Test func testCopyInvalidStageFails() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY --from=not_a_stage /hello.txt /copied.txt - """ - try createContext(tempDir: tempDir, dockerfile: dockerfile) - - let imageName = "registry.local/copy-invalid-stage:\(UUID().uuidString)" - #expect(throws: Error.self) { - try self.build(tag: imageName, tempDir: tempDir) - } - } - - @Test func testCopyFromNonexistentImageFails() throws { - let tempDir: URL = try createTempDir() - defer { - try! FileManager.default.removeItem(at: tempDir) - } - - let dockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY --from=doesnotexist:latest /hello.txt /copied.txt - """ - try createContext(tempDir: tempDir, dockerfile: dockerfile) - - let imageName = "registry.local/copy-bad-image:\(UUID().uuidString)" - #expect(throws: Error.self) { - try self.build(tag: imageName, tempDir: tempDir) - } - } -} diff --git a/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift deleted file mode 100644 index 32a30818d..000000000 --- a/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift +++ /dev/null @@ -1,651 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025-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 ContainerAPIClient -import ContainerizationArchive -import ContainerizationOCI -import Foundation -import Testing - -@Suite(.serialSuites) -class TestCLIImagesCommand: CLITest { - @Test func testPull() throws { - do { - try doPull(imageName: alpine) - let imagePresent = try isImagePresent(targetImage: alpine) - #expect(imagePresent, "expected to see \(alpine) pulled") - } catch { - Issue.record("failed to pull alpine image \(error)") - return - } - } - - @Test func testPullMulti() throws { - do { - try doPull(imageName: alpine) - try doPull(imageName: busybox) - - let alpinePresent = try isImagePresent(targetImage: alpine) - #expect(alpinePresent, "expected to see \(alpine) pulled") - - let busyPresent = try isImagePresent(targetImage: busybox) - #expect(busyPresent, "expected to see \(busybox) pulled") - } catch { - Issue.record("failed to pull images \(error)") - return - } - } - - @Test func testPullPlatform() throws { - do { - let os = "linux" - let arch = "amd64" - let pullArgs = [ - "--platform", - "\(os)/\(arch)", - ] - - try doPull(imageName: alpine, args: pullArgs) - - let output = try doInspectImages(image: alpine) - #expect(output.count == 1, "expected a single image inspect output, got \(output)") - - var found = false - for v in output[0].variants { - if v.platform.os == os && v.platform.architecture == arch { - found = true - } - } - #expect(found, "expected to find image with os \(os) and architecture \(arch), instead got \(output[0])") - } catch { - Issue.record("failed to pull and inspect image \(error)") - return - } - } - - @Test func testPullOsArch() throws { - do { - let os = "linux" - let arch = "amd64" - let pullArgs = [ - "--os", - os, - "--arch", - arch, - ] - - try doPull(imageName: alpine318, args: pullArgs) - - let output = try doInspectImages(image: alpine318) - #expect(output.count == 1, "expected a single image inspect output, got \(output)") - - var found = false - for v in output[0].variants { - if v.platform.os == os && v.platform.architecture == arch { - found = true - } - } - #expect(found, "expected to find image with os \(os) and architecture \(arch), instead got \(output[0])") - } catch { - Issue.record("failed to pull and inspect image \(error)") - return - } - } - - @Test func testPullOs() throws { - do { - let os = "linux" - let arch = Arch.hostArchitecture().rawValue - let pullArgs = [ - "--os", - os, - ] - - try doPull(imageName: alpine318, args: pullArgs) - - let output = try doInspectImages(image: alpine318) - #expect(output.count == 1, "expected a single image inspect output, got \(output)") - - var found = false - for v in output[0].variants { - if v.platform.os == os && v.platform.architecture == arch { - found = true - } - } - #expect(found, "expected to find image with os \(os) and architecture \(arch), instead got \(output[0])") - } catch { - Issue.record("failed to pull and inspect image \(error)") - return - } - } - - @Test func testPullArch() throws { - do { - let os = "linux" - let arch = "amd64" - let pullArgs = [ - "--arch", - arch, - ] - - try doPull(imageName: alpine318, args: pullArgs) - - let output = try doInspectImages(image: alpine318) - #expect(output.count == 1, "expected a single image inspect output, got \(output)") - - var found = false - for v in output[0].variants { - if v.platform.os == os && v.platform.architecture == arch { - found = true - } - } - #expect(found, "expected to find image with os \(os) and architecture \(arch), instead got \(output[0])") - } catch { - Issue.record("failed to pull and inspect image \(error)") - return - } - } - - @Test func testPullRemoveSingle() throws { - do { - try doPull(imageName: alpine) - let imagePulled = try isImagePresent(targetImage: alpine) - #expect(imagePulled, "expected to see image \(alpine) pulled") - - // tag image so we can safely remove later - let alpineRef: Reference = try Reference.parse(alpine) - let alpineTagged = "\(alpineRef.name):testPullRemoveSingle" - try doImageTag(image: alpine, newName: alpineTagged) - let taggedImagePresent = try isImagePresent(targetImage: alpineTagged) - #expect(taggedImagePresent, "expected to see image \(alpineTagged) tagged") - - try doRemoveImages(images: [alpineTagged]) - let imageRemoved = try !isImagePresent(targetImage: alpineTagged) - #expect(imageRemoved, "expected not to see image \(alpineTagged)") - } catch { - Issue.record("failed to pull and remove image \(error)") - return - } - } - - @Test func testImageTag() throws { - do { - try doPull(imageName: alpine) - let alpineRef: Reference = try Reference.parse(alpine) - let alpineTagged = "\(alpineRef.name):testImageTag" - try doImageTag(image: alpine, newName: alpineTagged) - let imagePresent = try isImagePresent(targetImage: alpineTagged) - #expect(imagePresent, "expected to see image \(alpineTagged) tagged") - } catch { - Issue.record("failed to pull and tag image \(error)") - return - } - } - - @Test func testImageSaveAndLoad() throws { - do { - // 1. pull image - try doPull(imageName: alpine) - try doPull(imageName: busybox) - - // 2. Tag image so we can safely remove later - let alpineRef: Reference = try Reference.parse(alpine) - let alpineTagged = "\(alpineRef.name):testImageSaveAndLoad" - try doImageTag(image: alpine, newName: alpineTagged) - let alpineTaggedImagePresent = try isImagePresent(targetImage: alpineTagged) - #expect(alpineTaggedImagePresent, "expected to see image \(alpineTagged) tagged") - - let busyboxRef: Reference = try Reference.parse(busybox) - let busyboxTagged = "\(busyboxRef.name):testImageSaveAndLoad" - try doImageTag(image: busybox, newName: busyboxTagged) - let busyboxTaggedImagePresent = try isImagePresent(targetImage: busyboxTagged) - #expect(busyboxTaggedImagePresent, "expected to see image \(busyboxTagged) tagged") - - // 3. save the image as a tarball - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: tempDir) - } - let tempFile = tempDir.appendingPathComponent(UUID().uuidString) - let saveArgs = [ - "image", - "save", - alpineTagged, - busyboxTagged, - "--output", - tempFile.path(), - ] - let (_, _, error, status) = try run(arguments: saveArgs) - if status != 0 { - throw CLIError.executionFailed("command failed: \(error)") - } - - // 4. remove the image through container - try doRemoveImages(images: [alpineTagged, busyboxTagged]) - - // 5. verify image is no longer present - let alpineImageRemoved = try !isImagePresent(targetImage: alpineTagged) - #expect(alpineImageRemoved, "expected image \(alpineTagged) to be removed") - let busyboxImageRemoved = try !isImagePresent(targetImage: busyboxTagged) - #expect(busyboxImageRemoved, "expected image \(busyboxTagged) to be removed") - - // 6. load the tarball - let loadArgs = [ - "image", - "load", - "-i", - tempFile.path(), - ] - let (_, _, loadErr, loadStatus) = try run(arguments: loadArgs) - if loadStatus != 0 { - throw CLIError.executionFailed("command failed: \(loadErr)") - } - - // 7. verify image is in the list again - let alpineImagePresent = try isImagePresent(targetImage: alpineTagged) - #expect(alpineImagePresent, "expected \(alpineTagged) to be present") - let busyboxImagePresent = try isImagePresent(targetImage: busyboxTagged) - #expect(busyboxImagePresent, "expected \(busyboxTagged) to be present") - } catch { - Issue.record("failed to save and load image \(error)") - return - } - } - - @Test func testImageSaveToStdoutProducesCleanArchive() throws { - do { - // 1. pull and tag an image to save - try doPull(imageName: alpine) - let alpineRef: Reference = try Reference.parse(alpine) - let alpineTagged = "\(alpineRef.name):testImageSaveToStdout" - try doImageTag(image: alpine, newName: alpineTagged) - defer { - try? doRemoveImages(images: [alpineTagged]) - } - - // 2. save to stdout (no --output): stdout is the archive stream - let saveArgs = [ - "image", - "save", - alpineTagged, - ] - let (outputData, _, error, status) = try run(arguments: saveArgs) - if status != 0 { - throw CLIError.executionFailed("save to stdout failed: \(error)") - } - - // 3. The archive on stdout must end at the tar EOF marker (two - // 512-byte zero blocks). With the bug, the saved-reference list - // is printed to stdout after the archive, so the trailing bytes - // are reference text rather than the tar EOF zeros (#1801). - #expect(outputData.count >= 1024, "stdout archive is too small to contain a tar EOF marker") - let trailer = outputData.suffix(1024) - #expect(trailer.allSatisfy { $0 == 0 }, "stdout archive has trailing non-archive bytes after the tar EOF marker") - - // 4. The saved-reference list is still surfaced, on stderr. - #expect(error.contains(alpineTagged), "expected the saved image reference on stderr") - } catch { - Issue.record("failed to save image to stdout \(error)") - return - } - } - - @Test func testImageSaveMissingPlatform() throws { - do { - // 1. pull image - try doPull(imageName: alpine) - - // 2. tag image so we can safely remove later - let alpineRef: Reference = try Reference.parse(alpine) - let alpineTagged = "\(alpineRef.name):testImageSaveMissingPlatform" - try doImageTag(image: alpine, newName: alpineTagged) - let alpineTaggedImagePresent = try isImagePresent(targetImage: alpineTagged) - #expect(alpineTaggedImagePresent, "expected to see image \(alpineTagged) tagged") - - defer { - try? doRemoveImages(images: [alpineTagged]) - } - - // 3. attempt to save with a platform that isn't in the image - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: tempDir) - } - let tempFile = tempDir.appendingPathComponent(UUID().uuidString) - let saveArgs = [ - "image", - "save", - alpineTagged, - "--platform", - "linux/arm/v5", - "--output", - tempFile.path(), - ] - let (_, _, error, status) = try run(arguments: saveArgs) - - #expect(status != 0, "expected save to fail for missing platform") - #expect( - error.contains("has no content for platform"), - "expected error to describe missing platform, got: \(error)") - #expect( - error.contains("available platforms:"), - "expected error to list available platforms, got: \(error)") - } catch { - Issue.record("failed missing-platform save test \(error)") - return - } - } - - @Test func testMaxConcurrentDownloadsValidation() throws { - // Test that invalid maxConcurrentDownloads value is rejected - let (_, _, error, status) = try run(arguments: [ - "image", - "pull", - "--max-concurrent-downloads", "0", - "alpine:latest", - ]) - - #expect(status != 0, "Expected command to fail with maxConcurrentDownloads=0") - #expect( - error.contains("maximum number of concurrent downloads must be greater than 0"), - "Expected validation error message in output") - } - - @Test func testImageLoadRejectsInvalidMembersWithoutForce() throws { - do { - // 0. Generate unique malicious filename for this test run - let maliciousFilename = "pwned-\(UUID().uuidString).txt" - let maliciousPath = "/tmp/\(maliciousFilename)" - - // 1. Pull image - try doPull(imageName: alpine) - - // 2. Tag image so we can safely remove later - let alpineRef: Reference = try Reference.parse(alpine) - let alpineTagged = "\(alpineRef.name):testImageLoadRejectsInvalidMembers" - try doImageTag(image: alpine, newName: alpineTagged) - let taggedImagePresent = try isImagePresent(targetImage: alpineTagged) - #expect(taggedImagePresent, "expected to see image \(alpineTagged) tagged") - - // 3. Save the image as a tarball - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: tempDir) - } - let tempFile = tempDir.appendingPathComponent(UUID().uuidString) - let saveArgs = [ - "image", - "save", - alpineTagged, - "--output", - tempFile.path(), - ] - let (_, _, saveError, saveStatus) = try run(arguments: saveArgs) - if saveStatus != 0 { - throw CLIError.executionFailed("save command failed: \(saveError)") - } - - // 4. Add malicious member to the tar - try addInvalidMemberToTar(tarPath: tempFile.path(), maliciousFilename: maliciousFilename) - - // 5. Remove the image - try doRemoveImages(images: [alpineTagged]) - let imageRemoved = try !isImagePresent(targetImage: alpineTagged) - #expect(imageRemoved, "expected image \(alpineTagged) to be removed") - - // 6. Try to load the modified tar without force - should fail - let loadArgs = [ - "image", - "load", - "-i", - tempFile.path(), - ] - let (_, _, loadError, loadStatus) = try run(arguments: loadArgs) - #expect(loadStatus != 0, "expected load to fail without force flag") - #expect(loadError.contains("rejected paths") || loadError.contains(maliciousFilename), "expected error about invalid member path") - - // 7. Verify that malicious file was NOT created - let maliciousFileExists = FileManager.default.fileExists(atPath: maliciousPath) - #expect(!maliciousFileExists, "malicious file should not have been created at \(maliciousPath)") - } catch { - Issue.record("failed to test image load with invalid members: \(error)") - return - } - } - - @Test func testImageLoadAcceptsInvalidMembersWithForce() throws { - do { - // 0. Generate unique malicious filename for this test run - let maliciousFilename = "pwned-\(UUID().uuidString).txt" - let maliciousPath = "/tmp/\(maliciousFilename)" - - // 1. Pull image - try doPull(imageName: alpine) - - // 2. Tag image so we can safely remove later - let alpineRef: Reference = try Reference.parse(alpine) - let alpineTagged = "\(alpineRef.name):testImageLoadAcceptsInvalidMembers" - try doImageTag(image: alpine, newName: alpineTagged) - let taggedImagePresent = try isImagePresent(targetImage: alpineTagged) - #expect(taggedImagePresent, "expected to see image \(alpineTagged) tagged") - - // 3. Save the image as a tarball - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: tempDir) - } - let tempFile = tempDir.appendingPathComponent(UUID().uuidString) - let saveArgs = [ - "image", - "save", - alpineTagged, - "--output", - tempFile.path(), - ] - let (_, _, saveError, saveStatus) = try run(arguments: saveArgs) - if saveStatus != 0 { - throw CLIError.executionFailed("save command failed: \(saveError)") - } - - // 4. Add malicious member to the tar - try addInvalidMemberToTar(tarPath: tempFile.path(), maliciousFilename: maliciousFilename) - - // 5. Remove the image - try doRemoveImages(images: [alpineTagged]) - let imageRemoved = try !isImagePresent(targetImage: alpineTagged) - #expect(imageRemoved, "expected image \(alpineTagged) to be removed") - - // 6. Try to load the modified tar with force - should succeed with warning - let loadArgs = [ - "image", - "load", - "-i", - tempFile.path(), - "--force", - ] - let (_, _, loadError, loadStatus) = try run(arguments: loadArgs) - #expect(loadStatus == 0, "expected load to succeed with force flag") - - // Check that warning was logged about rejected member - #expect(loadError.contains("invalid members") || loadError.contains(maliciousFilename), "expected warning about rejected member path") - - // 7. Verify image is loaded - let imageLoaded = try isImagePresent(targetImage: alpineTagged) - #expect(imageLoaded, "expected image \(alpineTagged) to be loaded") - - // 8. Verify that malicious file was NOT created - let maliciousFileExists = FileManager.default.fileExists(atPath: maliciousPath) - #expect(!maliciousFileExists, "malicious file should not have been created at \(maliciousPath)") - } catch { - Issue.record("failed to test image load with force and invalid members: \(error)") - return - } - } - - @Test func testImageSaveAndLoadStdinStdout() throws { - do { - // 1. pull image - try doPull(imageName: alpine) - try doPull(imageName: busybox) - - // 2. Tag image so we can safely remove later - let alpineRef: Reference = try Reference.parse(alpine) - let alpineTagged = "\(alpineRef.name):testImageSaveAndLoadStdinStdout" - try doImageTag(image: alpine, newName: alpineTagged) - let alpineTaggedImagePresent = try isImagePresent(targetImage: alpineTagged) - #expect(alpineTaggedImagePresent, "expected to see image \(alpineTagged) tagged") - - let busyboxRef: Reference = try Reference.parse(busybox) - let busyboxTagged = "\(busyboxRef.name):testImageSaveAndLoadStdinStdout" - try doImageTag(image: busybox, newName: busyboxTagged) - let busyboxTaggedImagePresent = try isImagePresent(targetImage: busyboxTagged) - #expect(busyboxTaggedImagePresent, "expected to see image \(busyboxTagged) tagged") - - // 3. save the image and output to stdout - let saveArgs = [ - "image", - "save", - alpineTagged, - busyboxTagged, - ] - let (stdoutData, _, error, status) = try run(arguments: saveArgs) - if status != 0 { - throw CLIError.executionFailed("command failed: \(error)") - } - - // 4. remove the image through container - try doRemoveImages(images: [alpineTagged, busyboxTagged]) - - // 5. verify image is no longer present - let alpineImageRemoved = try !isImagePresent(targetImage: alpineTagged) - #expect(alpineImageRemoved, "expected image \(alpineTagged) to be removed") - let busyboxImageRemoved = try !isImagePresent(targetImage: busyboxTagged) - #expect(busyboxImageRemoved, "expected image \(busyboxTagged) to be removed") - - // 6. load the tarball from the stdout data as stdin - let loadArgs = [ - "image", - "load", - ] - let (_, _, loadErr, loadStatus) = try run(arguments: loadArgs, stdin: stdoutData) - if loadStatus != 0 { - throw CLIError.executionFailed("command failed: \(loadErr)") - } - - // 7. verify image is in the list again - let alpineImagePresent = try isImagePresent(targetImage: alpineTagged) - #expect(alpineImagePresent, "expected \(alpineTagged) to be present") - let busyboxImagePresent = try isImagePresent(targetImage: busyboxTagged) - #expect(busyboxImagePresent, "expected \(busyboxTagged) to be present") - } catch { - Issue.record("failed to save and load image \(error)") - return - } - } - - @Test func testImageVariantSizeFieldExists() throws { - // 1. pull image - try doPull(imageName: alpine) - - // 2. run the image ls command - let (_, output, error, status) = try run(arguments: ["image", "ls", "--format", "json"]) - if status != 0 { - throw CLIError.executionFailed("failed to list images: \(error)") - } - - // 3. parse the json output - guard let data = output.data(using: .utf8), - let json = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]], - let image = json.first - else { - Issue.record("failed to parse JSON output or no images found: \(output)") - return - } - - // 4. check that the image reports at least one variant with a non-zero size - let variants = image["variants"] as? [[String: Any]] ?? [] - #expect(!variants.isEmpty, "expected image to report at least one variant: \(image)") - let hasSize = variants.contains { ($0["size"] as? Int ?? 0) > 0 } - #expect(hasSize, "expected at least one variant to have a non-zero 'size' field: \(image)") - } - - @Test func testImageListTableFormat() throws { - try doPull(imageName: alpine) - - let (_, output, error, status) = try run(arguments: ["image", "ls"]) - #expect(status == 0, "image ls should succeed, stderr: \(error)") - - let headers = ["NAME", "TAG", "DIGEST"] - #expect(headers.allSatisfy { output.contains($0) }, "table should contain all headers") - #expect(output.contains("alpine"), "table should contain pulled image name") - } - - private func addInvalidMemberToTar(tarPath: String, maliciousFilename: String) throws { - // Create a malicious entry with path traversal - let evilEntryName = "../../../../../../../../../../../tmp/\(maliciousFilename)" - let evilEntryContent = "pwned\n".data(using: .utf8)! - - // Create a temporary file for the modified tar - let tempModifiedTar = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar") - - // Open the modified tar for writing - let writer = try ArchiveWriter(format: .pax, filter: .none, file: tempModifiedTar) - - // First, copy all existing members from the input tar - let reader = try ArchiveReader(file: URL(fileURLWithPath: tarPath)) - for (entry, data) in reader { - if entry.fileType == .regular { - try writer.writeEntry(entry: entry, data: data) - } else { - try writer.writeEntry(entry: entry, data: nil) - } - } - - // Now add the evil entry - let evilEntry = WriteEntry() - evilEntry.path = evilEntryName - evilEntry.size = Int64(evilEntryContent.count) - evilEntry.modificationDate = Date() - evilEntry.fileType = .regular - evilEntry.permissions = 0o644 - - try writer.writeEntry(entry: evilEntry, data: evilEntryContent) - try writer.finishEncoding() - - // Replace the original tar with the modified one - try FileManager.default.removeItem(atPath: tarPath) - try FileManager.default.moveItem(at: tempModifiedTar, to: URL(fileURLWithPath: tarPath)) - } - - @Test func testInspectMissingImageFails() throws { - let (_, _, error, status) = try run(arguments: ["image", "inspect", "definitely-missing-image:latest"]) - #expect(status != 0, "Expected non-zero exit for missing image") - #expect(error.contains("image not found")) - } - - @Test func testImageLoadMissingFileErrorToStderr() throws { - let missingPath = "/path/that/does/not/exist-\(UUID().uuidString)" - let (_, stdout, stderr, status) = try run(arguments: ["image", "load", "-i", missingPath]) - - #expect(status != 0, "Expected non-zero exit for missing file") - #expect(stdout.isEmpty, "Expected stdout to be empty, got: \(stdout)") - #expect(stderr.contains("file does not exist") && stderr.contains(missingPath), "Expected stderr to contain error message, got: \(stderr)") - } -} diff --git a/Tests/CLITests/Subcommands/System/TestCLISystemDF.swift b/Tests/CLITests/Subcommands/System/TestCLISystemDF.swift deleted file mode 100644 index eb184e7c3..000000000 --- a/Tests/CLITests/Subcommands/System/TestCLISystemDF.swift +++ /dev/null @@ -1,100 +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 Foundation -import Testing - -@Suite(.serialSuites, .serialized) -final class TestCLISystemDF: CLITest { - private struct DiskUsageStats: Decodable { - let images: ResourceUsage - } - - private struct ResourceUsage: Decodable { - let active: Int - let reclaimable: UInt64 - let sizeInBytes: UInt64 - let total: Int - } - - // Issue #1526: reported image size must include content blobs, not just unpacked snapshots. - @Test func imageDiskUsageIsPopulatedAfterPull() throws { - try withCleanImageStore { - try doPull(imageName: alpine) - let stats = try systemDiskUsage() - #expect(stats.images.total >= 1) - #expect(stats.images.active == 0) - #expect(stats.images.sizeInBytes > 0) - #expect(stats.images.reclaimable == stats.images.sizeInBytes) - } - } - - // Issue #1527: tagging the same image must not double-count its storage. - @Test func tagsDoNotDoubleCountImageStorage() throws { - try withCleanImageStore { - try doPull(imageName: alpine) - let before = try systemDiskUsage() - - try doImageTag(image: alpine, newName: "local/system-df-alpine:tag-one") - try doImageTag(image: alpine, newName: "local/system-df-alpine:tag-two") - let after = try systemDiskUsage() - - #expect(after.images.total == before.images.total + 2) - #expect(after.images.sizeInBytes == before.images.sizeInBytes) - #expect(after.images.reclaimable == before.images.reclaimable) - } - } - - // Issue #1527: removing one of several tags must not free shared storage. - // Assumes no background GC runs between operations; blobs stay until all references are removed. - @Test func deletingOneOfMultipleTagsPreservesSharedStorage() throws { - try withCleanImageStore { - let baseline = try systemDiskUsage() - - try doPull(imageName: alpine) - try doImageTag(image: alpine, newName: "local/system-df-alpine:delete-probe") - let beforeDelete = try systemDiskUsage() - - try doRemoveImages(images: ["local/system-df-alpine:delete-probe"]) - let afterAliasDelete = try systemDiskUsage() - - #expect(afterAliasDelete.images.total == beforeDelete.images.total - 1) - #expect(afterAliasDelete.images.sizeInBytes == beforeDelete.images.sizeInBytes) - #expect(afterAliasDelete.images.reclaimable == beforeDelete.images.reclaimable) - - _ = try? run(arguments: ["image", "rm", "--all"]) - let afterFullClean = try systemDiskUsage() - #expect(afterFullClean.images.total <= baseline.images.total) - #expect(afterFullClean.images.sizeInBytes <= baseline.images.sizeInBytes) - } - } - - private func withCleanImageStore(_ body: () throws -> Void) throws { - _ = try? run(arguments: ["image", "rm", "--all"]) - defer { - _ = try? run(arguments: ["image", "rm", "--all"]) - } - try body() - } - - private func systemDiskUsage() throws -> DiskUsageStats { - let (data, _, error, status) = try run(arguments: ["system", "df", "--format", "json"]) - guard status == 0 else { - throw CLIError.executionFailed("system df failed: \(error)") - } - return try JSONDecoder().decode(DiskUsageStats.self, from: data) - } -} diff --git a/Tests/CLITests/Subcommands/System/TestKernelSet.swift b/Tests/CLITests/Subcommands/System/TestKernelSet.swift deleted file mode 100644 index f88e19e17..000000000 --- a/Tests/CLITests/Subcommands/System/TestKernelSet.swift +++ /dev/null @@ -1,129 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025-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 ContainerAPIClient -import ContainerPersistence -import ContainerizationArchive -import Foundation -import Testing - -// This suite is run serialized since each test modifies the global default kernel -@Suite(.serialSuites, .serialized) -class TestCLIKernelSet: CLITest { - let remoteTar = ContainerSystemConfig().kernel.url - let defaultBinaryPath = ContainerSystemConfig().kernel.binaryPath - - deinit { - try? resetDefaultBinary() - } - - func resetDefaultBinary() throws { - let arguments: [String] = [ - "system", - "kernel", - "set", - "--recommended", - "--force", - ] - let (_, _, error, status) = try run(arguments: arguments) - if status != 0 { - throw CLIError.executionFailed("failed to reset kernel to recommended: \(error)") - } - } - - func doKernelSet(extraArgs: [String]) throws { - var arguments = [ - "system", - "kernel", - "set", - "--force", - ] - arguments.append(contentsOf: extraArgs) - - let (_, _, error, status) = try run(arguments: arguments) - if status != 0 { - throw CLIError.executionFailed("failed to set kernel: \(error)") - } - } - - func validateContainerRun() throws { - let name = getTestName() - try doLongRun(name: name, args: []) - defer { try? doStop(name: name) } - - _ = try doExec(name: name, cmd: ["date"]) - try doStop(name: name) - } - - private func getTestName() -> String { - Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() - } - - @Test func fromLocalTar() async throws { - let symlinkBinaryPath: String = URL(filePath: defaultBinaryPath).deletingLastPathComponent().appending(path: "vmlinux.container").relativePath - - try await withTempDir { tempDir in - // manually download the tar file - let localTarPath = tempDir.appending(path: remoteTar.lastPathComponent) - try await ContainerAPIClient.FileDownloader.downloadFile(url: remoteTar, to: localTarPath) - - let extraArgs: [String] = [ - "--tar", - localTarPath.path, - "--binary", - symlinkBinaryPath, - ] - - try doKernelSet(extraArgs: extraArgs) - try validateContainerRun() - } - } - - @Test func fromRemoteTarSymlink() throws { - // opt/kata/share/kata-containers/vmlinux.container should point to opt/kata/share/kata-containers/vmlinux- in the archive - let symlinkBinaryPath: String = URL(filePath: defaultBinaryPath).deletingLastPathComponent().appending(path: "vmlinux.container").relativePath - let extraArgs: [String] = [ - "--tar", - remoteTar.absoluteString, - "--binary", - symlinkBinaryPath, - ] - - try doKernelSet(extraArgs: extraArgs) - try validateContainerRun() - } - - @Test func fromLocalDisk() async throws { - try await withTempDir { tempDir in - // manually download the tar file - let localTarPath = tempDir.appending(path: remoteTar.lastPathComponent) - try await ContainerAPIClient.FileDownloader.downloadFile(url: remoteTar, to: localTarPath) - - // extract just the file we want - let targetPath = tempDir.appending(path: URL(string: defaultBinaryPath)!.lastPathComponent) - let archiveReader = try ArchiveReader(file: localTarPath) - let (_, data) = try archiveReader.extractFile(path: defaultBinaryPath) - try data.write(to: targetPath, options: .atomic) - - let extraArgs = [ - "--binary", - targetPath.path, - ] - try doKernelSet(extraArgs: extraArgs) - try validateContainerRun() - } - } -} diff --git a/Tests/CLITests/Subcommands/Volumes/TestCLIAnonymousVolumes.swift b/Tests/CLITests/Subcommands/Volumes/TestCLIAnonymousVolumes.swift deleted file mode 100644 index 41bdb5267..000000000 --- a/Tests/CLITests/Subcommands/Volumes/TestCLIAnonymousVolumes.swift +++ /dev/null @@ -1,483 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025-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 ContainerResource -import Foundation -import Testing - -@Suite(.serialSuites, .serialized) -class TestCLIAnonymousVolumes: CLITest { - - override init() throws { - try super.init() - // Clean up any leftover resources from previous test runs - cleanUpAllTestResources() - } - - private func cleanUpAllTestResources() { - // Clean up test containers (force remove) - if let (_, output, _, status) = try? run(arguments: ["ls", "-a"]), status == 0 { - let containers = output.components(separatedBy: .newlines) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { $0.lowercased().starts(with: "test") } - - for container in containers { - let _ = (try? run(arguments: ["delete", "--force", container])) - } - } - - // Clean up test volumes (both anonymous and named) - if let (_, output, _, status) = try? run(arguments: ["volume", "list", "--quiet"]), status == 0 { - let volumes = output.components(separatedBy: .newlines) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { isValidUUID($0) || $0.lowercased().starts(with: "test") } - - for volume in volumes { - doVolumeDeleteIfExists(name: volume) - } - } - } - - private func getTestName() -> String { - Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() - } - - func getAnonymousVolumeNames() throws -> [String] { - let (_, output, error, status) = try run(arguments: ["volume", "list", "--quiet"]) - guard status == 0 else { - throw CLIError.executionFailed("volume list failed: \(error)") - } - return output.components(separatedBy: .newlines) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { isValidUUID($0) } - } - - func volumeExists(name: String) throws -> Bool { - let (_, output, _, status) = try run(arguments: ["volume", "list", "--quiet"]) - guard status == 0 else { return false } - let volumes = output.components(separatedBy: .newlines) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - return volumes.contains(name) - } - - func isValidUUID(_ name: String) -> Bool { - let pattern = #"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"# - guard let regex = try? Regex(pattern) else { return false } - return (try? regex.firstMatch(in: name)) != nil - } - - func doVolumeCreate(name: String) throws { - let (_, _, error, status) = try run(arguments: ["volume", "create", name]) - if status != 0 { - throw CLIError.executionFailed("volume create failed: \(error)") - } - } - - func doVolumeDeleteIfExists(name: String) { - let (_, _, _, _) = (try? run(arguments: ["volume", "rm", name])) ?? (nil, "", "", 1) - } - - func doRemoveIfExists(name: String, force: Bool = false) { - var args = ["delete"] - if force { - args.append("--force") - } - args.append(name) - let (_, _, _, _) = (try? run(arguments: args)) ?? (nil, "", "", 1) - } - - @Test func testAnonymousVolumeCreationAndPersistence() async throws { - let testName = getTestName() - let containerName = "\(testName)_c1" - - defer { - doRemoveIfExists(name: containerName, force: true) - // Clean up anonymous volumes - if let volumes = try? getAnonymousVolumeNames() { - volumes.forEach { doVolumeDeleteIfExists(name: $0) } - } - } - - // Get count of anonymous volumes before - let beforeCount = try getAnonymousVolumeNames().count - - // Run container with --rm and anonymous volume - let (_, _, _, status) = try run(arguments: [ - "run", - "--rm", - "--name", - containerName, - "-v", - "/data", - alpine, - "echo", - "test", - ]) - - #expect(status == 0, "container run should succeed") - - // Give time for container removal to complete - try await Task.sleep(for: .seconds(1)) - - // Verify container was removed - let (_, lsOutput, _, _) = try run(arguments: ["ls", "-a"]) - let containers = lsOutput.components(separatedBy: .newlines) - .filter { $0.contains(containerName) } - #expect(containers.isEmpty, "container should be removed with --rm") - - // Verify anonymous volume persists (no auto-cleanup) - let afterCount = try getAnonymousVolumeNames().count - #expect(afterCount == beforeCount + 1, "anonymous volume should persist even with --rm") - } - - @Test func testAnonymousVolumePersistenceWithoutRm() throws { - let testName = getTestName() - let containerName = "\(testName)_c1" - let testData = "persistent-data" - - defer { - doRemoveIfExists(name: containerName, force: true) - // Clean up any anonymous volumes - if let volumes = try? getAnonymousVolumeNames() { - volumes.forEach { doVolumeDeleteIfExists(name: $0) } - } - } - - // Run container WITHOUT --rm - try doLongRun(name: containerName, args: ["-v", "/data"], autoRemove: false) - try waitForContainerRunning(containerName) - - // Write data to anonymous volume - _ = try doExec(name: containerName, cmd: ["sh", "-c", "echo '\(testData)' > /data/test.txt"]) - - // Get the anonymous volume ID - let volumeNames = try getAnonymousVolumeNames() - #expect(volumeNames.count == 1, "should have exactly one anonymous volume") - let volumeID = volumeNames[0] - - // Stop and remove container - try doStop(name: containerName) - doRemoveIfExists(name: containerName, force: true) - - // Verify volume still exists - let exists = try volumeExists(name: volumeID) - #expect(exists, "anonymous volume should persist without --rm") - - // Mount same volume in new container and verify data - let containerName2 = "\(testName)_c2" - try doLongRun(name: containerName2, args: ["-v", "\(volumeID):/data"], autoRemove: false) - try waitForContainerRunning(containerName2) - - var output = try doExec(name: containerName2, cmd: ["cat", "/data/test.txt"]) - output = output.trimmingCharacters(in: .whitespacesAndNewlines) - #expect(output == testData, "data should persist in anonymous volume") - - // Clean up - try doStop(name: containerName2) - doRemoveIfExists(name: containerName2, force: true) - doVolumeDeleteIfExists(name: volumeID) - } - - @Test func testMultipleAnonymousVolumes() async throws { - let testName = getTestName() - let containerName = "\(testName)_c1" - - defer { - doRemoveIfExists(name: containerName, force: true) - // Clean up anonymous volumes - if let volumes = try? getAnonymousVolumeNames() { - volumes.forEach { doVolumeDeleteIfExists(name: $0) } - } - } - - let beforeCount = try getAnonymousVolumeNames().count - - // Run with multiple anonymous volumes - let (_, _, _, status) = try run(arguments: [ - "run", - "--rm", - "--name", - containerName, - "-v", "/data1", - "-v", "/data2", - "-v", "/data3", - alpine, - "sh", "-c", "ls -d /data*", - ]) - - #expect(status == 0, "container run should succeed") - - // Give time for container removal - try await Task.sleep(for: .seconds(1)) - - // All 3 volumes should persist (no auto-cleanup) - let afterCount = try getAnonymousVolumeNames().count - #expect(afterCount == beforeCount + 3, "all 3 anonymous volumes should persist") - } - - @Test func testAnonymousMountSyntax() async throws { - let testName = getTestName() - let containerName = "\(testName)_c1" - - defer { - doRemoveIfExists(name: containerName, force: true) - // Clean up anonymous volumes - if let volumes = try? getAnonymousVolumeNames() { - volumes.forEach { doVolumeDeleteIfExists(name: $0) } - } - } - - let beforeCount = try getAnonymousVolumeNames().count - - // Use --mount syntax - let (_, _, _, status) = try run(arguments: [ - "run", - "--rm", - "--name", - containerName, - "--mount", "type=volume,dst=/mydata", - alpine, - "ls", "-la", "/mydata", - ]) - - #expect(status == 0, "container run with --mount should succeed") - - // Give time for container removal - try await Task.sleep(for: .seconds(1)) - - // Anonymous volume should persist (no auto-cleanup) - let afterCount = try getAnonymousVolumeNames().count - #expect(afterCount == beforeCount + 1, "anonymous volume should persist") - } - - @Test func testAnonymousVolumeUUIDFormat() throws { - let testName = getTestName() - let containerName = "\(testName)_c1" - - defer { - try? doStop(name: containerName) - doRemoveIfExists(name: containerName, force: true) - if let volumes = try? getAnonymousVolumeNames() { - volumes.forEach { doVolumeDeleteIfExists(name: $0) } - } - } - - // Create container with anonymous volume - try doLongRun(name: containerName, args: ["-v", "/data"]) - try waitForContainerRunning(containerName) - - // Get the anonymous volume name - let volumeNames = try getAnonymousVolumeNames() - #expect(volumeNames.count == 1, "should have exactly one anonymous volume") - - let volumeName = volumeNames[0] - - // Verify UUID format: {lowercase uuid} - #expect(isValidUUID(volumeName), "volume name should match UUID format: \(volumeName)") - - // Verify total length is 36 characters (UUID without prefix) - #expect(volumeName.count == 36, "volume name should be 36 characters long") - } - - @Test func testAnonymousVolumeMetadata() throws { - let testName = getTestName() - let containerName = "\(testName)_c1" - - defer { - try? doStop(name: containerName) - doRemoveIfExists(name: containerName, force: true) - if let volumes = try? getAnonymousVolumeNames() { - volumes.forEach { doVolumeDeleteIfExists(name: $0) } - } - } - - // Create container with anonymous volume - try doLongRun(name: containerName, args: ["-v", "/data"]) - try waitForContainerRunning(containerName) - - // Get the anonymous volume - let volumeNames = try getAnonymousVolumeNames() - #expect(volumeNames.count == 1, "should have exactly one anonymous volume") - let volumeName = volumeNames[0] - - // Inspect volume in JSON format - let (_, output, error, status) = try run(arguments: ["volume", "list", "--format", "json"]) - #expect(status == 0, "volume list should succeed: \(error)") - - #expect(output.contains("\"creationDate\""), "JSON output should use creationDate key") - #expect(!output.contains("\"createdAt\""), "JSON output must not use deprecated createdAt key") - - // Parse JSON to verify metadata - let data = output.data(using: .utf8)! - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let volumes = try decoder.decode([VolumeResource].self, from: data) - - let anonVolume = volumes.first { $0.name == volumeName } - #expect(anonVolume != nil, "should find anonymous volume in list") - - if let vol = anonVolume { - #expect(vol.isAnonymous == true, "isAnonymous should be true") - } - } - - @Test func testAnonymousVolumeListDisplay() throws { - let testName = getTestName() - let namedVolumeName = "\(testName)_namedvol" - let containerName = "\(testName)_c1" - - defer { - try? doStop(name: containerName) - doRemoveIfExists(name: containerName, force: true) - doVolumeDeleteIfExists(name: namedVolumeName) - if let volumes = try? getAnonymousVolumeNames() { - volumes.forEach { doVolumeDeleteIfExists(name: $0) } - } - } - - // Create named volume - try doVolumeCreate(name: namedVolumeName) - - // Create container with anonymous volume - try doLongRun(name: containerName, args: ["-v", "/data"]) - try waitForContainerRunning(containerName) - - // List volumes - let (_, output, error, status) = try run(arguments: ["volume", "list"]) - #expect(status == 0, "volume list should succeed: \(error)") - - // Verify TYPE column exists and shows both types - #expect(output.contains("TYPE"), "output should contain TYPE column") - #expect(output.contains("named"), "output should show named volume type") - #expect(output.contains("anonymous"), "output should show anonymous volume type") - #expect(output.contains(namedVolumeName), "output should contain named volume") - } - - @Test func testAnonymousVolumeMixedWithNamedVolume() async throws { - let testName = getTestName() - let namedVolumeName = "\(testName)_namedvol" - let containerName = "\(testName)_c1" - - defer { - doRemoveIfExists(name: containerName, force: true) - doVolumeDeleteIfExists(name: namedVolumeName) - // Clean up anonymous volumes - if let volumes = try? getAnonymousVolumeNames() { - volumes.forEach { doVolumeDeleteIfExists(name: $0) } - } - } - - // Create named volume - try doVolumeCreate(name: namedVolumeName) - - let beforeAnonCount = try getAnonymousVolumeNames().count - - // Run with both named and anonymous volumes, with --rm - let (_, _, _, status) = try run(arguments: [ - "run", - "--rm", - "--name", - containerName, - "-v", "\(namedVolumeName):/named", - "-v", "/anon", - alpine, - "sh", "-c", "ls -d /*", - ]) - - #expect(status == 0, "container run should succeed") - - // Give time for container removal - try await Task.sleep(for: .seconds(1)) - - // Named volume should still exist - let namedExists = try volumeExists(name: namedVolumeName) - #expect(namedExists, "named volume should persist") - - let afterAnonCount = try getAnonymousVolumeNames().count - #expect(afterAnonCount == beforeAnonCount + 1, "anonymous volume should persist") - } - - @Test func testAnonymousVolumeManualDeletion() throws { - let testName = getTestName() - let containerName = "\(testName)_c1" - - defer { - doRemoveIfExists(name: containerName, force: true) - } - - // Create container WITHOUT --rm - try doLongRun(name: containerName, args: ["-v", "/data"], autoRemove: false) - try waitForContainerRunning(containerName) - - // Get volume ID - let volumeNames = try getAnonymousVolumeNames() - #expect(volumeNames.count == 1, "should have one anonymous volume") - let volumeID = volumeNames[0] - - // Stop container (unmounts volume) - try doStop(name: containerName) - doRemoveIfExists(name: containerName, force: true) - - // Manual deletion should succeed (volume is unmounted) - let (_, _, error, status) = try run(arguments: ["volume", "rm", volumeID]) - #expect(status == 0, "manual deletion of unmounted anonymous volume should succeed: \(error)") - - // Verify volume is gone - let exists = try volumeExists(name: volumeID) - #expect(!exists, "volume should be deleted") - } - - @Test func testAnonymousVolumeDetachedMode() async throws { - let testName = getTestName() - let containerName = "\(testName)_c1" - - defer { - doRemoveIfExists(name: containerName, force: true) - // Clean up anonymous volumes - if let volumes = try? getAnonymousVolumeNames() { - volumes.forEach { doVolumeDeleteIfExists(name: $0) } - } - } - - let beforeCount = try getAnonymousVolumeNames().count - - // Run in detached mode with --rm - let (_, _, _, status) = try run(arguments: [ - "run", - "-d", - "--rm", - "--name", - containerName, - "-v", "/data", - alpine, - "sleep", "2", - ]) - - #expect(status == 0, "detached container run should succeed") - - // Wait for container to exit - try await Task.sleep(for: .seconds(3)) - - // Container should be removed - let (_, lsOutput, _, _) = try run(arguments: ["ls", "-a"]) - let containers = lsOutput.components(separatedBy: .newlines) - .filter { $0.contains(containerName) } - #expect(containers.isEmpty, "container should be auto-removed") - - let afterCount = try getAnonymousVolumeNames().count - #expect(afterCount == beforeCount + 1, "anonymous volume should persist") - } -} diff --git a/Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift b/Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift deleted file mode 100644 index 84e46198c..000000000 --- a/Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift +++ /dev/null @@ -1,565 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025-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 ContainerAPIClient -import Foundation -import Testing - -@Suite(.serialSuites, .serialized) -class TestCLIVolumes: CLITest { - - func doVolumeCreate(name: String) throws { - let (_, _, error, status) = try run(arguments: ["volume", "create", name]) - if status != 0 { - throw CLIError.executionFailed("volume create failed: \(error)") - } - } - - func doVolumeDelete(name: String) throws { - let (_, _, error, status) = try run(arguments: ["volume", "rm", name]) - if status != 0 { - throw CLIError.executionFailed("volume delete failed: \(error)") - } - } - - func doVolumeDeleteIfExists(name: String) { - let (_, _, _, _) = (try? run(arguments: ["volume", "rm", name])) ?? (nil, "", "", 1) - } - - func doRemoveIfExists(name: String, force: Bool = false) { - var args = ["delete"] - if force { - args.append("--force") - } - args.append(name) - let (_, _, _, _) = (try? run(arguments: args)) ?? (nil, "", "", 1) - } - - func doesVolumeDeleteFail(name: String) throws -> Bool { - let (_, _, _, status) = try run(arguments: ["volume", "rm", name]) - return status != 0 - } - - private func getTestName() -> String { - Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() - } - - @Test func testVolumeDataPersistenceAcrossContainers() throws { - let testName = getTestName() - let volumeName = "\(testName)_vol" - let container1Name = "\(testName)_c1" - let container2Name = "\(testName)_c2" - let testData = "persistent-data-test" - let testFile = "/data/test.txt" - - // Clean up any existing resources from previous runs - doVolumeDeleteIfExists(name: volumeName) - doRemoveIfExists(name: container1Name, force: true) - doRemoveIfExists(name: container2Name, force: true) - - defer { - // Clean up containers and volume - try? doStop(name: container1Name) - doRemoveIfExists(name: container1Name, force: true) - try? doStop(name: container2Name) - doRemoveIfExists(name: container2Name, force: true) - doVolumeDeleteIfExists(name: volumeName) - } - - // Create volume - try doVolumeCreate(name: volumeName) - - // Run first container with volume, write data, then stop - try doLongRun(name: container1Name, args: ["-v", "\(volumeName):/data"]) - try waitForContainerRunning(container1Name) - - // Write test data to the volume - _ = try doExec(name: container1Name, cmd: ["sh", "-c", "echo '\(testData)' > \(testFile)"]) - - // Stop first container - try doStop(name: container1Name) - - // Run second container with same volume - try doLongRun(name: container2Name, args: ["-v", "\(volumeName):/data"]) - try waitForContainerRunning(container2Name) - - // Verify data persisted - var output = try doExec(name: container2Name, cmd: ["cat", testFile]) - output = output.trimmingCharacters(in: .whitespacesAndNewlines) - - #expect(output == testData, "expected persisted data '\(testData)', instead got '\(output)'") - - try doStop(name: container2Name) - try doVolumeDelete(name: volumeName) - } - - @Test func testVolumeSharedAccessConflict() throws { - let testName = getTestName() - let volumeName = "\(testName)_vol" - let container1Name = "\(testName)_c1" - let container2Name = "\(testName)_c2" - - // Clean up any existing resources from previous runs - doVolumeDeleteIfExists(name: volumeName) - doRemoveIfExists(name: container1Name, force: true) - doRemoveIfExists(name: container2Name, force: true) - - defer { - // Clean up containers and volume - try? doStop(name: container1Name) - doRemoveIfExists(name: container1Name, force: true) - try? doStop(name: container2Name) - doRemoveIfExists(name: container2Name, force: true) - doVolumeDeleteIfExists(name: volumeName) - } - - // Create volume - try doVolumeCreate(name: volumeName) - - // Run first container with volume - try doLongRun(name: container1Name, args: ["-v", "\(volumeName):/data"]) - try waitForContainerRunning(container1Name) - - // Try to run second container with same volume - should fail - let (_, _, _, status) = try run(arguments: ["run", "--name", container2Name, "-v", "\(volumeName):/data", alpine] + defaultContainerArgs) - - #expect(status != 0, "second container should fail when trying to use volume already in use") - - // Clean up - try doStop(name: container1Name) - doRemoveIfExists(name: container1Name, force: true) - doVolumeDeleteIfExists(name: volumeName) - } - - @Test func testVolumeDeleteProtectionWhileInUse() throws { - let testName = getTestName() - let volumeName = "\(testName)_vol" - let containerName = "\(testName)_c1" - - // Clean up any existing resources from previous runs - doVolumeDeleteIfExists(name: volumeName) - doRemoveIfExists(name: containerName, force: true) - - defer { - // Clean up container and volume - try? doStop(name: containerName) - doRemoveIfExists(name: containerName, force: true) - doVolumeDeleteIfExists(name: volumeName) - } - - // Create volume - try doVolumeCreate(name: volumeName) - - // Run container with volume - try doLongRun(name: containerName, args: ["-v", "\(volumeName):/data"]) - try waitForContainerRunning(containerName) - - // Try to delete volume while container is running - should fail - let deleteFailedWhileInUse = try doesVolumeDeleteFail(name: volumeName) - #expect(deleteFailedWhileInUse, "volume delete should fail while volume is in use") - - // Stop container - try doStop(name: containerName) - doRemoveIfExists(name: containerName, force: true) - - // Now volume delete should succeed - try doVolumeDelete(name: volumeName) - } - - @Test func testVolumeDeleteProtectionWithCreatedContainer() async throws { - let testName = getTestName() - let volumeName = "\(testName)_vol" - let containerName = "\(testName)_c1" - - // Clean up any existing resources from previous runs - doVolumeDeleteIfExists(name: volumeName) - doRemoveIfExists(name: containerName, force: true) - - defer { - // Clean up container and volume - try? doStop(name: containerName) - doRemoveIfExists(name: containerName, force: true) - doVolumeDeleteIfExists(name: volumeName) - } - - // Create volume - try doVolumeCreate(name: volumeName) - - // Create (but don't start) container with volume - try doCreate(name: containerName, image: alpine, volumes: ["\(volumeName):/mnt/data"]) - - // Give some time for container to be fully registered - try await Task.sleep(for: .seconds(1)) - - // Try to delete volume while container is created - should fail - let deleteFailedWhileInUse = try doesVolumeDeleteFail(name: volumeName) - #expect(deleteFailedWhileInUse, "volume delete should fail when volume is used by created container") - - // Remove the container - doRemoveIfExists(name: containerName, force: true) - - // Now volume delete should succeed - doVolumeDeleteIfExists(name: volumeName) - } - - @Test func testVolumeBasicOperations() throws { - let testName = getTestName() - let volumeName = "\(testName)_vol" - - // Clean up any existing resources from previous runs - doVolumeDeleteIfExists(name: volumeName) - - defer { - doVolumeDeleteIfExists(name: volumeName) - } - - // Create volume - try doVolumeCreate(name: volumeName) - - // List volumes and verify it exists - let (_, output, error, status) = try run(arguments: ["volume", "list", "--quiet"]) - if status != 0 { - throw CLIError.executionFailed("volume list failed: \(error)") - } - - let volumes = output.components(separatedBy: .newlines) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - - #expect(volumes.contains(volumeName), "created volume should appear in list") - - // Inspect volume - let (_, inspectOutput, inspectError, inspectStatus) = try run(arguments: ["volume", "inspect", volumeName]) - if inspectStatus != 0 { - throw CLIError.executionFailed("volume inspect failed: \(inspectError)") - } - - #expect(inspectOutput.contains(volumeName), "volume inspect should contain volume name") - #expect(inspectOutput.contains("\"creationDate\""), "inspect JSON should use creationDate key") - #expect(!inspectOutput.contains("\"createdAt\""), "inspect JSON must not use deprecated createdAt key") - - // Delete volume - try doVolumeDelete(name: volumeName) - } - - @Test func testImplicitNamedVolumeCreation() throws { - let testName = getTestName() - let containerName = "\(testName)_c1" - let volumeName = "\(testName)_autovolume" - - defer { - doRemoveIfExists(name: containerName, force: true) - doVolumeDeleteIfExists(name: volumeName) - } - - // Verify volume doesn't exist yet - let (_, listOutput, _, _) = try run(arguments: ["volume", "list", "--quiet"]) - let volumeExistsBefore = listOutput.contains(volumeName) - #expect(!volumeExistsBefore, "volume should not exist initially") - - // Run container with non-existent named volume - should auto-create - let (_, output, _, status) = try run(arguments: [ - "run", - "--name", - containerName, - "-v", "\(volumeName):/data", - alpine, - "echo", "test", - ]) - - // Should succeed and create volume automatically - #expect(status == 0, "should succeed and auto-create named volume") - #expect(output.contains("test"), "container should run successfully") - - // Volume should now exist - let (_, listOutputAfter, _, _) = try run(arguments: ["volume", "list", "--quiet"]) - let volumeExistsAfter = listOutputAfter.contains(volumeName) - #expect(volumeExistsAfter, "volume should be created") - } - - @Test func testImplicitNamedVolumeReuse() throws { - let testName = getTestName() - let containerName1 = "\(testName)_c1" - let containerName2 = "\(testName)_c2" - let volumeName = "\(testName)_sharedvolume" - - defer { - doRemoveIfExists(name: containerName1, force: true) - doRemoveIfExists(name: containerName2, force: true) - doVolumeDeleteIfExists(name: volumeName) - } - - // First container - should auto-create volume - let (_, _, _, status1) = try run(arguments: [ - "run", - "--name", - containerName1, - "-v", "\(volumeName):/data", - alpine, - "sh", "-c", "echo 'first' > /data/test.txt", - ]) - - #expect(status1 == 0, "first container should succeed") - - // Second container - should reuse existing volume - let (_, _, _, status2) = try run(arguments: [ - "run", - "--name", - containerName2, - "-v", "\(volumeName):/data", - alpine, - "cat", "/data/test.txt", - ]) - - #expect(status2 == 0, "second container should succeed") - } - - @Test func testVolumePruneNoVolumes() throws { - // Prune with no volumes should succeed with 0 reclaimed - let (_, _, error, status) = try run(arguments: ["volume", "prune"]) - if status != 0 { - throw CLIError.executionFailed("volume prune failed: \(error)") - } - - #expect(error.contains("Zero KB"), "should show no space reclaimed") - } - - @Test func testVolumePruneUnusedVolumes() throws { - let testName = getTestName() - let volumeName1 = "\(testName)_vol1" - let volumeName2 = "\(testName)_vol2" - - // Clean up any existing resources from previous runs - doVolumeDeleteIfExists(name: volumeName1) - doVolumeDeleteIfExists(name: volumeName2) - - defer { - doVolumeDeleteIfExists(name: volumeName1) - doVolumeDeleteIfExists(name: volumeName2) - } - - try doVolumeCreate(name: volumeName1) - try doVolumeCreate(name: volumeName2) - let (_, listBefore, _, statusBefore) = try run(arguments: ["volume", "list", "--quiet"]) - #expect(statusBefore == 0) - #expect(listBefore.contains(volumeName1)) - #expect(listBefore.contains(volumeName2)) - - // Prune should remove both - let (_, output, error, status) = try run(arguments: ["volume", "prune"]) - if status != 0 { - throw CLIError.executionFailed("volume prune failed: \(error)") - } - - #expect(output.contains(volumeName1) || !output.contains("No volumes to prune"), "should prune volume1") - #expect(output.contains(volumeName2) || !output.contains("No volumes to prune"), "should prune volume2") - #expect(error.contains("Reclaimed"), "should show reclaimed space") - - // Verify volumes are gone - let (_, listAfter, _, statusAfter) = try run(arguments: ["volume", "list", "--quiet"]) - #expect(statusAfter == 0) - #expect(!listAfter.contains(volumeName1), "volume1 should be pruned") - #expect(!listAfter.contains(volumeName2), "volume2 should be pruned") - } - - @Test func testVolumePruneSkipsVolumeInUse() throws { - let testName = getTestName() - let volumeInUse = "\(testName)_inuse" - let volumeUnused = "\(testName)_unused" - let containerName = "\(testName)_c1" - - // Clean up any existing resources from previous runs - doVolumeDeleteIfExists(name: volumeInUse) - doVolumeDeleteIfExists(name: volumeUnused) - doRemoveIfExists(name: containerName, force: true) - - defer { - try? doStop(name: containerName) - doRemoveIfExists(name: containerName, force: true) - doVolumeDeleteIfExists(name: volumeInUse) - doVolumeDeleteIfExists(name: volumeUnused) - } - - try doVolumeCreate(name: volumeInUse) - try doVolumeCreate(name: volumeUnused) - try doLongRun(name: containerName, args: ["-v", "\(volumeInUse):/data"]) - try waitForContainerRunning(containerName) - - // Prune should only remove the unused volume - let (_, _, error, status) = try run(arguments: ["volume", "prune"]) - if status != 0 { - throw CLIError.executionFailed("volume prune failed: \(error)") - } - - // Verify in-use volume still exists - let (_, listAfter, _, statusAfter) = try run(arguments: ["volume", "list", "--quiet"]) - #expect(statusAfter == 0) - #expect(listAfter.contains(volumeInUse), "volume in use should NOT be pruned") - #expect(!listAfter.contains(volumeUnused), "unused volume should be pruned") - - try doStop(name: containerName) - doRemoveIfExists(name: containerName, force: true) - doVolumeDeleteIfExists(name: volumeInUse) - } - - @Test func testVolumePruneSkipsVolumeAttachedToStoppedContainer() async throws { - let testName = getTestName() - let volumeName = "\(testName)_vol" - let containerName = "\(testName)_c1" - - // Clean up any existing resources from previous runs - doVolumeDeleteIfExists(name: volumeName) - doRemoveIfExists(name: containerName, force: true) - - defer { - doRemoveIfExists(name: containerName, force: true) - doVolumeDeleteIfExists(name: volumeName) - } - - try doVolumeCreate(name: volumeName) - try doCreate(name: containerName, image: alpine, volumes: ["\(volumeName):/data"]) - try await Task.sleep(for: .seconds(1)) - - // Prune should NOT remove the volume (container exists, even if stopped) - let (_, _, error, status) = try run(arguments: ["volume", "prune"]) - if status != 0 { - throw CLIError.executionFailed("volume prune failed: \(error)") - } - - let (_, listAfter, _, statusAfter) = try run(arguments: ["volume", "list", "--quiet"]) - #expect(statusAfter == 0) - #expect(listAfter.contains(volumeName), "volume attached to stopped container should NOT be pruned") - - doRemoveIfExists(name: containerName, force: true) - let (_, _, error2, status2) = try run(arguments: ["volume", "prune"]) - if status2 != 0 { - throw CLIError.executionFailed("volume prune failed: \(error2)") - } - - // Verify volume is gone - let (_, listFinal, _, statusFinal) = try run(arguments: ["volume", "list", "--quiet"]) - #expect(statusFinal == 0) - #expect(!listFinal.contains(volumeName), "volume should be pruned after container is deleted") - } - - // MARK: - Delete validation tests - - @Test func testVolumeDeleteNoArgs() throws { - let (_, _, _, status) = try run(arguments: ["volume", "delete"]) - #expect(status != 0, "Expected non-zero exit when no args and no --all") - } - - @Test func testVolumeDeleteExplicitNamesConflictWithAll() throws { - let (_, _, error, status) = try run(arguments: ["volume", "delete", "--all", "some-volume"]) - #expect(status != 0, "Expected non-zero exit for conflicting flags") - #expect(error.contains("conflict")) - } - - // MARK: - Inspect validation tests - - @Test func testVolumeInspectMissingFails() throws { - let (_, _, error, status) = try run(arguments: ["volume", "inspect", "definitely-missing-volume"]) - #expect(status != 0, "Expected non-zero exit for missing volume") - #expect(error.contains("volume not found")) - } - - // MARK: - Journal option tests - - @Test func testVolumeCreateWithJournalOrdered() throws { - let testName = getTestName() - let volumeName = "\(testName)_vol" - - doVolumeDeleteIfExists(name: volumeName) - defer { doVolumeDeleteIfExists(name: volumeName) } - - let (_, _, error, status) = try run(arguments: [ - "volume", "create", "--opt", "journal=ordered", volumeName, - ]) - #expect(status == 0, "volume create with journal=ordered should succeed: \(error)") - - let (_, listOutput, _, listStatus) = try run(arguments: ["volume", "list", "--quiet"]) - #expect(listStatus == 0) - #expect(listOutput.contains(volumeName), "journaled volume should appear in list") - } - - @Test func testVolumeCreateWithJournalAndSize() throws { - let testName = getTestName() - let volumeName = "\(testName)_vol" - - doVolumeDeleteIfExists(name: volumeName) - defer { doVolumeDeleteIfExists(name: volumeName) } - - let (_, _, error, status) = try run(arguments: [ - "volume", "create", "--opt", "journal=writeback:64m", volumeName, - ]) - #expect(status == 0, "volume create with journal=writeback:64m should succeed: \(error)") - } - - @Test func testVolumeCreateWithInvalidJournalModeErrors() throws { - let testName = getTestName() - let volumeName = "\(testName)_vol" - - doVolumeDeleteIfExists(name: volumeName) - defer { doVolumeDeleteIfExists(name: volumeName) } - - let (_, _, _, status) = try run(arguments: [ - "volume", "create", "--opt", "journal=none", volumeName, - ]) - #expect(status != 0, "volume create with journal=none should fail") - } - - @Test func testJournaledVolumeDataPersistence() throws { - let testName = getTestName() - let volumeName = "\(testName)_vol" - let container1Name = "\(testName)_c1" - let container2Name = "\(testName)_c2" - let testData = "journaled-data" - let testFile = "/data/test.txt" - - doVolumeDeleteIfExists(name: volumeName) - doRemoveIfExists(name: container1Name, force: true) - doRemoveIfExists(name: container2Name, force: true) - - defer { - try? doStop(name: container1Name) - doRemoveIfExists(name: container1Name, force: true) - try? doStop(name: container2Name) - doRemoveIfExists(name: container2Name, force: true) - doVolumeDeleteIfExists(name: volumeName) - } - - let (_, _, createError, createStatus) = try run(arguments: [ - "volume", "create", "--opt", "journal=ordered", volumeName, - ]) - guard createStatus == 0 else { - throw CLIError.executionFailed("volume create failed: \(createError)") - } - - try doLongRun(name: container1Name, args: ["-v", "\(volumeName):/data"]) - try waitForContainerRunning(container1Name) - _ = try doExec(name: container1Name, cmd: ["sh", "-c", "echo '\(testData)' > \(testFile)"]) - try doStop(name: container1Name) - - try doLongRun(name: container2Name, args: ["-v", "\(volumeName):/data"]) - try waitForContainerRunning(container2Name) - var output = try doExec(name: container2Name, cmd: ["cat", testFile]) - output = output.trimmingCharacters(in: .whitespacesAndNewlines) - #expect(output == testData, "expected '\(testData)', got '\(output)'") - - try doStop(name: container2Name) - try doVolumeDelete(name: volumeName) - } -} diff --git a/Tests/IntegrationTests/Build/BuildFixture.swift b/Tests/IntegrationTests/Build/BuildFixture.swift new file mode 100644 index 000000000..e3ffb578a --- /dev/null +++ b/Tests/IntegrationTests/Build/BuildFixture.swift @@ -0,0 +1,355 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import Darwin +import Foundation +import SystemPackage +import Testing + +// MARK: - Build context types + +extension ContainerFixture { + /// A file-system entry to materialize inside a build context directory. + enum FileSystemEntry { + case file( + _ path: String, + content: FileEntryContent, + permissions: FilePermissions = [.r, .w, .gr, .gw, .or, .ow], + uid: uid_t = 0, + gid: gid_t = 0 + ) + case directory( + _ path: String, + permissions: FilePermissions = [.r, .w, .x, .gr, .gw, .gx, .or, .ow, .ox], + uid: uid_t = 0, + gid: gid_t = 0 + ) + case symbolicLink(_ path: String, target: String, uid: uid_t = 0, gid: gid_t = 0) + } + + enum FileEntryContent { + case zeroFilled(size: Int64) + case data(Data) + } + + struct FilePermissions: OptionSet { + let rawValue: UInt16 + static let r = FilePermissions(rawValue: 0o400) + static let w = FilePermissions(rawValue: 0o200) + static let x = FilePermissions(rawValue: 0o100) + static let gr = FilePermissions(rawValue: 0o040) + static let gw = FilePermissions(rawValue: 0o020) + static let gx = FilePermissions(rawValue: 0o010) + static let or = FilePermissions(rawValue: 0o004) + static let ow = FilePermissions(rawValue: 0o002) + static let ox = FilePermissions(rawValue: 0o001) + } +} + +// MARK: - Builder lifecycle helpers + +extension ContainerFixture { + + /// Starts the buildkit builder container. + func builderStart(cpus: Int64 = 2, memoryInGBs: Int64 = 2) throws { + try run(["builder", "start", "-c", "\(cpus)", "-m", "\(memoryInGBs)GB"]).check() + } + + /// Stops the buildkit builder container. + func builderStop() throws { + try run(["builder", "stop"]).check() + } + + /// Deletes the buildkit builder container. + func builderDelete(force: Bool = false) throws { + var args = ["builder", "delete"] + if force { args.append("--force") } + try run(args).check() + } + + /// Polls until the buildkit container is running and the builder shim is ready. + func waitForBuilderRunning() async throws { + try waitForContainerRunning("buildkit", attempts: 10) + for _ in 0..<3 { + let response = try? doExec("buildkit", cmd: ["pidof", "-s", "container-builder-shim"]) + if let r = response, !r.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return + } + try await Task.sleep(for: .seconds(1)) + } + 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. + 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(_:)``. + 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 + +extension ContainerFixture { + + /// Creates a new scratch directory under ``testDir`` and returns its path. + /// + /// The directory is removed automatically when the fixture scope exits. + func createTempDir() throws -> FilePath { + let dir = testDir.appending(UUID().uuidString) + try FileManager.default.createDirectory( + atPath: dir.string, withIntermediateDirectories: true, attributes: nil) + return dir + } + + /// Writes `contents` to a new file under ``testDir`` with the given suffix. + func createTempFile(suffix: String, contents: Data) throws -> FilePath { + let file = testDir.appending(UUID().uuidString + suffix) + try contents.write(to: URL(filePath: file.string), options: .atomic) + return file + } + + /// Writes a Dockerfile and optional context entries into `dir`. + /// + /// Creates `dir/Dockerfile` (if `dockerfile` is non-empty) and + /// `dir/context/` populated with `context` entries. + func createContext(dir: FilePath, dockerfile: String, context: [FileSystemEntry]? = nil) throws { + if !dockerfile.isEmpty { + try Data(dockerfile.utf8).write(to: URL(filePath: dir.appending("Dockerfile").string), options: .atomic) + } + let contextDir = dir.appending("context") + try FileManager.default.createDirectory( + atPath: contextDir.string, withIntermediateDirectories: true, attributes: nil) + for entry in context ?? [] { + try createEntry(entry, contextDir: contextDir) + } + } + + /// Materializes a ``FileSystemEntry`` inside `contextDir`. + func createEntry(_ entry: FileSystemEntry, contextDir: FilePath) throws { + switch entry { + case .file(let path, let content, let permissions, let uid, let gid): + let fullPath = appendingRelative(contextDir, path) + let parentDir = fullPath.string.components(separatedBy: "/").dropLast().joined(separator: "/") + try FileManager.default.createDirectory( + atPath: parentDir, withIntermediateDirectories: true, attributes: nil) + switch content { + case .data(let data): + try data.write(to: URL(filePath: fullPath.string), options: .atomic) + case .zeroFilled(let size): + let zeros = Data(count: Int(size)) + try zeros.write(to: URL(filePath: fullPath.string), options: .atomic) + } + // Set permissions explicitly so they match the requested mode regardless of umask. + try FileManager.default.setAttributes( + [.posixPermissions: Int(permissions.rawValue)], + ofItemAtPath: fullPath.string) + // Ownership change silently ignored when not running as root. + _ = lchown(fullPath.string, uid, gid) + + case .directory(let path, let permissions, let uid, let gid): + let fullPath = appendingRelative(contextDir, path) + try FileManager.default.createDirectory( + atPath: fullPath.string, + withIntermediateDirectories: true, + attributes: [ + .posixPermissions: Int(permissions.rawValue), + .ownerAccountID: uid, + .groupOwnerAccountID: gid, + ]) + + case .symbolicLink(let path, let target, let uid, let gid): + let fullPath = appendingRelative(contextDir, path) + let parentDir = fullPath.string.components(separatedBy: "/").dropLast().joined(separator: "/") + try FileManager.default.createDirectory( + atPath: parentDir, withIntermediateDirectories: true, attributes: nil) + let targetPath = appendingRelative(contextDir, target) + let relativeDest = relativePathFrom(targetPath, from: fullPath) + try FileManager.default.createSymbolicLink( + atPath: fullPath.string, withDestinationPath: relativeDest) + lchown(fullPath.string, uid, gid) + } + } + + /// Appends a multi-component relative path (e.g. `"a/b/c"`) to a `FilePath` base. + private func appendingRelative(_ base: FilePath, _ relative: String) -> FilePath { + relative.split(separator: "/", omittingEmptySubsequences: true) + .reduce(base) { $0.appending(String($1)) } + } + + /// Computes the relative path from `base` to `dest`. + /// + /// - FIXME: This duplicates logic in `ContainerBuild/URL+Extensions.swift`. + /// Both copies should be extracted to `ContainerizationOS/FilePathOps` + /// in the containerization package. + private func relativePathFrom(_ dest: FilePath, from base: FilePath) -> String { + let destParts = dest.string.components(separatedBy: "/").filter { !$0.isEmpty } + let baseParts = base.string.components(separatedBy: "/").filter { !$0.isEmpty } + let common = zip(destParts, baseParts).prefix { $0.0 == $0.1 }.count + guard common > 0 else { return dest.string } + let ups = Array(repeating: "..", count: baseParts.count - common) + let remainder = Array(destParts.dropFirst(common)) + return (ups + remainder).joined(separator: "/") + } +} + +// MARK: - Build invocation helpers + +extension ContainerFixture { + + /// Builds an image from `contextDir/Dockerfile` with context `contextDir/context/`. + @discardableResult + func build( + tag: String, + contextDir: FilePath = FilePath("."), + buildArgs: [String] = [], + otherArgs: [String] = [] + ) throws -> String { + try buildWithPaths(tags: [tag], contextDir: contextDir, buildArgs: buildArgs, otherArgs: otherArgs) + } + + /// Builds using a context directory and an optional explicit Dockerfile path. + /// + /// Mirrors `container build [-f dockerfilePath] [contextDir]`: + /// - `tags` defaults to `[]`; when empty the runtime auto-generates a UUID tag + /// and prints it to stdout (call `.trimmingCharacters(in: .whitespacesAndNewlines)` + /// on the return value to obtain it) + /// - `contextDir` defaults to the current directory (`.`) + /// - `dockerfilePath` defaults to `nil`, resolved to `contextDir/Dockerfile` at call time + @discardableResult + func buildWithPaths( + tags: [String] = [], + contextDir: FilePath = FilePath("."), + dockerfilePath: FilePath? = nil, + buildArgs: [String] = [], + otherArgs: [String] = [] + ) throws -> String { + let contextPath = contextDir.appending("context") + let resolvedDockerfile = dockerfilePath ?? contextDir.appending("Dockerfile") + var args = ["build", "-f", resolvedDockerfile.string] + for tag in tags { args += ["-t", tag] } + for arg in buildArgs { args += ["--build-arg", arg] } + args.append(contextPath.string) + args.append(contentsOf: otherArgs) + let result = try run(args) + guard result.status == 0 else { + throw CommandError.executionFailed( + "build failed: stdout=\(result.output) stderr=\(result.error)") + } + return result.output + } + + /// Builds with a Dockerfile read from stdin. + @discardableResult + func buildWithStdin( + tags: [String], + contextDir: FilePath, + dockerfileContents: String, + buildArgs: [String] = [], + otherArgs: [String] = [] + ) throws -> String { + let contextPath = contextDir.appending("context") + var args = ["build", "-f", "-"] + for tag in tags { args += ["-t", tag] } + for arg in buildArgs { args += ["--build-arg", arg] } + args.append(contextPath.string) + args.append(contentsOf: otherArgs) + let result = try run(args, stdin: Data(dockerfileContents.utf8)) + guard result.status == 0 else { + throw CommandError.executionFailed( + "build failed: stdout=\(result.output) stderr=\(result.error)") + } + return result.output + } + + /// Builds with `--output type=local,dest=`. + @discardableResult + func buildWithPathsAndLocalOutput( + tag: String, + contextDir: FilePath = FilePath("."), + dockerfilePath: FilePath? = nil, + outputDir: FilePath, + buildArgs: [String] = [] + ) throws -> String { + let contextPath = contextDir.appending("context") + let resolvedDockerfile = dockerfilePath ?? contextDir.appending("Dockerfile") + var args = [ + "build", + "-f", resolvedDockerfile.string, + "-t", tag, + "--output", "type=local,dest=\(outputDir.string)", + ] + for arg in buildArgs { args += ["--build-arg", arg] } + args.append(contextPath.string) + let result = try run(args) + guard result.status == 0 else { + throw CommandError.executionFailed( + "build failed: stdout=\(result.output) stderr=\(result.error)") + } + return result.output + } +} + +// MARK: - Container exec helpers + +extension ContainerFixture { + /// Returns true if `path` exists as a regular file inside `container`. + func containerHasFile(_ container: String, at path: String) throws -> Bool { + try run(["exec", container, "test", "-f", path]).status == 0 + } + + /// Asserts that `path` exists as a regular file inside `container`. + func assertContainerHasFile(_ container: String, at path: String, _ comment: String? = nil) throws { + let exists = try containerHasFile(container, at: path) + #expect(exists, "\(comment ?? path) should exist in container") + } + + /// Asserts that `path` does NOT exist inside `container`. + func assertContainerMissingFile(_ container: String, at path: String, _ comment: String? = nil) throws { + let exists = try containerHasFile(container, at: path) + #expect(!exists, "\(comment ?? path) should NOT exist in container") + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift new file mode 100644 index 000000000..fc3e1d5c5 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift @@ -0,0 +1,138 @@ +//===----------------------------------------------------------------------===// +// 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 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 new file mode 100644 index 000000000..059cb38ae --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift @@ -0,0 +1,75 @@ +//===----------------------------------------------------------------------===// +// 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 Darwin +import Foundation +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(_:)``. +@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) } + + 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") + } + } + } + + @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) + } + + _ = 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/TestCLIBuilderLocalOutputSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift new file mode 100644 index 000000000..834d8a978 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift @@ -0,0 +1,147 @@ +//===----------------------------------------------------------------------===// +// 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 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 new file mode 100644 index 000000000..1e75cb780 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderSerial.swift @@ -0,0 +1,1104 @@ +//===----------------------------------------------------------------------===// +// 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 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 \(ContainerFixture.warmupImages[0])\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/TestCLIBuilderTarExportSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift new file mode 100644 index 000000000..543afe394 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift @@ -0,0 +1,125 @@ +//===----------------------------------------------------------------------===// +// 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 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/Containers/TestCLINotFound.swift b/Tests/IntegrationTests/Containers/TestCLINotFound.swift new file mode 100644 index 000000000..6602ed694 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLINotFound.swift @@ -0,0 +1,44 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +/// Tests that stop, kill, and delete return errors for non-existent containers. +@Suite +struct TestCLINotFound { + + @Test func testStopNonExistentContainer() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["stop", "does-not-exist"]) + #expect(result.status != 0, "stop should fail for a non-existent container") + } + } + + @Test func testKillNonExistentContainer() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["kill", "does-not-exist"]) + #expect(result.status != 0, "kill should fail for a non-existent container") + } + } + + @Test func testDeleteNonExistentContainer() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["delete", "does-not-exist"]) + #expect(result.status != 0, "delete should fail for a non-existent container") + } + } +} diff --git a/Tests/IntegrationTests/Images/TestCLIImagesCommand.swift b/Tests/IntegrationTests/Images/TestCLIImagesCommand.swift new file mode 100644 index 000000000..862705ef9 --- /dev/null +++ b/Tests/IntegrationTests/Images/TestCLIImagesCommand.swift @@ -0,0 +1,338 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationArchive +import ContainerizationOCI +import Foundation +import Testing + +@Suite +struct TestCLIImagesCommand { + private let alpine = ContainerFixture.warmupImages[0] // ghcr.io/linuxcontainers/alpine:3.20 + private let alpine318 = ContainerFixture.warmupImages[1] // ghcr.io/linuxcontainers/alpine:3.18 + private let busybox = ContainerFixture.warmupImages[2] // ghcr.io/containerd/busybox:1.36 + + /// Host architecture string for platform tests. + private var hostArchitecture: String { + #if arch(arm64) + return "arm64" + #else + return "amd64" + #endif + } + + @Test func testPull() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + #expect(try f.isImagePresent(alpine), "expected \(alpine) to be present") + } + } + + @Test func testPullMulti() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + try f.doPull(busybox) + #expect(try f.isImagePresent(alpine), "expected \(alpine) to be present") + #expect(try f.isImagePresent(busybox), "expected \(busybox) to be present") + } + } + + @Test func testPullPlatform() async throws { + try await ContainerFixture.with { f in + let os = "linux" + let arch = "amd64" + try f.doPull(alpine, args: ["--platform", "\(os)/\(arch)"]) + let output = try f.doInspectImages(alpine) + #expect(output.count == 1) + #expect( + output[0].variants.contains { $0.platform.os == os && $0.platform.architecture == arch }, + "expected variant for \(os)/\(arch) in \(output[0])") + } + } + + @Test func testPullOsArch() async throws { + try await ContainerFixture.with { f in + let os = "linux" + let arch = "amd64" + try f.doPull(alpine318, args: ["--os", os, "--arch", arch]) + let output = try f.doInspectImages(alpine318) + #expect(output.count == 1) + #expect( + output[0].variants.contains { $0.platform.os == os && $0.platform.architecture == arch }, + "expected variant for \(os)/\(arch)") + } + } + + @Test func testPullOs() async throws { + try await ContainerFixture.with { f in + let os = "linux" + let arch = hostArchitecture + try f.doPull(alpine318, args: ["--os", os]) + let output = try f.doInspectImages(alpine318) + #expect(output.count == 1) + #expect( + output[0].variants.contains { $0.platform.os == os && $0.platform.architecture == arch }, + "expected variant for \(os)/\(arch)") + } + } + + @Test func testPullArch() async throws { + try await ContainerFixture.with { f in + let os = "linux" + let arch = "amd64" + try f.doPull(alpine318, args: ["--arch", arch]) + let output = try f.doInspectImages(alpine318) + #expect(output.count == 1) + #expect( + output[0].variants.contains { $0.platform.os == os && $0.platform.architecture == arch }, + "expected variant for \(os)/\(arch)") + } + } + + @Test func testPullRemoveSingle() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + #expect(try f.isImagePresent(alpine)) + let tagged = "\((try Reference.parse(alpine)).name):testPullRemoveSingle" + try f.doImageTag(alpine, newName: tagged) + #expect(try f.isImagePresent(tagged)) + try f.doRemoveImages([tagged]) + #expect(!(try f.isImagePresent(tagged)), "expected \(tagged) to be removed") + } + } + + @Test func testImageTag() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + let tagged = "\((try Reference.parse(alpine)).name):testImageTag" + try f.doImageTag(alpine, newName: tagged) + #expect(try f.isImagePresent(tagged)) + } + } + + @Test func testImageSaveAndLoad() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + try f.doPull(busybox) + + let alpineTagged = "\((try Reference.parse(alpine)).name):testImageSaveAndLoad" + let busyboxTagged = "\((try Reference.parse(busybox)).name):testImageSaveAndLoad" + try f.doImageTag(alpine, newName: alpineTagged) + try f.doImageTag(busybox, newName: busyboxTagged) + #expect(try f.isImagePresent(alpineTagged)) + #expect(try f.isImagePresent(busyboxTagged)) + + let tempFile = f.testDir.appending("save-\(UUID().uuidString).tar") + try f.run(["image", "save", alpineTagged, busyboxTagged, "--output", tempFile.string]).check() + + try f.doRemoveImages([alpineTagged, busyboxTagged]) + #expect(!(try f.isImagePresent(alpineTagged))) + #expect(!(try f.isImagePresent(busyboxTagged))) + + try f.run(["image", "load", "-i", tempFile.string]).check() + #expect(try f.isImagePresent(alpineTagged)) + #expect(try f.isImagePresent(busyboxTagged)) + } + } + + @Test func testImageSaveToStdoutProducesCleanArchive() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + let tagged = "\((try Reference.parse(alpine)).name):testImageSaveToStdout" + try f.doImageTag(alpine, newName: tagged) + f.addCleanup { try? f.doRemoveImages([tagged]) } + + let result = try f.run(["image", "save", tagged]) + try result.check("save to stdout failed") + + #expect(result.outputData.count >= 1024, "stdout archive too small to contain tar EOF marker") + let trailer = result.outputData.suffix(1024) + #expect(trailer.allSatisfy { $0 == 0 }, "stdout archive has trailing non-archive bytes after tar EOF marker") + #expect(result.error.contains(tagged), "expected saved image reference on stderr") + } + } + + @Test func testImageSaveMissingPlatform() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + let tagged = "\((try Reference.parse(alpine)).name):testImageSaveMissingPlatform" + try f.doImageTag(alpine, newName: tagged) + f.addCleanup { try? f.doRemoveImages([tagged]) } + + let tempFile = f.testDir.appending("save-missing.tar") + let result = try f.run([ + "image", "save", tagged, + "--platform", "linux/arm/v5", + "--output", tempFile.string, + ]) + #expect(result.status != 0, "expected save to fail for missing platform") + #expect(result.error.contains("has no content for platform")) + #expect(result.error.contains("available platforms:")) + } + } + + @Test func testMaxConcurrentDownloadsValidation() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["image", "pull", "--max-concurrent-downloads", "0", "alpine:latest"]) + #expect(result.status != 0) + #expect(result.error.contains("maximum number of concurrent downloads must be greater than 0")) + } + } + + @Test func testImageLoadRejectsInvalidMembersWithoutForce() async throws { + try await ContainerFixture.with { f in + let maliciousFilename = "pwned-\(UUID().uuidString).txt" + try f.doPull(alpine) + let tagged = "\((try Reference.parse(alpine)).name):testImageLoadRejectsInvalidMembers" + try f.doImageTag(alpine, newName: tagged) + #expect(try f.isImagePresent(tagged)) + + let tempFile = f.testDir.appending("save.tar") + try f.run(["image", "save", tagged, "--output", tempFile.string]).check() + try addInvalidMemberToTar(tarPath: tempFile.string, maliciousFilename: maliciousFilename) + + try f.doRemoveImages([tagged]) + #expect(!(try f.isImagePresent(tagged))) + + let loadResult = try f.run(["image", "load", "-i", tempFile.string]) + #expect(loadResult.status != 0, "expected load to fail without force flag") + #expect(loadResult.error.contains("rejected paths") || loadResult.error.contains(maliciousFilename)) + #expect( + !FileManager.default.fileExists(atPath: "/tmp/\(maliciousFilename)"), + "malicious file should not have been created") + } + } + + @Test func testImageLoadAcceptsInvalidMembersWithForce() async throws { + try await ContainerFixture.with { f in + let maliciousFilename = "pwned-\(UUID().uuidString).txt" + try f.doPull(alpine) + let tagged = "\((try Reference.parse(alpine)).name):testImageLoadAcceptsInvalidMembers" + try f.doImageTag(alpine, newName: tagged) + f.addCleanup { try? f.doRemoveImages([tagged]) } + + let tempFile = f.testDir.appending("save.tar") + try f.run(["image", "save", tagged, "--output", tempFile.string]).check() + try addInvalidMemberToTar(tarPath: tempFile.string, maliciousFilename: maliciousFilename) + + try f.doRemoveImages([tagged]) + let loadResult = try f.run(["image", "load", "-i", tempFile.string, "--force"]) + #expect(loadResult.status == 0, "expected load to succeed with force flag") + #expect(loadResult.error.contains("invalid members") || loadResult.error.contains(maliciousFilename)) + #expect(try f.isImagePresent(tagged)) + #expect( + !FileManager.default.fileExists(atPath: "/tmp/\(maliciousFilename)"), + "malicious file should not have been created") + } + } + + @Test func testImageSaveAndLoadStdinStdout() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + try f.doPull(busybox) + + let alpineTagged = "\((try Reference.parse(alpine)).name):testImageSaveAndLoadStdinStdout" + let busyboxTagged = "\((try Reference.parse(busybox)).name):testImageSaveAndLoadStdinStdout" + try f.doImageTag(alpine, newName: alpineTagged) + try f.doImageTag(busybox, newName: busyboxTagged) + #expect(try f.isImagePresent(alpineTagged)) + #expect(try f.isImagePresent(busyboxTagged)) + + let saveResult = try f.run(["image", "save", alpineTagged, busyboxTagged]).check() + try f.doRemoveImages([alpineTagged, busyboxTagged]) + #expect(!(try f.isImagePresent(alpineTagged))) + #expect(!(try f.isImagePresent(busyboxTagged))) + + try f.run(["image", "load"], stdin: saveResult.outputData).check() + #expect(try f.isImagePresent(alpineTagged)) + #expect(try f.isImagePresent(busyboxTagged)) + } + } + + @Test func testImageVariantSizeFieldExists() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + let result = try f.run(["image", "ls", "--format", "json"]).check() + guard let json = try JSONSerialization.jsonObject(with: result.outputData) as? [[String: Any]], + let image = json.first + else { + Issue.record("failed to parse image list JSON or no images found") + return + } + let variants = image["variants"] as? [[String: Any]] ?? [] + #expect(!variants.isEmpty, "expected at least one variant") + #expect( + variants.contains { ($0["size"] as? Int ?? 0) > 0 }, + "expected at least one variant with non-zero size") + } + } + + @Test func testImageListTableFormat() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + let result = try f.run(["image", "ls"]).check() + #expect(["NAME", "TAG", "DIGEST"].allSatisfy { result.output.contains($0) }) + #expect(result.output.contains("alpine")) + } + } + + @Test func testInspectMissingImageFails() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["image", "inspect", "definitely-missing-image:latest"]) + #expect(result.status != 0) + #expect(result.error.contains("image not found")) + } + } + + @Test func testImageLoadMissingFileErrorToStderr() async throws { + try await ContainerFixture.with { f in + let missingPath = "/path/that/does/not/exist-\(UUID().uuidString)" + let result = try f.run(["image", "load", "-i", missingPath]) + #expect(result.status != 0) + #expect(result.output.isEmpty, "stdout should be empty") + #expect(result.error.contains("file does not exist") && result.error.contains(missingPath)) + } + } + + // MARK: - Private helpers + + private func addInvalidMemberToTar(tarPath: String, maliciousFilename: String) throws { + let evilEntryName = "../../../../../../../../../../../tmp/\(maliciousFilename)" + let evilEntryContent = "pwned\n".data(using: .utf8)! + let tempModifiedTar = URL(filePath: tarPath + ".modified") + + let writer = try ArchiveWriter(format: .pax, filter: .none, file: tempModifiedTar) + let reader = try ArchiveReader(file: URL(fileURLWithPath: tarPath)) + for (entry, data) in reader { + if entry.fileType == .regular { + try writer.writeEntry(entry: entry, data: data) + } else { + try writer.writeEntry(entry: entry, data: nil) + } + } + let evilEntry = WriteEntry() + evilEntry.path = evilEntryName + evilEntry.size = Int64(evilEntryContent.count) + evilEntry.modificationDate = Date() + evilEntry.fileType = .regular + evilEntry.permissions = 0o644 + try writer.writeEntry(entry: evilEntry, data: evilEntryContent) + try writer.finishEncoding() + + try FileManager.default.removeItem(atPath: tarPath) + try FileManager.default.moveItem(at: tempModifiedTar, to: URL(fileURLWithPath: tarPath)) + } +} diff --git a/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift b/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift new file mode 100644 index 000000000..275e7428b --- /dev/null +++ b/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift @@ -0,0 +1,88 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerAPIClient +import ContainerPersistence +import ContainerizationArchive +import Foundation +import Testing + +/// Tests for `container system kernel set`. Each test modifies the global default +/// kernel binary, so the suite must run fully serialised. +@Suite(.serialized) +struct TestCLIKernelSetSerial { + private let remoteTar = ContainerSystemConfig().kernel.url + private let defaultBinaryPath = ContainerSystemConfig().kernel.binaryPath + + // MARK: - Tests + + @Test func fromLocalTar() async throws { + let symlinkBinaryPath = URL(filePath: defaultBinaryPath) + .deletingLastPathComponent() + .appending(path: "vmlinux.container") + .relativePath + + try await ContainerFixture.with { f in + f.addCleanup { _ = try? f.run(["system", "kernel", "set", "--recommended", "--force"]) } + try await f.withTempDir { tempDir in + let localTarPath = tempDir.appending(path: remoteTar.lastPathComponent) + try await ContainerAPIClient.FileDownloader.downloadFile(url: remoteTar, to: localTarPath) + try f.run(["system", "kernel", "set", "--force", "--tar", localTarPath.path, "--binary", symlinkBinaryPath]).check() + try await validateContainerRun(f) + } + } + } + + @Test func fromRemoteTarSymlink() async throws { + let symlinkBinaryPath = URL(filePath: defaultBinaryPath) + .deletingLastPathComponent() + .appending(path: "vmlinux.container") + .relativePath + + try await ContainerFixture.with { f in + f.addCleanup { _ = try? f.run(["system", "kernel", "set", "--recommended", "--force"]) } + try f.run(["system", "kernel", "set", "--force", "--tar", remoteTar.absoluteString, "--binary", symlinkBinaryPath]).check() + try await validateContainerRun(f) + } + } + + @Test func fromLocalDisk() async throws { + try await ContainerFixture.with { f in + f.addCleanup { _ = try? f.run(["system", "kernel", "set", "--recommended", "--force"]) } + try await f.withTempDir { tempDir in + let localTarPath = tempDir.appending(path: remoteTar.lastPathComponent) + try await ContainerAPIClient.FileDownloader.downloadFile(url: remoteTar, to: localTarPath) + + let targetPath = tempDir.appending(path: URL(string: defaultBinaryPath)!.lastPathComponent) + let archiveReader = try ArchiveReader(file: localTarPath) + let (_, data) = try archiveReader.extractFile(path: defaultBinaryPath) + try data.write(to: targetPath, options: .atomic) + + try f.run(["system", "kernel", "set", "--force", "--binary", targetPath.path]).check() + try await validateContainerRun(f) + } + } + } + + // MARK: - Private helpers + + private func validateContainerRun(_ f: ContainerFixture) async throws { + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + _ = try f.doExec(name, cmd: ["date"]) + } + } +} diff --git a/Tests/IntegrationTests/System/TestCLISystemDFSerial.swift b/Tests/IntegrationTests/System/TestCLISystemDFSerial.swift new file mode 100644 index 000000000..e7e3b7276 --- /dev/null +++ b/Tests/IntegrationTests/System/TestCLISystemDFSerial.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +/// Tests for `container system df`. All tests clear and inspect the global image +/// store, so they must run serially with no concurrent image activity. +@Suite(.serialized) +struct TestCLISystemDFSerial { + private struct DiskUsageStats: Decodable { + let images: ResourceUsage + } + private struct ResourceUsage: Decodable { + let active: Int + let reclaimable: UInt64 + let sizeInBytes: UInt64 + let total: Int + } + + private let alpine = ContainerFixture.warmupImages[0] + + // Issue #1526: reported image size must include content blobs, not just unpacked snapshots. + @Test func imageDiskUsageIsPopulatedAfterPull() async throws { + try await ContainerFixture.with { f in + try withCleanImageStore(f) { + try f.doPull(self.alpine) + let stats = try systemDiskUsage(f) + #expect(stats.images.total >= 1) + #expect(stats.images.active == 0) + #expect(stats.images.sizeInBytes > 0) + #expect(stats.images.reclaimable == stats.images.sizeInBytes) + } + } + } + + // Issue #1527: tagging the same image must not double-count its storage. + @Test func tagsDoNotDoubleCountImageStorage() async throws { + try await ContainerFixture.with { f in + try withCleanImageStore(f) { + try f.doPull(self.alpine) + let before = try systemDiskUsage(f) + try f.doImageTag(self.alpine, newName: "local/system-df-alpine:tag-one") + try f.doImageTag(self.alpine, newName: "local/system-df-alpine:tag-two") + let after = try systemDiskUsage(f) + #expect(after.images.total == before.images.total + 2) + #expect(after.images.sizeInBytes == before.images.sizeInBytes) + #expect(after.images.reclaimable == before.images.reclaimable) + } + } + } + + // Issue #1527: removing one of several tags must not free shared storage. + @Test func deletingOneOfMultipleTagsPreservesSharedStorage() async throws { + try await ContainerFixture.with { f in + try withCleanImageStore(f) { + let baseline = try systemDiskUsage(f) + try f.doPull(self.alpine) + try f.doImageTag(self.alpine, newName: "local/system-df-alpine:delete-probe") + let beforeDelete = try systemDiskUsage(f) + + try f.doRemoveImages(["local/system-df-alpine:delete-probe"]) + let afterAliasDelete = try systemDiskUsage(f) + #expect(afterAliasDelete.images.total == beforeDelete.images.total - 1) + #expect(afterAliasDelete.images.sizeInBytes == beforeDelete.images.sizeInBytes) + #expect(afterAliasDelete.images.reclaimable == beforeDelete.images.reclaimable) + + _ = try? f.doRemoveImages() + let afterFullClean = try systemDiskUsage(f) + #expect(afterFullClean.images.total <= baseline.images.total) + #expect(afterFullClean.images.sizeInBytes <= baseline.images.sizeInBytes) + } + } + } + + // MARK: - Private helpers + + private func withCleanImageStore(_ f: ContainerFixture, _ body: () throws -> Void) throws { + _ = try? f.doRemoveImages() + defer { _ = try? f.doRemoveImages() } + try body() + } + + private func systemDiskUsage(_ f: ContainerFixture) throws -> DiskUsageStats { + let result = try f.run(["system", "df", "--format", "json"]).check() + return try JSONDecoder().decode(DiskUsageStats.self, from: result.outputData) + } +} diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+ImageHelpers.swift b/Tests/IntegrationTests/Utilities/ContainerFixture+ImageHelpers.swift new file mode 100644 index 000000000..f8c04b152 --- /dev/null +++ b/Tests/IntegrationTests/Utilities/ContainerFixture+ImageHelpers.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +// MARK: - Image inspect types + +extension ContainerFixture { + /// Decoded output of `container image inspect` or `container image list --format json`. + struct ImageInspectOutput: Codable { + struct Configuration: Codable { let name: String } + struct Variant: Codable { + struct Platform: Codable { + let os: String + let architecture: String + } + let platform: Platform + } + let configuration: Configuration + let variants: [Variant] + } +} + +// MARK: - Image lifecycle helpers + +extension ContainerFixture { + + /// Pulls an image. Passes optional extra args (e.g. `["--platform", "linux/amd64"]`). + func doPull(_ imageName: String, args: [String] = []) throws { + var pullArgs = ["image", "pull"] + args + pullArgs.append(imageName) + try run(pullArgs).check() + } + + /// Returns all images currently in the local store. + func doListImages() throws -> [ImageInspectOutput] { + let result = try run(["image", "list", "--format", "json"]).check() + return try JSONDecoder().decode([ImageInspectOutput].self, from: result.outputData) + } + + /// Returns true if an image with the given exact reference is present. + func isImagePresent(_ targetImage: String) throws -> Bool { + try doListImages().contains { $0.configuration.name == targetImage } + } + + /// Tags `image` with `newName`. + func doImageTag(_ image: String, newName: String) throws { + try run(["image", "tag", image, newName]).check() + } + + /// Removes the given images, or all images when `images` is `nil`. + func doRemoveImages(_ images: [String]? = nil) throws { + var args = ["image", "rm"] + if let images { args.append(contentsOf: images) } else { args.append("--all") } + try run(args).check() + } + + /// Returns the full inspect output for an image, including variant information. + func doInspectImages(_ name: String) throws -> [ImageInspectOutput] { + let result = try run(["image", "inspect", name]).check() + return try JSONDecoder().decode([ImageInspectOutput].self, from: result.outputData) + } + + /// Returns the `configuration.name` of an image. + func inspectImage(_ name: String) throws -> String { + let outputs = try doInspectImages(name) + guard let first = outputs.first else { + throw CommandError.executionFailed("image '\(name)' not found in inspect output") + } + return first.configuration.name + } + + /// Asserts that the image was successfully built and is present in the image store. + func assertImageBuilt(_ image: String) throws { + let name = try inspectImage(image) + #expect(name == image, "expected image \(image) to be present") + } +} diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+SystemHelpers.swift b/Tests/IntegrationTests/Utilities/ContainerFixture+SystemHelpers.swift new file mode 100644 index 000000000..1c0069c04 --- /dev/null +++ b/Tests/IntegrationTests/Utilities/ContainerFixture+SystemHelpers.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +// MARK: - System helpers + +extension ContainerFixture { + /// Creates a temporary directory under ``testDir``, passes its `URL` to `body`, + /// then removes it when `body` exits (cleanup handled by the fixture scope). + func withTempDir(_ body: (URL) async throws -> T) async throws -> T { + let dir = URL(filePath: testDir.appending(UUID().uuidString).string) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return try await body(dir) + } +} diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+VolumeHelpers.swift b/Tests/IntegrationTests/Utilities/ContainerFixture+VolumeHelpers.swift new file mode 100644 index 000000000..6bf96be59 --- /dev/null +++ b/Tests/IntegrationTests/Utilities/ContainerFixture+VolumeHelpers.swift @@ -0,0 +1,87 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +// MARK: - Volume lifecycle helpers + +extension ContainerFixture { + + /// Creates a named volume, optionally with extra `--opt` arguments. + func doVolumeCreate(_ name: String, opts: [String] = []) throws { + var args = ["volume", "create"] + for opt in opts { args += ["--opt", opt] } + args.append(name) + try run(args).check() + } + + /// Deletes a volume, throwing on failure. + func doVolumeDelete(_ name: String) throws { + try run(["volume", "rm", name]).check() + } + + /// Deletes a volume, silently ignoring errors. + func doVolumeDeleteIfExists(_ name: String) { + _ = try? run(["volume", "rm", name]) + } + + /// Returns `true` if `volume rm` exits non-zero (i.e. the delete was blocked). + func doesVolumeDeleteFail(_ name: String) throws -> Bool { + try run(["volume", "rm", name]).status != 0 + } + + /// Returns the names of all volume attachments on a container + /// (the UUID name for anonymous volumes, the explicit name for named volumes). + func getContainerMountedVolumeNames(_ containerName: String) throws -> [String] { + let inspect = try inspectContainer(containerName) + return inspect.configuration.mounts.compactMap { mount in + if case .volume(let name, _, _, _) = mount.type { return name } + return nil + } + } + + /// Returns the names of all anonymous volumes (UUID-format names) in the local store. + func getAnonymousVolumeNames() throws -> [String] { + let result = try run(["volume", "list", "--quiet"]).check() + return result.output + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && isAnonymousVolumeName($0) } + } + + /// Deletes all currently known anonymous volumes. Useful before count-based assertions. + func doCleanupAnonymousVolumes() { + for vol in (try? getAnonymousVolumeNames()) ?? [] { + doVolumeDeleteIfExists(vol) + } + } + + /// Returns `true` if a volume with the given name appears in `volume list`. + func volumeExists(_ name: String) throws -> Bool { + let result = try run(["volume", "list", "--quiet"]).check() + return result.output + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .contains(name) + } + + /// Returns `true` if `name` has the UUID format used for anonymous volumes. + private func isAnonymousVolumeName(_ name: String) -> Bool { + let pattern = #"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"# + guard let regex = try? Regex(pattern) else { return false } + return (try? regex.firstMatch(in: name)) != nil + } +} diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture.swift b/Tests/IntegrationTests/Utilities/ContainerFixture.swift index 151d8db14..1ef172a7c 100644 --- a/Tests/IntegrationTests/Utilities/ContainerFixture.swift +++ b/Tests/IntegrationTests/Utilities/ContainerFixture.swift @@ -74,8 +74,7 @@ final class ContainerFixture: Sendable { let testID: String /// Scratch directory for build inputs, test data, and command output. - /// Created at fixture init; removed on cleanup unless `CLITEST_PRESERVE_SCRATCH` - /// is set in the environment. + /// Created at fixture init; removed on cleanup unless `CLITEST_PRESERVE_SCRATCH=true`. let testDir: FilePath // MARK: - Unstructured API @@ -91,15 +90,20 @@ final class ContainerFixture: Sendable { ProcessInfo.processInfo.environment["CLITEST_SCRATCH_ROOT"] .map { FilePath($0) } ?? FilePath(FileManager.default.temporaryDirectory.path) - let testDir = scratchRoot.appending(testID) - try FileManager.default.createDirectory( - atPath: testDir.string, withIntermediateDirectories: true, attributes: nil) let testName = Test.current.map { $0.name.hasSuffix("()") ? String($0.name.dropLast(2)) : $0.name } ?? testID let suiteName = Test.current.map { "\(type(of: $0))" } ?? "unknown" + // Name the scratch directory so it's immediately identifiable when browsing: + // {sanitizedTestName}-{testID} + let safeName = testName.replacingOccurrences( + of: "[^a-zA-Z0-9]", with: "-", options: .regularExpression) + let testDir = scratchRoot.appending("\(safeName)-\(testID)") + try FileManager.default.createDirectory( + atPath: testDir.string, withIntermediateDirectories: true, attributes: nil) + var logger = Logger(label: "com.apple.container.test") { label in if let root = ProcessInfo.processInfo.environment["CLITEST_LOG_ROOT"], !root.isEmpty { let path = @@ -117,7 +121,7 @@ final class ContainerFixture: Sendable { let fixture = ContainerFixture(testID: testID, testDir: testDir, log: logger) - if ProcessInfo.processInfo.environment["CLITEST_PRESERVE_SCRATCH"] == nil { + if ProcessInfo.processInfo.environment["CLITEST_PRESERVE_SCRATCH"] != "true" { fixture.addCleanup { try? FileManager.default.removeItem(atPath: testDir.string) } diff --git a/Tests/IntegrationTests/Volumes/TestCLIAnonymousVolumes.swift b/Tests/IntegrationTests/Volumes/TestCLIAnonymousVolumes.swift new file mode 100644 index 000000000..9bf427b74 --- /dev/null +++ b/Tests/IntegrationTests/Volumes/TestCLIAnonymousVolumes.swift @@ -0,0 +1,251 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerResource +import Foundation +import Testing + +/// Tests for anonymous (UUID-named) volumes. +/// +/// Each test discovers its volumes via container inspect rather than counting +/// global volume state, so the suite runs in the concurrent pass. +@Suite +struct TestCLIAnonymousVolumes { + private let alpine = ContainerFixture.warmupImages[0] + + @Test func testAnonymousVolumeCreationAndPersistence() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) + try f.waitForContainerRunning(c) + + let volumeIDs = try f.getContainerMountedVolumeNames(c) + try #require(volumeIDs.count == 1, "should have exactly one anonymous volume") + let volumeID = volumeIDs[0] + f.addCleanup { f.doVolumeDeleteIfExists(volumeID) } + + try f.doStop(c) + try f.doRemove(c) + #expect(try f.volumeExists(volumeID), "anonymous volume should persist after container removal") + } + } + + @Test func testAnonymousVolumePersistenceWithoutRm() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c1" + try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) + try f.waitForContainerRunning(c) + _ = try f.doExec(c, cmd: ["sh", "-c", "echo 'persistent-data' > /data/test.txt"]) + + let volumeIDs = try f.getContainerMountedVolumeNames(c) + try #require(volumeIDs.count == 1) + let volumeID = volumeIDs[0] + f.addCleanup { f.doVolumeDeleteIfExists(volumeID) } + + try f.doStop(c) + try f.doRemove(c) + #expect(try f.volumeExists(volumeID), "anonymous volume should persist without --rm") + + let c2 = "\(f.testID)-c2" + try f.doLongRun(name: c2, image: image, args: ["-v", "\(volumeID):/data"], autoRemove: false) + try f.waitForContainerRunning(c2) + let output = try f.doExec(c2, cmd: ["cat", "/data/test.txt"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == "persistent-data") + try f.doStop(c2) + try f.doRemove(c2) + } + } + + @Test func testMultipleAnonymousVolumes() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun( + name: c, image: image, + args: ["-v", "/data1", "-v", "/data2", "-v", "/data3"], autoRemove: false) + try f.waitForContainerRunning(c) + + let volumeIDs = try f.getContainerMountedVolumeNames(c) + #expect(volumeIDs.count == 3, "should have 3 anonymous volumes") + f.addCleanup { for v in volumeIDs { f.doVolumeDeleteIfExists(v) } } + + try f.doStop(c) + try f.doRemove(c) + for v in volumeIDs { #expect(try f.volumeExists(v), "volume \(v) should persist") } + } + } + + @Test func testAnonymousMountSyntax() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun( + name: c, image: image, + args: ["--mount", "type=volume,dst=/mydata"], autoRemove: false) + try f.waitForContainerRunning(c) + + let volumeIDs = try f.getContainerMountedVolumeNames(c) + #expect(volumeIDs.count == 1, "should have one anonymous volume from --mount syntax") + f.addCleanup { for v in volumeIDs { f.doVolumeDeleteIfExists(v) } } + try f.doStop(c) + try f.doRemove(c) + #expect(try f.volumeExists(volumeIDs[0])) + } + } + + @Test func testAnonymousVolumeUUIDFormat() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) + try f.waitForContainerRunning(c) + + // Capture volume IDs before any stop/remove so cleanup and assert can use them. + let volumeIDs = try f.getContainerMountedVolumeNames(c) + try #require(volumeIDs.count == 1) + let volumeID = volumeIDs[0] + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + f.doVolumeDeleteIfExists(volumeID) + } + + #expect(volumeID.count == 36, "volume name should be 36 characters (UUID format)") + } + } + + @Test func testAnonymousVolumeMetadata() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) + try f.waitForContainerRunning(c) + + // Capture volume ID before stop/remove. + let volumeIDs = try f.getContainerMountedVolumeNames(c) + try #require(volumeIDs.count == 1) + let volumeID = volumeIDs[0] + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + f.doVolumeDeleteIfExists(volumeID) + } + + let result = try f.run(["volume", "list", "--format", "json"]).check() + #expect(result.output.contains("\"creationDate\"")) + #expect(!result.output.contains("\"createdAt\"")) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let volumes = try decoder.decode([VolumeResource].self, from: result.outputData) + let anonVolume = volumes.first { $0.name == volumeID } + try #require(anonVolume != nil, "should find anonymous volume in list") + #expect(anonVolume!.isAnonymous == true) + } + } + + @Test func testAnonymousVolumeListDisplay() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let namedVol = "\(f.testID)-namedvol" + let c = "\(f.testID)-c" + try f.doVolumeCreate(namedVol) + f.addCleanup { f.doVolumeDeleteIfExists(namedVol) } + + try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) + try f.waitForContainerRunning(c) + + // Capture volume IDs while container is running. + let volumeIDs = try f.getContainerMountedVolumeNames(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + for v in volumeIDs { f.doVolumeDeleteIfExists(v) } + } + + let result = try f.run(["volume", "list"]).check() + #expect(result.output.contains("TYPE")) + #expect(result.output.contains("named")) + #expect(result.output.contains("anonymous")) + #expect(result.output.contains(namedVol)) + } + } + + @Test func testAnonymousVolumeMixedWithNamedVolume() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let namedVol = "\(f.testID)-namedvol" + let c = "\(f.testID)-c" + try f.doVolumeCreate(namedVol) + f.addCleanup { f.doVolumeDeleteIfExists(namedVol) } + + try f.doLongRun( + name: c, image: image, + args: ["-v", "\(namedVol):/named", "-v", "/anon"], autoRemove: false) + try f.waitForContainerRunning(c) + + let allVolumeIDs = try f.getContainerMountedVolumeNames(c) + let anonVols = allVolumeIDs.filter { $0 != namedVol } + #expect(anonVols.count == 1, "should have one anonymous volume") + f.addCleanup { for v in anonVols { f.doVolumeDeleteIfExists(v) } } + + try f.doStop(c) + try f.doRemove(c) + #expect(try f.volumeExists(namedVol), "named volume should persist") + #expect(try f.volumeExists(anonVols[0]), "anonymous volume should persist") + } + } + + @Test func testAnonymousVolumeManualDeletion() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) + try f.waitForContainerRunning(c) + + let volumeIDs = try f.getContainerMountedVolumeNames(c) + try #require(volumeIDs.count == 1) + let volumeID = volumeIDs[0] + + try f.doStop(c) + try f.doRemove(c) + let result = try f.run(["volume", "rm", volumeID]) + #expect(result.status == 0, "manual deletion of unmounted anonymous volume should succeed") + #expect(!(try f.volumeExists(volumeID))) + } + } + + @Test func testAnonymousVolumeDetachedMode() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) + try f.waitForContainerRunning(c) + + let volumeIDs = try f.getContainerMountedVolumeNames(c) + try #require(volumeIDs.count == 1) + let volumeID = volumeIDs[0] + f.addCleanup { f.doVolumeDeleteIfExists(volumeID) } + + try f.doStop(c) + try f.doRemove(c) + #expect(try f.volumeExists(volumeID), "anonymous volume should persist after container removal") + } + } +} diff --git a/Tests/IntegrationTests/Volumes/TestCLIVolumes.swift b/Tests/IntegrationTests/Volumes/TestCLIVolumes.swift new file mode 100644 index 000000000..a7fc2ee57 --- /dev/null +++ b/Tests/IntegrationTests/Volumes/TestCLIVolumes.swift @@ -0,0 +1,269 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@Suite +struct TestCLIVolumes { + private let alpine = ContainerFixture.warmupImages[0] + + @Test func testVolumeDataPersistenceAcrossContainers() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + let c1 = "\(f.testID)-c1" + let c2 = "\(f.testID)-c2" + try f.doPull(alpine) + let image = try f.copyWarmupImage(alpine) + f.addCleanup { + f.doVolumeDeleteIfExists(vol) + try? f.doRemoveIfExists(c1, force: true, ignoreFailure: true) + try? f.doRemoveIfExists(c2, force: true, ignoreFailure: true) + } + + try f.doVolumeCreate(vol) + try f.doLongRun(name: c1, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) + try f.waitForContainerRunning(c1) + _ = try f.doExec(c1, cmd: ["sh", "-c", "echo 'persistent-data-test' > /data/test.txt"]) + try f.doStop(c1) + try f.doRemove(c1) + try f.doLongRun(name: c2, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) + try f.waitForContainerRunning(c2) + let output = try f.doExec(c2, cmd: ["cat", "/data/test.txt"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == "persistent-data-test") + try f.doStop(c2) + try f.doRemove(c2) + try f.doVolumeDelete(vol) + } + } + + @Test func testVolumeSharedAccessConflict() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + let c1 = "\(f.testID)-c1" + let c2 = "\(f.testID)-c2" + try f.doPull(alpine) + let image = try f.copyWarmupImage(alpine) + f.addCleanup { + try? f.doStop(c1) + try? f.doRemoveIfExists(c1, force: true, ignoreFailure: true) + try? f.doRemoveIfExists(c2, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + + try f.doVolumeCreate(vol) + try f.doLongRun(name: c1, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) + try f.waitForContainerRunning(c1) + + let result = try f.run(["run", "--name", c2, "-v", "\(vol):/data", image, "sleep", "infinity"]) + #expect(result.status != 0, "second container should fail when volume is already in use") + + try f.doStop(c1) + try? f.doRemoveIfExists(c1, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + } + + @Test func testVolumeDeleteProtectionWhileInUse() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + let c = "\(f.testID)-c1" + try f.doPull(alpine) + let image = try f.copyWarmupImage(alpine) + f.addCleanup { + try? f.doStop(c) + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + + try f.doVolumeCreate(vol) + try f.doLongRun(name: c, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) + try f.waitForContainerRunning(c) + + #expect(try f.doesVolumeDeleteFail(vol), "volume delete should fail while in use") + + try f.doStop(c) + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + try f.doVolumeDelete(vol) + } + } + + @Test func testVolumeDeleteProtectionWithCreatedContainer() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + let c = "\(f.testID)-c1" + try f.doPull(alpine) + let image = try f.copyWarmupImage(alpine) + f.addCleanup { + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + + try f.doVolumeCreate(vol) + try f.doCreate(name: c, image: image, volumes: ["\(vol):/mnt/data"]) + try await Task.sleep(for: .seconds(1)) + + #expect(try f.doesVolumeDeleteFail(vol), "volume delete should fail when used by created container") + + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + } + + @Test func testVolumeBasicOperations() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + f.addCleanup { f.doVolumeDeleteIfExists(vol) } + + try f.doVolumeCreate(vol) + + let listResult = try f.run(["volume", "list", "--quiet"]).check() + let volumes = listResult.output.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + #expect(volumes.contains(vol), "created volume should appear in list") + + let inspectResult = try f.run(["volume", "inspect", vol]).check() + #expect(inspectResult.output.contains(vol)) + #expect(inspectResult.output.contains("\"creationDate\"")) + #expect(!inspectResult.output.contains("\"createdAt\"")) + + try f.doVolumeDelete(vol) + } + } + + @Test func testImplicitNamedVolumeCreation() async throws { + try await ContainerFixture.with { f in + let c = "\(f.testID)-c1" + let vol = "\(f.testID)-autovolume" + try f.doPull(alpine) + let image = try f.copyWarmupImage(alpine) + f.addCleanup { + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + + #expect(!(try f.volumeExists(vol)), "volume should not exist initially") + + let result = try f.run(["run", "--name", c, "-v", "\(vol):/data", image, "echo", "test"]) + #expect(result.status == 0, "should succeed and auto-create named volume") + #expect(result.output.contains("test")) + #expect(try f.volumeExists(vol), "volume should be created") + } + } + + @Test func testImplicitNamedVolumeReuse() async throws { + try await ContainerFixture.with { f in + let c1 = "\(f.testID)-c1" + let c2 = "\(f.testID)-c2" + let vol = "\(f.testID)-sharedvolume" + try f.doPull(alpine) + let image = try f.copyWarmupImage(alpine) + f.addCleanup { + try? f.doRemoveIfExists(c1, force: true, ignoreFailure: true) + try? f.doRemoveIfExists(c2, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + + let r1 = try f.run(["run", "--name", c1, "-v", "\(vol):/data", image, "sh", "-c", "echo 'first' > /data/test.txt"]) + #expect(r1.status == 0) + let r2 = try f.run(["run", "--name", c2, "-v", "\(vol):/data", image, "cat", "/data/test.txt"]) + #expect(r2.status == 0) + } + } + + @Test func testVolumeDeleteNoArgs() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["volume", "delete"]) + #expect(result.status != 0) + } + } + + @Test func testVolumeDeleteExplicitNamesConflictWithAll() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["volume", "delete", "--all", "some-volume"]) + #expect(result.status != 0) + #expect(result.error.contains("conflict")) + } + } + + @Test func testVolumeInspectMissingFails() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["volume", "inspect", "definitely-missing-volume"]) + #expect(result.status != 0) + #expect(result.error.contains("volume not found")) + } + } + + @Test func testVolumeCreateWithJournalOrdered() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + f.addCleanup { f.doVolumeDeleteIfExists(vol) } + try f.doVolumeCreate(vol, opts: ["journal=ordered"]) + #expect(try f.volumeExists(vol)) + } + } + + @Test func testVolumeCreateWithJournalAndSize() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + f.addCleanup { f.doVolumeDeleteIfExists(vol) } + try f.doVolumeCreate(vol, opts: ["journal=writeback:64m"]) + } + } + + @Test func testVolumeCreateWithInvalidJournalModeErrors() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + f.addCleanup { f.doVolumeDeleteIfExists(vol) } + let result = try f.run(["volume", "create", "--opt", "journal=none", vol]) + #expect(result.status != 0) + } + } + + @Test func testJournaledVolumeDataPersistence() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + let c1 = "\(f.testID)-c1" + let c2 = "\(f.testID)-c2" + try f.doPull(alpine) + let image = try f.copyWarmupImage(alpine) + f.addCleanup { + try? f.doStop(c1) + try? f.doRemoveIfExists(c1, force: true, ignoreFailure: true) + try? f.doStop(c2) + try? f.doRemoveIfExists(c2, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + + try f.doVolumeCreate(vol, opts: ["journal=ordered"]) + try f.doLongRun(name: c1, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) + try f.waitForContainerRunning(c1) + _ = try f.doExec(c1, cmd: ["sh", "-c", "echo 'journaled-data' > /data/test.txt"]) + try f.doStop(c1) + try f.doRemove(c1) + try f.doLongRun(name: c2, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) + try f.waitForContainerRunning(c2) + let output = try f.doExec(c2, cmd: ["cat", "/data/test.txt"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == "journaled-data") + try f.doStop(c2) + try f.doRemove(c2) + try f.doVolumeDelete(vol) + } + } +} diff --git a/Tests/IntegrationTests/Volumes/TestCLIVolumesSerial.swift b/Tests/IntegrationTests/Volumes/TestCLIVolumesSerial.swift new file mode 100644 index 000000000..57a94107a --- /dev/null +++ b/Tests/IntegrationTests/Volumes/TestCLIVolumesSerial.swift @@ -0,0 +1,110 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@Suite(.serialized) +struct TestCLIVolumesSerial { + private let alpine = ContainerFixture.warmupImages[0] + + @Test func testVolumePruneNoVolumes() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["volume", "prune"]).check() + #expect(result.error.contains("Zero KB"), "should show no space reclaimed") + } + } + + @Test func testVolumePruneUnusedVolumes() async throws { + try await ContainerFixture.with { f in + let v1 = "\(f.testID)-vol1" + let v2 = "\(f.testID)-vol2" + f.addCleanup { + f.doVolumeDeleteIfExists(v1) + f.doVolumeDeleteIfExists(v2) + } + + try f.doVolumeCreate(v1) + try f.doVolumeCreate(v2) + let list = try f.run(["volume", "list", "--quiet"]).check().output + #expect(list.contains(v1) && list.contains(v2)) + + let result = try f.run(["volume", "prune"]).check() + #expect(result.output.contains(v1) || !result.output.contains("No volumes to prune")) + #expect(result.output.contains(v2) || !result.output.contains("No volumes to prune")) + #expect(result.error.contains("Reclaimed")) + + let listAfter = try f.run(["volume", "list", "--quiet"]).check().output + #expect(!listAfter.contains(v1) && !listAfter.contains(v2)) + } + } + + @Test func testVolumePruneSkipsVolumeInUse() async throws { + try await ContainerFixture.with { f in + let vInUse = "\(f.testID)-inuse" + let vUnused = "\(f.testID)-unused" + let c = "\(f.testID)-c1" + try f.doPull(alpine) + let image = try f.copyWarmupImage(alpine) + f.addCleanup { + try? f.doStop(c) + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vInUse) + f.doVolumeDeleteIfExists(vUnused) + } + + try f.doVolumeCreate(vInUse) + try f.doVolumeCreate(vUnused) + try f.doLongRun(name: c, image: image, args: ["-v", "\(vInUse):/data"], autoRemove: false) + try f.waitForContainerRunning(c) + + try f.run(["volume", "prune"]).check() + + let listAfter = try f.run(["volume", "list", "--quiet"]).check().output + #expect(listAfter.contains(vInUse), "in-use volume should NOT be pruned") + #expect(!listAfter.contains(vUnused), "unused volume should be pruned") + + try f.doStop(c) + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vInUse) + } + } + + @Test func testVolumePruneSkipsVolumeAttachedToStoppedContainer() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + let c = "\(f.testID)-c1" + try f.doPull(alpine) + let image = try f.copyWarmupImage(alpine) + f.addCleanup { + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + + try f.doVolumeCreate(vol) + try f.doCreate(name: c, image: image, volumes: ["\(vol):/data"]) + try await Task.sleep(for: .seconds(1)) + + try f.run(["volume", "prune"]).check() + #expect(try f.volumeExists(vol), "volume attached to stopped container should NOT be pruned") + + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + try f.run(["volume", "prune"]).check() + #expect(!(try f.volumeExists(vol)), "volume should be pruned after container is deleted") + } + } + +}