diff --git a/.gitignore b/.gitignore index a0c4ff586..429f000b1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,6 @@ installer/ .venv/ .claude/ .clitests/ -test-*/ test_results/ *.pid *.log diff --git a/Makefile b/Makefile index 74e4e987a..dddbf39cc 100644 --- a/Makefile +++ b/Makefile @@ -199,35 +199,32 @@ endef # concurrent pass. WARMUP_FILTER, CONCURRENT_FILTER, and GLOBAL_FILTER select # the three phases. Expand the filter lists as suites are migrated from CLITests. PARALLEL_WIDTH ?= 2 -WARMUP_FILTER = ImageWarmup - -CONCURRENT_TEST_SUITES ?= \ - TestCLIExportCommand/ \ - TestCLIHelp \ - TestCLIMachineCommand/ \ - TestCLIRmRaceCondition/ \ - TestCLIStatus \ - TestCLIStop/ \ - TestCLIVersion/ +WARMUP_FILTER = ImageWarmup/ + +CONCURRENT_TEST_SUITES ?= $(sort $(addsuffix /,$(basename $(notdir \ + $(shell find Tests/IntegrationTests -name 'TestCLI*.swift' \ + ! -name '*Serial.swift' 2>/dev/null))))) CONCURRENT_FILTER = $(subst $(space),|,$(strip $(CONCURRENT_TEST_SUITES))) GLOBAL_TEST_SUITES ?= \ - TestCLIBuilderLifecycleSerial/ \ - TestCLIBuilderSerial/ \ TestCLIBuilderEnvOnlySerial/ \ + TestCLIBuilderLifecycleSerial/ \ TestCLIBuilderLocalOutputSerial/ \ + TestCLIBuilderSerial/ \ TestCLIBuilderTarExportSerial/ \ - TestCLIMachineRuntimeSerial/ + TestCLIKernelSetSerial/ \ + TestCLIMachineRuntimeSerial/ \ + TestCLIPruneCommandSerial/ \ + TestCLIRemoveSerial/ \ + TestCLIRunLifecycleSerial/ \ + 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... @@ -246,7 +243,6 @@ 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)" && \ @@ -273,29 +269,7 @@ coverage-integration-new: all @mkdir -p $(COVERAGE_OUTPUT_DIR)/integration $(RUN_INTEGRATION) -INTEGRATION_TEST_SUITES ?= \ - TestCLINetwork \ - TestCLIRunLifecycle \ - TestCLIRunCapabilities \ - TestCLIExecCommand \ - TestCLICreateCommand \ - TestCLIRunCommand1 \ - TestCLIRunCommand2 \ - TestCLIRunCommand3 \ - TestCLIPruneCommand \ - TestCLIRegistry \ - TestCLIStatsCommand \ - TestCLIImagesCommand \ - TestCLIRunBase \ - TestCLIRunInitImage \ - TestCLIBuildBase \ - TestCLIVolumes \ - TestCLIKernelSet \ - TestCLIAnonymousVolumes \ - TestCLINotFound \ - TestCLISystemDF \ - TestCLINoParallelCases \ - TestCLICopyCommand +INTEGRATION_TEST_SUITES ?= NoTests/ empty := space := $(empty) $(empty) diff --git a/Tests/CLITests/Subcommands/Containers/TestCLICopy.swift b/Tests/CLITests/Subcommands/Containers/TestCLICopy.swift deleted file mode 100644 index f7db9a822..000000000 --- a/Tests/CLITests/Subcommands/Containers/TestCLICopy.swift +++ /dev/null @@ -1,958 +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 ContainerizationExtras -import Foundation -import Testing - -class TestCLICopyCommand: CLITest { - private func getTestName() -> String { - Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() - } - - @Test func testCopyHostToContainer() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { - try? doStop(name: name) - } - try doStart(name: name) - try waitForContainerRunning(name) - - let tempFile = testDir.appendingPathComponent("testfile.txt") - let content = "hello from host" - try content.write(to: tempFile, atomically: true, encoding: .utf8) - - let (_, _, error, status) = try run(arguments: [ - "copy", - tempFile.path, - "\(name):/tmp/", - ]) - if status != 0 { - throw CLIError.executionFailed("copy failed: \(error)") - } - - let catOutput = try doExec(name: name, cmd: ["cat", "/tmp/testfile.txt"]) - #expect( - catOutput.trimmingCharacters(in: .whitespacesAndNewlines) == content, - "expected file content to be '\(content)', got '\(catOutput.trimmingCharacters(in: .whitespacesAndNewlines))'" - ) - - try doStop(name: name) - } catch { - Issue.record("failed to copy file from host to container: \(error)") - return - } - } - - @Test func testCopyContainerToHost() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { - try? doStop(name: name) - } - try doStart(name: name) - try waitForContainerRunning(name) - - let content = "hello from container" - _ = try doExec(name: name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/containerfile.txt"]) - - let destPath = testDir.appendingPathComponent("containerfile.txt") - let (_, _, error, status) = try run(arguments: [ - "copy", - "\(name):/tmp/containerfile.txt", - destPath.path, - ]) - if status != 0 { - throw CLIError.executionFailed("copy failed: \(error)") - } - - let hostContent = try String(contentsOfFile: destPath.path, encoding: .utf8) - #expect( - hostContent == content, - "expected file content to be '\(content)', got '\(hostContent)'" - ) - - try doStop(name: name) - } catch { - Issue.record("failed to copy file from container to host: \(error)") - return - } - } - - @Test func testCopyUsingCpAlias() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { - try? doStop(name: name) - } - try doStart(name: name) - try waitForContainerRunning(name) - - let tempFile = testDir.appendingPathComponent("aliasfile.txt") - let content = "testing cp alias" - try content.write(to: tempFile, atomically: true, encoding: .utf8) - - let (_, _, error, status) = try run(arguments: [ - "cp", - tempFile.path, - "\(name):/tmp/", - ]) - if status != 0 { - throw CLIError.executionFailed("cp alias failed: \(error)") - } - - let catOutput = try doExec(name: name, cmd: ["cat", "/tmp/aliasfile.txt"]) - #expect( - catOutput.trimmingCharacters(in: .whitespacesAndNewlines) == content, - "expected file content to be '\(content)', got '\(catOutput.trimmingCharacters(in: .whitespacesAndNewlines))'" - ) - - try doStop(name: name) - } catch { - Issue.record("failed to copy file using cp alias: \(error)") - return - } - } - - @Test func testCopyLocalToLocalFails() throws { - let (_, _, _, status) = try run(arguments: [ - "copy", - "/tmp/source.txt", - "/tmp/dest.txt", - ]) - #expect(status != 0, "expected local-to-local copy to fail") - } - - @Test func testCopyContainerToContainerFails() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { - try? doStop(name: name) - } - - let (_, _, _, status) = try run(arguments: [ - "copy", - "\(name):/tmp/file.txt", - "\(name):/tmp/file2.txt", - ]) - #expect(status != 0, "expected container-to-container copy to fail") - } catch { - Issue.record("failed test for container-to-container copy: \(error)") - return - } - } - - @Test func testCopyToNonRunningContainerFails() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { - try? doStop(name: name) - } - - let tempFile = testDir.appendingPathComponent("norun.txt") - try "test".write(to: tempFile, atomically: true, encoding: .utf8) - - let (_, _, _, status) = try run(arguments: [ - "copy", - tempFile.path, - "\(name):/tmp/", - ]) - #expect(status != 0, "expected copy to non-running container to fail") - } catch { - Issue.record("failed test for copy to non-running container: \(error)") - return - } - } - - @Test func testCopyDirectoryHostToContainer() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { - try? doStop(name: name) - } - try doStart(name: name) - try waitForContainerRunning(name) - - let srcDir = testDir.appendingPathComponent("hostdir") - try FileManager.default.createDirectory(at: srcDir, withIntermediateDirectories: true) - try "file1 content".write(to: srcDir.appendingPathComponent("file1.txt"), atomically: true, encoding: .utf8) - try "file2 content".write(to: srcDir.appendingPathComponent("file2.txt"), atomically: true, encoding: .utf8) - - let (_, _, error, status) = try run(arguments: [ - "copy", - srcDir.path, - "\(name):/tmp/", - ]) - if status != 0 { - throw CLIError.executionFailed("copy directory failed: \(error)") - } - - let cat1 = try doExec(name: name, cmd: ["cat", "/tmp/hostdir/file1.txt"]) - #expect( - cat1.trimmingCharacters(in: .whitespacesAndNewlines) == "file1 content", - "expected file1 content, got '\(cat1.trimmingCharacters(in: .whitespacesAndNewlines))'" - ) - let cat2 = try doExec(name: name, cmd: ["cat", "/tmp/hostdir/file2.txt"]) - #expect( - cat2.trimmingCharacters(in: .whitespacesAndNewlines) == "file2 content", - "expected file2 content, got '\(cat2.trimmingCharacters(in: .whitespacesAndNewlines))'" - ) - - try doStop(name: name) - } catch { - Issue.record("failed to copy directory from host to container: \(error)") - return - } - } - - @Test func testCopyDirectoryContainerToHost() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { - try? doStop(name: name) - } - try doStart(name: name) - try waitForContainerRunning(name) - - _ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /tmp/guestdir && echo -n 'aaa' > /tmp/guestdir/a.txt && echo -n 'bbb' > /tmp/guestdir/b.txt"]) - - let destPath = testDir.appendingPathComponent("guestdir") - let (_, _, error, status) = try run(arguments: [ - "copy", - "\(name):/tmp/guestdir", - destPath.path, - ]) - if status != 0 { - throw CLIError.executionFailed("copy directory failed: \(error)") - } - - let contentA = try String(contentsOfFile: destPath.appendingPathComponent("a.txt").path, encoding: .utf8) - #expect(contentA == "aaa", "expected 'aaa', got '\(contentA)'") - let contentB = try String(contentsOfFile: destPath.appendingPathComponent("b.txt").path, encoding: .utf8) - #expect(contentB == "bbb", "expected 'bbb', got '\(contentB)'") - - try doStop(name: name) - } catch { - Issue.record("failed to copy directory from container to host: \(error)") - return - } - } - - @Test func testCopyNestedDirectoryHostToContainer() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { - try? doStop(name: name) - } - try doStart(name: name) - try waitForContainerRunning(name) - - let srcDir = testDir.appendingPathComponent("nested") - let subDir = srcDir.appendingPathComponent("sub") - try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true) - try "root file".write(to: srcDir.appendingPathComponent("root.txt"), atomically: true, encoding: .utf8) - try "nested file".write(to: subDir.appendingPathComponent("deep.txt"), atomically: true, encoding: .utf8) - - let (_, _, error, status) = try run(arguments: [ - "copy", - srcDir.path, - "\(name):/tmp/", - ]) - if status != 0 { - throw CLIError.executionFailed("copy nested directory failed: \(error)") - } - - let catRoot = try doExec(name: name, cmd: ["cat", "/tmp/nested/root.txt"]) - #expect( - catRoot.trimmingCharacters(in: .whitespacesAndNewlines) == "root file", - "expected 'root file', got '\(catRoot.trimmingCharacters(in: .whitespacesAndNewlines))'" - ) - let catDeep = try doExec(name: name, cmd: ["cat", "/tmp/nested/sub/deep.txt"]) - #expect( - catDeep.trimmingCharacters(in: .whitespacesAndNewlines) == "nested file", - "expected 'nested file', got '\(catDeep.trimmingCharacters(in: .whitespacesAndNewlines))'" - ) - - try doStop(name: name) - } catch { - Issue.record("failed to copy nested directory from host to container: \(error)") - return - } - } - - @Test func testCopyNestedDirectoryContainerToHost() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { - try? doStop(name: name) - } - try doStart(name: name) - try waitForContainerRunning(name) - - _ = try doExec( - name: name, cmd: ["sh", "-c", "mkdir -p /tmp/nested/sub && echo -n 'root file' > /tmp/nested/root.txt && echo -n 'nested file' > /tmp/nested/sub/deep.txt"]) - - let destPath = testDir.appendingPathComponent("nested") - let (_, _, error, status) = try run(arguments: [ - "copy", - "\(name):/tmp/nested", - destPath.path, - ]) - if status != 0 { - throw CLIError.executionFailed("copy nested directory failed: \(error)") - } - - let contentRoot = try String(contentsOfFile: destPath.appendingPathComponent("root.txt").path, encoding: .utf8) - #expect(contentRoot == "root file", "expected 'root file', got '\(contentRoot)'") - let contentDeep = try String(contentsOfFile: destPath.appendingPathComponent("sub").appendingPathComponent("deep.txt").path, encoding: .utf8) - #expect(contentDeep == "nested file", "expected 'nested file', got '\(contentDeep)'") - - try doStop(name: name) - } catch { - Issue.record("failed to copy nested directory from container to host: \(error)") - return - } - } - - // MARK: - CopyOut S1: no trailing slash - - @Test func testCopyOutFileToExistingFile() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - let content = "container content" - _ = try doExec(name: name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/source.txt"]) - - let destPath = testDir.appendingPathComponent("existing.txt") - try "old content".write(to: destPath, atomically: true, encoding: .utf8) - - let (_, _, error, status) = try run(arguments: ["copy", "\(name):/tmp/source.txt", destPath.path]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try String(contentsOfFile: destPath.path, encoding: .utf8) - #expect(result == content) - try doStop(name: name) - } catch { - Issue.record("testCopyOutFileToExistingFile failed: \(error)") - } - } - - @Test func testCopyOutDirectoryToExistingFileFails() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - _ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'x' > /tmp/srcdir/file.txt"]) - - let destPath = testDir.appendingPathComponent("existing.txt") - try "x".write(to: destPath, atomically: true, encoding: .utf8) - - let (_, _, _, status) = try run(arguments: ["copy", "\(name):/tmp/srcdir", destPath.path]) - #expect(status != 0, "expected directory-to-existing-file to fail") - try doStop(name: name) - } catch { - Issue.record("testCopyOutDirectoryToExistingFileFails failed: \(error)") - } - } - - @Test func testCopyOutFileToExistingDirectory() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - let content = "container content" - _ = try doExec(name: name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/source.txt"]) - - let destDir = testDir.appendingPathComponent("dstdir") - try FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) - - let (_, _, error, status) = try run(arguments: ["copy", "\(name):/tmp/source.txt", destDir.path]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try String(contentsOfFile: destDir.appendingPathComponent("source.txt").path, encoding: .utf8) - #expect(result == content) - try doStop(name: name) - } catch { - Issue.record("testCopyOutFileToExistingDirectory failed: \(error)") - } - } - - @Test func testCopyOutDirectoryToExistingDirectory() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - _ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) - - let destDir = testDir.appendingPathComponent("dstdir") - try FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) - - let (_, _, error, status) = try run(arguments: ["copy", "\(name):/tmp/srcdir", destDir.path]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try String(contentsOfFile: destDir.appendingPathComponent("srcdir").appendingPathComponent("file.txt").path, encoding: .utf8) - #expect(result == "hello") - try doStop(name: name) - } catch { - Issue.record("testCopyOutDirectoryToExistingDirectory failed: \(error)") - } - } - - // MARK: - CopyOut S2: trailing slash on dst - - @Test func testCopyOutFileToNonExistingTrailingSlashFails() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - _ = try doExec(name: name, cmd: ["sh", "-c", "echo -n 'x' > /tmp/source.txt"]) - - let destPath = testDir.appendingPathComponent("nonexistent").path + "/" - let (_, _, _, status) = try run(arguments: ["copy", "\(name):/tmp/source.txt", destPath]) - #expect(status != 0, "expected file-to-nonexisting/ to fail") - try doStop(name: name) - } catch { - Issue.record("testCopyOutFileToNonExistingTrailingSlashFails failed: \(error)") - } - } - - @Test func testCopyOutDirectoryToNonExistingTrailingSlash() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - _ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) - - let destDir = testDir.appendingPathComponent("newdir") - let (_, _, error, status) = try run(arguments: ["copy", "\(name):/tmp/srcdir", destDir.path + "/"]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - var isDir: ObjCBool = false - #expect(FileManager.default.fileExists(atPath: destDir.path, isDirectory: &isDir) && isDir.boolValue) - let result = try String(contentsOfFile: destDir.appendingPathComponent("file.txt").path, encoding: .utf8) - #expect(result == "hello") - try doStop(name: name) - } catch { - Issue.record("testCopyOutDirectoryToNonExistingTrailingSlash failed: \(error)") - } - } - - @Test func testCopyOutFileToExistingDirectoryTrailingSlash() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - let content = "container content" - _ = try doExec(name: name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/source.txt"]) - - let destDir = testDir.appendingPathComponent("dstdir") - try FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) - - let (_, _, error, status) = try run(arguments: ["copy", "\(name):/tmp/source.txt", destDir.path + "/"]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try String(contentsOfFile: destDir.appendingPathComponent("source.txt").path, encoding: .utf8) - #expect(result == content) - try doStop(name: name) - } catch { - Issue.record("testCopyOutFileToExistingDirectoryTrailingSlash failed: \(error)") - } - } - - @Test func testCopyOutDirectoryToExistingDirectoryTrailingSlash() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - _ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) - - let destDir = testDir.appendingPathComponent("dstdir") - try FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) - - let (_, _, error, status) = try run(arguments: ["copy", "\(name):/tmp/srcdir", destDir.path + "/"]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try String(contentsOfFile: destDir.appendingPathComponent("srcdir").appendingPathComponent("file.txt").path, encoding: .utf8) - #expect(result == "hello") - try doStop(name: name) - } catch { - Issue.record("testCopyOutDirectoryToExistingDirectoryTrailingSlash failed: \(error)") - } - } - - // MARK: - CopyOut S3: trailing slash on src - - @Test func testCopyOutDirectoryContentsToNonExisting() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - _ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir/sub && echo -n 'hello' > /tmp/srcdir/file.txt"]) - - let destDir = testDir.appendingPathComponent("newdir") - let (_, _, error, status) = try run(arguments: ["copy", "\(name):/tmp/srcdir/", destDir.path]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try String(contentsOfFile: destDir.appendingPathComponent("file.txt").path, encoding: .utf8) - #expect(result == "hello") - var isDir: ObjCBool = false - #expect(FileManager.default.fileExists(atPath: destDir.appendingPathComponent("sub").path, isDirectory: &isDir) && isDir.boolValue) - try doStop(name: name) - } catch { - Issue.record("testCopyOutDirectoryContentsToNonExisting failed: \(error)") - } - } - - @Test func testCopyOutDirectoryContentsToExistingFileFails() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - _ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'x' > /tmp/srcdir/file.txt"]) - - let destPath = testDir.appendingPathComponent("existing.txt") - try "x".write(to: destPath, atomically: true, encoding: .utf8) - - let (_, _, _, status) = try run(arguments: ["copy", "\(name):/tmp/srcdir/", destPath.path]) - #expect(status != 0, "expected directory/-to-existing-file to fail") - try doStop(name: name) - } catch { - Issue.record("testCopyOutDirectoryContentsToExistingFileFails failed: \(error)") - } - } - - @Test func testCopyOutDirectoryContentsToExistingDirectory() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - _ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) - - let destDir = testDir.appendingPathComponent("dstdir") - try FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) - - let (_, _, error, status) = try run(arguments: ["copy", "\(name):/tmp/srcdir/", destDir.path]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try String(contentsOfFile: destDir.appendingPathComponent("srcdir").appendingPathComponent("file.txt").path, encoding: .utf8) - #expect(result == "hello") - try doStop(name: name) - } catch { - Issue.record("testCopyOutDirectoryContentsToExistingDirectory failed: \(error)") - } - } - - // MARK: - CopyOut S4: trailing slash on both src and dst - - @Test func testCopyOutDirectoryContentsToNonExistingTrailingSlash() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - _ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) - - let destDir = testDir.appendingPathComponent("newdir") - let (_, _, error, status) = try run(arguments: ["copy", "\(name):/tmp/srcdir/", destDir.path + "/"]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try String(contentsOfFile: destDir.appendingPathComponent("file.txt").path, encoding: .utf8) - #expect(result == "hello") - try doStop(name: name) - } catch { - Issue.record("testCopyOutDirectoryContentsToNonExistingTrailingSlash failed: \(error)") - } - } - - @Test func testCopyOutDirectoryContentsToExistingDirectoryTrailingSlash() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - _ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) - - let destDir = testDir.appendingPathComponent("dstdir") - try FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) - - let (_, _, error, status) = try run(arguments: ["copy", "\(name):/tmp/srcdir/", destDir.path + "/"]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try String(contentsOfFile: destDir.appendingPathComponent("srcdir").appendingPathComponent("file.txt").path, encoding: .utf8) - #expect(result == "hello") - try doStop(name: name) - } catch { - Issue.record("testCopyOutDirectoryContentsToExistingDirectoryTrailingSlash failed: \(error)") - } - } - - // MARK: - CopyIn S1: no trailing slash - - @Test func testCopyInFileToExistingFile() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - let content = "new content" - let srcFile = testDir.appendingPathComponent("source.txt") - try content.write(to: srcFile, atomically: true, encoding: .utf8) - _ = try doExec(name: name, cmd: ["sh", "-c", "echo -n 'old content' > /tmp/existing.txt"]) - - let (_, _, error, status) = try run(arguments: ["copy", srcFile.path, "\(name):/tmp/existing.txt"]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try doExec(name: name, cmd: ["cat", "/tmp/existing.txt"]) - #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == content) - try doStop(name: name) - } catch { - Issue.record("testCopyInFileToExistingFile failed: \(error)") - } - } - - @Test func testCopyInDirectoryToExistingFileFails() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - let srcDir = testDir.appendingPathComponent("srcdir") - try FileManager.default.createDirectory(at: srcDir, withIntermediateDirectories: true) - try "x".write(to: srcDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8) - _ = try doExec(name: name, cmd: ["sh", "-c", "echo -n 'x' > /tmp/existing.txt"]) - - let (_, _, _, status) = try run(arguments: ["copy", srcDir.path, "\(name):/tmp/existing.txt"]) - #expect(status != 0, "expected directory-to-existing-file to fail") - try doStop(name: name) - } catch { - Issue.record("testCopyInDirectoryToExistingFileFails failed: \(error)") - } - } - - @Test func testCopyInFileToExistingDirectory() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - let content = "host content" - let srcFile = testDir.appendingPathComponent("source.txt") - try content.write(to: srcFile, atomically: true, encoding: .utf8) - _ = try doExec(name: name, cmd: ["mkdir", "-p", "/tmp/dstdir"]) - - let (_, _, error, status) = try run(arguments: ["copy", srcFile.path, "\(name):/tmp/dstdir"]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try doExec(name: name, cmd: ["cat", "/tmp/dstdir/source.txt"]) - #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == content) - try doStop(name: name) - } catch { - Issue.record("testCopyInFileToExistingDirectory failed: \(error)") - } - } - - @Test func testCopyInDirectoryToExistingDirectory() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - let srcDir = testDir.appendingPathComponent("srcdir") - try FileManager.default.createDirectory(at: srcDir, withIntermediateDirectories: true) - try "hello".write(to: srcDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8) - _ = try doExec(name: name, cmd: ["mkdir", "-p", "/tmp/dstdir"]) - - let (_, _, error, status) = try run(arguments: ["copy", srcDir.path, "\(name):/tmp/dstdir"]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try doExec(name: name, cmd: ["cat", "/tmp/dstdir/srcdir/file.txt"]) - #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") - try doStop(name: name) - } catch { - Issue.record("testCopyInDirectoryToExistingDirectory failed: \(error)") - } - } - - // MARK: - CopyIn S2: trailing slash on dst - - @Test func testCopyInFileToNonExistingTrailingSlashFails() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - let srcFile = testDir.appendingPathComponent("source.txt") - try "x".write(to: srcFile, atomically: true, encoding: .utf8) - - let (_, _, _, status) = try run(arguments: ["copy", srcFile.path, "\(name):/tmp/nonexistent/"]) - #expect(status != 0, "expected file-to-nonexisting/ to fail") - try doStop(name: name) - } catch { - Issue.record("testCopyInFileToNonExistingTrailingSlashFails failed: \(error)") - } - } - - @Test func testCopyInDirectoryToNonExistingTrailingSlash() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - let srcDir = testDir.appendingPathComponent("srcdir") - try FileManager.default.createDirectory(at: srcDir, withIntermediateDirectories: true) - try "hello".write(to: srcDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8) - - let (_, _, error, status) = try run(arguments: ["copy", srcDir.path, "\(name):/tmp/newdir/"]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try doExec(name: name, cmd: ["cat", "/tmp/newdir/file.txt"]) - #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") - try doStop(name: name) - } catch { - Issue.record("testCopyInDirectoryToNonExistingTrailingSlash failed: \(error)") - } - } - - // MARK: - CopyIn S3: trailing slash on src - - @Test func testCopyInDirectoryContentsToNonExisting() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - let srcDir = testDir.appendingPathComponent("srcdir") - let subDir = srcDir.appendingPathComponent("sub") - try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true) - try "hello".write(to: srcDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8) - - let (_, _, error, status) = try run(arguments: ["copy", srcDir.path + "/", "\(name):/tmp/newdir"]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try doExec(name: name, cmd: ["cat", "/tmp/newdir/file.txt"]) - #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") - try doStop(name: name) - } catch { - Issue.record("testCopyInDirectoryContentsToNonExisting failed: \(error)") - } - } - - @Test func testCopyInDirectoryContentsToExistingFileFails() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - let srcDir = testDir.appendingPathComponent("srcdir") - try FileManager.default.createDirectory(at: srcDir, withIntermediateDirectories: true) - try "x".write(to: srcDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8) - _ = try doExec(name: name, cmd: ["sh", "-c", "echo -n 'x' > /tmp/existing.txt"]) - - let (_, _, _, status) = try run(arguments: ["copy", srcDir.path + "/", "\(name):/tmp/existing.txt"]) - #expect(status != 0, "expected directory/-to-existing-file to fail") - try doStop(name: name) - } catch { - Issue.record("testCopyInDirectoryContentsToExistingFileFails failed: \(error)") - } - } - - @Test func testCopyInDirectoryContentsToExistingDirectory() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - let srcDir = testDir.appendingPathComponent("srcdir") - try FileManager.default.createDirectory(at: srcDir, withIntermediateDirectories: true) - try "hello".write(to: srcDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8) - _ = try doExec(name: name, cmd: ["mkdir", "-p", "/tmp/dstdir"]) - - let (_, _, error, status) = try run(arguments: ["copy", srcDir.path + "/", "\(name):/tmp/dstdir"]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try doExec(name: name, cmd: ["cat", "/tmp/dstdir/srcdir/file.txt"]) - #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") - try doStop(name: name) - } catch { - Issue.record("testCopyInDirectoryContentsToExistingDirectory failed: \(error)") - } - } - - // MARK: - CopyIn S4: trailing slash on both src and dst - - @Test func testCopyInDirectoryContentsToNonExistingTrailingSlash() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - let srcDir = testDir.appendingPathComponent("srcdir") - try FileManager.default.createDirectory(at: srcDir, withIntermediateDirectories: true) - try "hello".write(to: srcDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8) - - let (_, _, error, status) = try run(arguments: ["copy", srcDir.path + "/", "\(name):/tmp/newdir/"]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try doExec(name: name, cmd: ["cat", "/tmp/newdir/file.txt"]) - #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") - try doStop(name: name) - } catch { - Issue.record("testCopyInDirectoryContentsToNonExistingTrailingSlash failed: \(error)") - } - } - - @Test func testCopyInDirectoryContentsToExistingDirectoryTrailingSlash() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - let srcDir = testDir.appendingPathComponent("srcdir") - try FileManager.default.createDirectory(at: srcDir, withIntermediateDirectories: true) - try "hello".write(to: srcDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8) - _ = try doExec(name: name, cmd: ["mkdir", "-p", "/tmp/dstdir"]) - - let (_, _, error, status) = try run(arguments: ["copy", srcDir.path + "/", "\(name):/tmp/dstdir/"]) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try doExec(name: name, cmd: ["cat", "/tmp/dstdir/srcdir/file.txt"]) - #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") - try doStop(name: name) - } catch { - Issue.record("testCopyInDirectoryContentsToExistingDirectoryTrailingSlash failed: \(error)") - } - } - - // MARK: - Relative path resolution - - @Test func testCopyInRelativeSourcePath() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - let content = "relative source" - try content.write(to: testDir.appendingPathComponent("relfile.txt"), atomically: true, encoding: .utf8) - - let (_, _, error, status) = try run( - arguments: ["copy", "./relfile.txt", "\(name):/tmp/"], - currentDirectory: testDir) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try doExec(name: name, cmd: ["cat", "/tmp/relfile.txt"]) - #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == content) - try doStop(name: name) - } catch { - Issue.record("testCopyInRelativeSourcePath failed: \(error)") - } - } - - @Test func testCopyOutRelativeDestinationPath() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { try? doStop(name: name) } - try doStart(name: name) - try waitForContainerRunning(name) - - let content = "relative dest" - _ = try doExec(name: name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/relfile.txt"]) - - let (_, _, error, status) = try run( - arguments: ["copy", "\(name):/tmp/relfile.txt", "./"], - currentDirectory: testDir) - if status != 0 { throw CLIError.executionFailed("copy failed: \(error)") } - - let result = try String(contentsOfFile: testDir.appendingPathComponent("relfile.txt").path, encoding: .utf8) - #expect(result == content) - try doStop(name: name) - } catch { - Issue.record("testCopyOutRelativeDestinationPath failed: \(error)") - } - } -} diff --git a/Tests/CLITests/Subcommands/Containers/TestCLICreate.swift b/Tests/CLITests/Subcommands/Containers/TestCLICreate.swift deleted file mode 100644 index edf0c545f..000000000 --- a/Tests/CLITests/Subcommands/Containers/TestCLICreate.swift +++ /dev/null @@ -1,126 +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 ContainerizationExtras -import Foundation -import Testing - -@Suite(.serialSuites) -class TestCLICreateCommand: CLITest { - private func getTestName() -> String { - Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() - } - - @Test func testCreateArgsPassthrough() throws { - let name = getTestName() - #expect(throws: Never.self, "expected container create to succeed") { - try doCreate(name: name, args: ["echo", "-n", "hello", "world"]) - try doRemove(name: name) - } - } - - @Test func testCreateWithMACAddress() throws { - let name = getTestName() - let expectedMAC = try MACAddress("02:42:ac:11:00:03") - #expect(throws: Never.self, "expected container create with MAC address to succeed") { - try doCreate(name: name, networks: ["default,mac=\(expectedMAC)"]) - try doStart(name: name) - defer { - try? doStop(name: name) - try? doRemove(name: name) - } - try waitForContainerRunning(name) - let inspectResp = try inspectContainer(name) - #expect(inspectResp.networks.count > 0, "expected at least one network attachment") - let actualMAC = inspectResp.networks[0].macAddress?.description ?? "nil" - #expect( - actualMAC == expectedMAC.description, "expected MAC address \(expectedMAC), got \(actualMAC)" - ) - } - } - - @Test func testPublishPortParserMaxPorts() throws { - let name = getTestName() - var args: [String] = ["create", "--name", name] - - let portCount = 64 - for i in 0.. String { - Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() - } - - @Test func testCreateExecCommand() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { - try? doStop(name: name) - } - try doStart(name: name) - var unameActual = try doExec(name: name, cmd: ["uname"]) - unameActual = unameActual.trimmingCharacters(in: .whitespacesAndNewlines) - #expect(unameActual == "Linux", "expected OS to be Linux, instead got \(unameActual)") - try doStop(name: name) - } catch { - Issue.record("failed to exec in container \(error)") - return - } - } - - @Test func testExecDetach() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { - try? doStop(name: name) - } - try doStart(name: name) - - // Run a long-running process in detached mode - let output = try doExec(name: name, cmd: ["sh", "-c", "touch /tmp/detach_test_marker"], detach: true) - let containerIdOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) - try #require(containerIdOutput == name, "exec --detach should print the container ID") - - // Verify the detached process is running by checking if we can still exec commands - var lsActual = try doExec(name: name, cmd: ["ls", "/"]) - lsActual = lsActual.trimmingCharacters(in: .whitespacesAndNewlines) - try #require(lsActual.contains("tmp"), "container should still be running and accepting exec commands") - - // Retry loop to check if the marker file was created by the detached process - var markerFound = false - for _ in 0..<3 { - let (_, _, _, status) = try run(arguments: [ - "exec", - name, - "test", "-f", "/tmp/detach_test_marker", - ]) - if status == 0 { - markerFound = true - break - } - sleep(1) - } - try #require(markerFound, "marker file should be created by detached process within 3 seconds") - - try doStop(name: name) - } catch { - Issue.record("failed to exec with detach in container \(error)") - return - } - } - - @Test func testExecDetachProcessRunning() throws { - do { - let name = getTestName() - try doCreate(name: name) - defer { - try? doStop(name: name) - } - try doStart(name: name) - - // Run a long-running process in detached mode - let output = try doExec(name: name, cmd: ["sleep", "10"], detach: true) - let containerIdOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) - try #require(containerIdOutput == name, "exec --detach should print the container ID") - - // Immediately check if the process is running using ps - var psOutput = try doExec(name: name, cmd: ["ps", "aux"]) - psOutput = psOutput.trimmingCharacters(in: .whitespacesAndNewlines) - try #require(psOutput.contains("sleep 10"), "detached process 'sleep 10' should be visible in ps output") - - try doStop(name: name) - } catch { - Issue.record("failed to verify detached process is running \(error)") - return - } - } - - @Test func testExecOnExitingContainer() throws { - do { - let name = getTestName() - try doLongRun(name: name, containerArgs: ["sh"], autoRemove: false) - defer { - try? doRemove(name: name) - } - // Give time for container process to exit due to no stdin - sleep(1) - - try doStart(name: name) - do { - _ = try doExec(name: name, cmd: ["sleep", "infinity"]) - } catch CLIError.executionFailed(let message) { - // There's no nice way to check fail reason here - #expect( - message.contains("is not running") || message.contains("failed to create process"), - "expected container is not running if exec failed" - ) - } - - // Give time for the exec (or start) error handling settles down - sleep(1) - #expect(throws: Never.self, "expected the container remains") { - try getContainerStatus(name) - } - } - } -} diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIPrune.swift b/Tests/CLITests/Subcommands/Containers/TestCLIPrune.swift deleted file mode 100644 index 49300a7d9..000000000 --- a/Tests/CLITests/Subcommands/Containers/TestCLIPrune.swift +++ /dev/null @@ -1,88 +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) -class TestCLIPruneCommand: CLITest { - private func getTestName() -> String { - Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() - } - - @Test(.disabled("flaky — prune picks up containers from concurrent suites; tests being rewritten")) - func testContainerPruneNoContainers() throws { - let (_, _, error, status) = try run(arguments: ["prune"]) - if status != 0 { - throw CLIError.executionFailed("container prune failed: \(error)") - } - - #expect(error.contains("Reclaimed Zero KB in disk space"), "should show no containers message") - } - - @Test func testContainerPruneStoppedContainers() throws { - let testName = getTestName() - let npcName = "\(testName)_wont_be_pruned" - let pc0Name = "\(testName)_pruned_0" - let pc1Name = "\(testName)_pruned_1" - - try doLongRun(name: npcName, containerArgs: ["sleep", "3600"], autoRemove: true) - try doLongRun(name: pc0Name, containerArgs: ["sleep", "3600"], autoRemove: false) - try doLongRun(name: pc1Name, containerArgs: ["sleep", "3600"], autoRemove: false) - defer { - try? doStop(name: npcName) - try? doStop(name: pc0Name) - try? doStop(name: pc1Name) - try? doRemove(name: npcName) - try? doRemove(name: pc0Name) - try? doRemove(name: pc1Name) - } - try waitForContainerRunning(npcName) - try waitForContainerRunning(pc0Name) - try waitForContainerRunning(pc1Name) - - try doStop(name: pc0Name) - try doStop(name: pc1Name) - - let pc0Id = try getContainerId(pc0Name) - let pc1Id = try getContainerId(pc1Name) - - // Poll status until both containers are stopped, with interval checks and a timeout to avoid infinite loop - let start = Date() - let timeout: TimeInterval = 30 // seconds - while true { - let s0 = try getContainerStatus(pc0Name) - let s1 = try getContainerStatus(pc1Name) - if s0 == "stopped" && s1 == "stopped" { break } - if Date().timeIntervalSince(start) > timeout { - throw CLIError.executionFailed("Timeout waiting for containers to stop: pc0=\(s0), pc1=\(s1)") - } - Thread.sleep(forTimeInterval: 0.2) - } - - let (_, output, error, status) = try run(arguments: ["prune"]) - - if status != 0 { - throw CLIError.executionFailed("container prune failed: \(error)") - } - - #expect(output.contains(pc0Id) && output.contains(pc1Id), "should show the stopped containers id") - #expect(!error.contains("Reclaimed Zero KB in disk space"), "reclaimed spaces should not Zero KB") - - let checkStatus = try getContainerStatus(npcName) - #expect(checkStatus == "running", "not pruned container should still be running") - } -} diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIRemove.swift b/Tests/CLITests/Subcommands/Containers/TestCLIRemove.swift deleted file mode 100644 index 640c2c9d9..000000000 --- a/Tests/CLITests/Subcommands/Containers/TestCLIRemove.swift +++ /dev/null @@ -1,141 +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 - -class TestCLIRemove: CLITest { - - @Test func testDeleteStopped() async throws { - let name = testName - defer { try? doRemove(name: name, force: true) } - - // Create without --rm so the container persists after being stopped - let (_, _, createError, createStatus) = try run(arguments: ["create", "--name", name, alpine, "sleep", "infinity"]) - #expect(createStatus == 0, "create failed: \(createError)") - - let (_, _, deleteError, deleteStatus) = try run(arguments: ["delete", name]) - #expect(deleteStatus == 0, "delete failed: \(deleteError)") - #expect(throws: CLIError.self) { try self.inspectContainer(name) } - } - - @Test func testDeleteAlias() async throws { - let name = testName - defer { try? doRemove(name: name, force: true) } - - let (_, _, createError, createStatus) = try run(arguments: ["create", "--name", name, alpine, "sleep", "infinity"]) - #expect(createStatus == 0, "create failed: \(createError)") - - let (_, _, rmError, rmStatus) = try run(arguments: ["rm", name]) - #expect(rmStatus == 0, "rm failed: \(rmError)") - #expect(throws: CLIError.self) { try self.inspectContainer(name) } - } - - @Test func testDeleteForceRunning() async throws { - let name = testName - defer { try? doRemove(name: name, force: true) } - - try doLongRun(name: name, autoRemove: false) - try waitForContainerRunning(name) - - try doRemove(name: name, force: true) - #expect(throws: CLIError.self) { try self.inspectContainer(name) } - } - - @Test func testDeleteAllStopped() async throws { - let name1 = testName + "-1" - let name2 = testName + "-2" - defer { - try? doRemove(name: name1, force: true) - try? doRemove(name: name2, force: true) - } - - let (_, _, e1, s1) = try run(arguments: ["create", "--name", name1, alpine, "sleep", "infinity"]) - #expect(s1 == 0, "create \(name1) failed: \(e1)") - let (_, _, e2, s2) = try run(arguments: ["create", "--name", name2, alpine, "sleep", "infinity"]) - #expect(s2 == 0, "create \(name2) failed: \(e2)") - - let (_, _, deleteError, deleteStatus) = try run(arguments: ["delete", "--all"]) - #expect(deleteStatus == 0, "delete --all failed: \(deleteError)") - #expect(throws: CLIError.self) { try self.inspectContainer(name1) } - #expect(throws: CLIError.self) { try self.inspectContainer(name2) } - } - - @Test func testDeleteAllSkipsRunning() async throws { - let runningName = testName + "-running" - let stoppedName = testName + "-stopped" - defer { - try? doRemove(name: runningName, force: true) - try? doRemove(name: stoppedName, force: true) - } - - try doLongRun(name: runningName, autoRemove: false) - try waitForContainerRunning(runningName) - - let (_, _, createError, createStatus) = try run(arguments: ["create", "--name", stoppedName, alpine, "sleep", "infinity"]) - #expect(createStatus == 0, "create failed: \(createError)") - - let (_, _, deleteError, deleteStatus) = try run(arguments: ["delete", "--all"]) - #expect(deleteStatus == 0, "delete --all failed: \(deleteError)") - - // Running container should be untouched - #expect(try getContainerStatus(runningName) == "running") - // Stopped container should be gone - #expect(throws: CLIError.self) { try self.inspectContainer(stoppedName) } - } - - @Test func testDeleteAllForce() async throws { - let name = testName - defer { try? doRemove(name: name, force: true) } - - try doLongRun(name: name, autoRemove: false) - try waitForContainerRunning(name) - - let (_, _, deleteError, deleteStatus) = try run(arguments: ["delete", "--all", "--force"]) - #expect(deleteStatus == 0, "delete --all --force failed: \(deleteError)") - #expect(throws: CLIError.self) { try self.inspectContainer(name) } - } - - @Test func testDeleteNoArgs() throws { - let (_, _, _, status) = try run(arguments: ["delete"]) - #expect(status != 0, "Expected non-zero exit when no args and no --all") - } - - @Test func testDeleteExplicitIdsConflictWithAll() throws { - let (_, _, error, status) = try run(arguments: ["delete", "--all", "some-container"]) - #expect(status != 0, "Expected non-zero exit for conflicting flags") - #expect(error.contains("conflict")) - } - - @Test func testDeleteDuplicateIds() async throws { - let name = testName - defer { try? doRemove(name: name, force: true) } - - let (_, _, createError, createStatus) = try run(arguments: ["create", "--name", name, alpine, "sleep", "infinity"]) - #expect(createStatus == 0, "create failed: \(createError)") - - let (_, output, deleteError, deleteStatus) = try run(arguments: ["delete", name, name]) - #expect(deleteStatus == 0, "delete with duplicate IDs failed: \(deleteError)") - let lines = output.split(separator: "\n").filter { $0.contains(name) } - #expect(lines.count == 1, "Expected container to be deleted exactly once, got \(lines.count) lines") - } - - @Test func testInspectMissingContainerFails() throws { - let (_, _, error, status) = try run(arguments: ["inspect", "definitely-missing-container"]) - #expect(status != 0, "Expected non-zero exit for missing container") - #expect(error.contains("container not found")) - } -} diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIStats.swift b/Tests/CLITests/Subcommands/Containers/TestCLIStats.swift deleted file mode 100644 index 756a3e9e8..000000000 --- a/Tests/CLITests/Subcommands/Containers/TestCLIStats.swift +++ /dev/null @@ -1,219 +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) -class TestCLIStatsCommand: CLITest { - private func getTestName() -> String { - Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() - } - - @Test func testStatsNoStreamJSONFormat() throws { - let name = getTestName() - #expect(throws: Never.self, "expected stats command to succeed") { - try doLongRun(name: name) - defer { - try? doStop(name: name) - try? doRemove(name: name) - } - try waitForContainerRunning(name) - - let (data, _, error, status) = try run(arguments: [ - "stats", - "--format", "json", - "--no-stream", - name, - ]) - - try #require(status == 0, "stats command should succeed, error: \(error)") - - let decoder = JSONDecoder() - let stats = try decoder.decode([ContainerStats].self, from: data) - - #expect(stats.count == 1, "expected stats for one container") - #expect(stats[0].id == name, "container ID should match") - let memoryUsageBytes = try #require(stats[0].memoryUsageBytes) - let numProcesses = try #require(stats[0].numProcesses) - #expect(memoryUsageBytes > 0, "memory usage should be non-zero") - #expect(numProcesses >= 1, "should have at least one process") - } - } - - @Test func testStatsIdleCPUPercentage() throws { - let name = getTestName() - #expect(throws: Never.self, "expected stats to show low CPU for idle container") { - try doLongRun(name: name, containerArgs: ["sleep", "3600"]) - defer { - try? doStop(name: name) - try? doRemove(name: name) - } - try waitForContainerRunning(name) - - // Get stats in table format - let (_, output, _, status) = try run(arguments: [ - "stats", - "--no-stream", - name, - ]) - try #require(status == 0, "stats command should succeed") - - // Parse the table output - let lines = output.components(separatedBy: .newlines) - #expect(lines.count >= 2, "should have at least header and one data row") - - // Find the data row (not the header) - let dataLine = lines.first { $0.contains(name) } - try #require(dataLine != nil, "should find container data row") - - // Extract CPU percentage - it should be in the second column - let columns = dataLine!.split(separator: " ").filter { !$0.isEmpty } - #expect(columns.count >= 2, "should have at least 2 columns") - - // Second column is CPU% - let cpuString = String(columns[1]) - #expect(cpuString.hasSuffix("%"), "CPU column should end with %") - - // Parse the percentage - let cpuValue = Double(cpuString.dropLast()) - try #require(cpuValue != nil, "should be able to parse CPU percentage") - - // Idle container should use very little CPU (less than 5%) - #expect(cpuValue! < 5.0, "idle container CPU should be < 5%, got \(cpuValue!)%") - } - } - - @Test func testStatsHighCPUPercentage() throws { - let name = getTestName() - #expect(throws: Never.self, "expected stats to show high CPU for busy container") { - // Run a container with a busy loop - try doLongRun(name: name, containerArgs: ["sh", "-c", "while true; do :; done"]) - defer { - try? doStop(name: name) - try? doRemove(name: name) - } - try waitForContainerRunning(name) - - // Get stats in table format - let (_, output, _, status) = try run(arguments: [ - "stats", - "--no-stream", - name, - ]) - try #require(status == 0, "stats command should succeed") - - // Parse the table output - let lines = output.components(separatedBy: .newlines) - #expect(lines.count >= 2, "should have at least header and one data row") - - // Find the data row (not the header) - let dataLine = lines.first { $0.contains(name) } - try #require(dataLine != nil, "should find container data row") - - // Extract CPU percentage - it should be in the second column - // Format is like: "container_id 95.23% ..." - let columns = dataLine!.split(separator: " ").filter { !$0.isEmpty } - #expect(columns.count >= 2, "should have at least 2 columns") - - // Second column is CPU% - let cpuString = String(columns[1]) - #expect(cpuString.hasSuffix("%"), "CPU column should end with %") - - // Parse the percentage - let cpuValue = Double(cpuString.dropLast()) - try #require(cpuValue != nil, "should be able to parse CPU percentage") - - // Busy loop should use significant CPU (at least 50% of one core) - #expect(cpuValue! > 50.0, "busy container CPU should be > 50%, got \(cpuValue!)%") - // Should not exceed reasonable limits (one core doing while loop = ~100%) - #expect(cpuValue! < 150.0, "single busy loop should not exceed 150%, got \(cpuValue!)%") - } - } - - @Test func testStatsTableFormat() throws { - let name = getTestName() - #expect(throws: Never.self, "expected stats table format to work") { - try doLongRun(name: name) - defer { - try? doStop(name: name) - try? doRemove(name: name) - } - try waitForContainerRunning(name) - - // Get stats in table format - let (_, output, error, status) = try run(arguments: [ - "stats", - "--no-stream", - name, - ]) - - try #require(status == 0, "stats command should succeed, error: \(error)") - #expect(output.contains("Container ID"), "output should contain table header") - #expect(output.contains("Cpu %"), "output should contain CPU column") - #expect(output.contains("Memory Usage"), "output should contain Memory column") - #expect(output.contains(name), "output should contain container name") - } - } - - @Test func testStatsAllContainers() throws { - let name1 = getTestName() + "-1" - let name2 = getTestName() + "-2" - #expect(throws: Never.self, "expected stats for all containers") { - try doLongRun(name: name1) - try doLongRun(name: name2) - defer { - try? doStop(name: name1) - try? doStop(name: name2) - try? doRemove(name: name1) - try? doRemove(name: name2) - } - try waitForContainerRunning(name1) - try waitForContainerRunning(name2) - - // Get stats for all containers (no name specified) - let (data, _, error, status) = try run(arguments: [ - "stats", - "--format", "json", - "--no-stream", - ]) - - try #require(status == 0, "stats command should succeed, error: \(error)") - - let stats = try JSONDecoder().decode([ContainerStats].self, from: data) - - // Should have stats for both containers - try #require(stats.count >= 2, "should have stats for at least 2 containers") - - let containerIds = stats.map { $0.id } - #expect(containerIds.contains(name1), "should include first container") - #expect(containerIds.contains(name2), "should include second container") - } - } - - @Test func testStatsNonExistentContainer() throws { - #expect(throws: Never.self, "expected stats to fail for non-existent container") { - let (_, _, _, status) = try run(arguments: [ - "stats", - "--no-stream", - "nonexistent-container-xyz", - ]) - - #expect(status != 0, "stats command should fail for non-existent container") - } - } -} diff --git a/Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift b/Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift deleted file mode 100644 index fe326faef..000000000 --- a/Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift +++ /dev/null @@ -1,70 +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 - -class TestCLIProgressAuto: CLITest { - @Test func testAutoProgressFallsBackToPlainWhenPiped() throws { - let (_, _, error, status) = try run(arguments: [ - "image", "pull", - "--progress", "auto", - alpine, - ]) - #expect(status == 0, "image pull should succeed, stderr: \(error)") - let lines = error.components(separatedBy: .newlines) - .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } - #expect(!lines.isEmpty, "expected plain progress output on stderr when piped") - #expect(!error.contains("\u{1B}["), "expected no ANSI escapes in piped output") - } - - @Test func testExplicitPlainProgress() throws { - let (_, _, error, status) = try run(arguments: [ - "image", "pull", - "--progress", "plain", - alpine, - ]) - #expect(status == 0, "image pull --progress plain should succeed, stderr: \(error)") - let lines = error.components(separatedBy: .newlines) - .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } - #expect(!lines.isEmpty, "expected plain progress output on stderr") - #expect(!error.contains("\u{1B}["), "expected no ANSI escapes with --progress plain") - } - - @Test func testExplicitAnsiProgress() throws { - let (_, _, error, status) = try run(arguments: [ - "image", "pull", - "--progress", "ansi", - alpine, - ]) - #expect(status == 0, "image pull --progress ansi should succeed, stderr: \(error)") - let lines = error.components(separatedBy: .newlines) - .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } - #expect(!lines.isEmpty, "expected ansi progress output on stderr") - } - - @Test func testNoneProgressSuppressesOutput() throws { - let (_, _, error, status) = try run(arguments: [ - "image", "pull", - "--progress", "none", - alpine, - ]) - #expect(status == 0, "image pull --progress none should succeed, stderr: \(error)") - let lines = error.components(separatedBy: .newlines) - .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } - #expect(lines.isEmpty, "expected no progress output on stderr with --progress none") - } -} diff --git a/Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift b/Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift deleted file mode 100644 index 81e692b69..000000000 --- a/Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift +++ /dev/null @@ -1,209 +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 ContainerizationError -import Darwin -import Foundation -import Testing - -@Suite(.serialSuites) -class TestCLIRunLifecycle: CLITest { - private func getTestName() -> String { - Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() - } - - @Test func testRunFailureCleanup() throws { - let name = getTestName() - - // try to create a container we know will fail - let badArgs: [String] = [ - "--rm", - "--user", - name, - ] - #expect(throws: CLIError.self, "expect container to fail with invalid user") { - try self.doLongRun(name: name, args: badArgs) - } - - // try to create a container with the same name but no user that should succeed - #expect(throws: Never.self, "expected container run to succeed") { - try self.doLongRun(name: name, args: []) - defer { - try? self.doStop(name: name) - } - let _ = try self.doExec(name: name, cmd: ["date"]) - try self.doStop(name: name) - } - } - - @Test func testStartIdempotent() throws { - let name = getTestName() - - #expect(throws: Never.self, "expected container run to succeed") { - try self.doLongRun(name: name, args: []) - defer { - try? self.doStop(name: name) - } - try self.waitForContainerRunning(name) - - let (_, output, _, status) = try self.run(arguments: ["start", name]) - #expect(status == 0, "expected start to succeed on already running container") - #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == name, "expected output to be container name") - - // Don't care about the resp, just that the container is still there and not cleaned up. - let _ = try inspectContainer(name) - - try self.doStop(name: name) - } - } - - @Test func testStartIdempotentAttachFails() throws { - let name = getTestName() - - #expect(throws: Never.self, "expected container run to succeed") { - try self.doLongRun(name: name, args: []) - defer { - try? self.doStop(name: name) - } - try self.waitForContainerRunning(name) - - let (_, _, error, status) = try self.run(arguments: ["start", "-a", name]) - #expect(status != 0, "expected start with attach to fail on already running container") - #expect(error.contains("attach is currently unsupported on already running containers"), "expected error message about attach not supported") - - try self.doStop(name: name) - } - } - - @Test func testStartPortBindFails() async throws { - let port = UInt16.random(in: 50000..<60000) - - let name = getTestName() - try self.doCreate(name: name, ports: ["\(port)"]) - defer { - try? self.doRemove(name: name) - } - - let server = "\(name)-server" - try doLongRun( - name: server, - image: "docker.io/library/python:alpine", - args: ["--publish", "\(port):\(port)"], - containerArgs: ["python3", "-m", "http.server", "\(port)"] - ) - defer { - try? doStop(name: server) - } - - #expect(throws: CLIError.self) { - try doStart(name: name) - } - - let status = try getContainerStatus(name) - #expect(status == "stopped") - } - - @Test func testRunInvalidExcutable() async throws { - let name = getTestName() - #expect(throws: CLIError.self, "running invalid executable must throw error, not hang") { - try doLongRun( - name: name, - containerArgs: ["foobarbaz"] - ) - } - try? doRemove(name: name) - } - - @Test func testExecInvalidExcutable() async throws { - let name = getTestName() - try doLongRun(name: name) - defer { - try? doStop(name: name) - } - - #expect(throws: CLIError.self, "executing invalid executable must throw error, not hang") { - try doExec( - name: name, - cmd: ["foobarbaz"] - ) - } - } - - @Test func testSSHForwarding() throws { - let name = getTestName() - - // Create a temp dir and socket path for the simulated SSH agent. - let socketDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: socketDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: socketDir) } - - let socketPath = socketDir.appendingPathComponent("ssh-auth.sock").path - - // Create a listening Unix domain socket to act as a fake SSH agent. - let serverFd = socket(AF_UNIX, SOCK_STREAM, 0) - precondition(serverFd >= 0, "socket() failed") - defer { Darwin.close(serverFd) } - - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - withUnsafeMutableBytes(of: &addr.sun_path) { bytes in - socketPath.withCString { cStr in - bytes.copyMemory(from: UnsafeRawBufferPointer(start: cStr, count: socketPath.utf8.count + 1)) - } - } - let bindResult = withUnsafePointer(to: addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - bind(serverFd, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - precondition(bindResult == 0, "bind() failed: \(errno)") - precondition(listen(serverFd, 5) == 0, "listen() failed") - - // Accept and immediately close connections in background to keep the socket alive. - let acceptThread = Thread { - while true { - let clientFd = accept(serverFd, nil, nil) - if clientFd < 0 { break } - Darwin.close(clientFd) - } - } - acceptThread.start() - - defer { try? doStop(name: name) } - - try doLongRun(name: name, args: ["--ssh"], env: ["SSH_AUTH_SOCK": socketPath]) - try waitForContainerRunning(name) - - // Verify SSH_AUTH_SOCK is set to the expected guest path inside the container. - let sshSockValue = try doExec(name: name, cmd: ["sh", "-c", "echo $SSH_AUTH_SOCK"]) - #expect( - sshSockValue.trimmingCharacters(in: .whitespacesAndNewlines) == "/var/host-services/ssh-auth.sock", - "expected SSH_AUTH_SOCK to point to guest socket path" - ) - - // Verify the forwarded socket file is present and is a socket. - let socketCheck = try doExec( - name: name, - cmd: ["sh", "-c", "[ -S /var/host-services/ssh-auth.sock ] && echo exists || echo missing"] - ) - #expect( - socketCheck.trimmingCharacters(in: .whitespacesAndNewlines) == "exists", - "expected forwarded SSH socket to exist in container" - ) - - try doStop(name: name) - } -} diff --git a/Tests/IntegrationTests/Build/BuildFixture.swift b/Tests/IntegrationTests/Build/BuildFixture.swift index 41a0ec38e..786cb743f 100644 --- a/Tests/IntegrationTests/Build/BuildFixture.swift +++ b/Tests/IntegrationTests/Build/BuildFixture.swift @@ -97,7 +97,7 @@ extension ContainerFixture { /// Polls until the buildkit container is running and the builder shim is ready. func waitForBuilderRunning() async throws { - try waitForContainerRunning("buildkit", attempts: 10) + try await 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 { diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnly.swift b/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift similarity index 100% rename from Tests/IntegrationTests/Build/TestCLIBuilderEnvOnly.swift rename to Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderLifecycle.swift b/Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift similarity index 100% rename from Tests/IntegrationTests/Build/TestCLIBuilderLifecycle.swift rename to Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutput.swift b/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift similarity index 100% rename from Tests/IntegrationTests/Build/TestCLIBuilderLocalOutput.swift rename to Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift diff --git a/Tests/IntegrationTests/Build/TestCLIBuilder.swift b/Tests/IntegrationTests/Build/TestCLIBuilderSerial.swift similarity index 100% rename from Tests/IntegrationTests/Build/TestCLIBuilder.swift rename to Tests/IntegrationTests/Build/TestCLIBuilderSerial.swift diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderTarExport.swift b/Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift similarity index 100% rename from Tests/IntegrationTests/Build/TestCLIBuilderTarExport.swift rename to Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift diff --git a/Tests/IntegrationTests/Containers/TestCLICopy.swift b/Tests/IntegrationTests/Containers/TestCLICopy.swift new file mode 100644 index 000000000..dcbd18c13 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLICopy.swift @@ -0,0 +1,546 @@ +//===----------------------------------------------------------------------===// +// 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 TestCLICopyCommand { + + // MARK: - Basic host/container copy + + @Test func testCopyHostToContainer() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let src = f.testDir.appending("testfile.txt") + let content = "hello from host" + try content.write(toFile: src.string, atomically: true, encoding: .utf8) + try f.run(["copy", src.string, "\(name):/tmp/"]).check() + let cat = try f.doExec(name, cmd: ["cat", "/tmp/testfile.txt"]) + #expect(cat.trimmingCharacters(in: .whitespacesAndNewlines) == content) + } + } + } + + @Test func testCopyContainerToHost() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let content = "hello from container" + try f.doExec(name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/containerfile.txt"]) + let dest = f.testDir.appending("containerfile.txt") + try f.run(["copy", "\(name):/tmp/containerfile.txt", dest.string]).check() + let hostContent = try String(contentsOfFile: dest.string, encoding: .utf8) + #expect(hostContent == content) + } + } + } + + @Test func testCopyUsingCpAlias() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let src = f.testDir.appending("aliasfile.txt") + let content = "testing cp alias" + try content.write(toFile: src.string, atomically: true, encoding: .utf8) + try f.run(["cp", src.string, "\(name):/tmp/"]).check() + let cat = try f.doExec(name, cmd: ["cat", "/tmp/aliasfile.txt"]) + #expect(cat.trimmingCharacters(in: .whitespacesAndNewlines) == content) + } + } + } + + @Test func testCopyLocalToLocalFails() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["copy", "/tmp/source.txt", "/tmp/dest.txt"]) + #expect(result.status != 0, "expected local-to-local copy to fail") + } + } + + @Test func testCopyContainerToContainerFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doCreate(name: name, image: image) + f.addCleanup { try f.doRemoveIfExists(name, ignoreFailure: true) } + let result = try f.run(["copy", "\(name):/tmp/file.txt", "\(name):/tmp/file2.txt"]) + #expect(result.status != 0, "expected container-to-container copy to fail") + } + } + + @Test func testCopyToNonRunningContainerFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doCreate(name: name, image: image) + f.addCleanup { try f.doRemoveIfExists(name, ignoreFailure: true) } + let src = f.testDir.appending("norun.txt") + try "test".write(toFile: src.string, atomically: true, encoding: .utf8) + let result = try f.run(["copy", src.string, "\(name):/tmp/"]) + #expect(result.status != 0, "expected copy to non-running container to fail") + } + } + + @Test func testCopyDirectoryHostToContainer() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("hostdir") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) + try "file1 content".write(toFile: srcDir.appending("file1.txt").string, atomically: true, encoding: .utf8) + try "file2 content".write(toFile: srcDir.appending("file2.txt").string, atomically: true, encoding: .utf8) + try f.run(["copy", srcDir.string, "\(name):/tmp/"]).check() + let cat1 = try f.doExec(name, cmd: ["cat", "/tmp/hostdir/file1.txt"]) + #expect(cat1.trimmingCharacters(in: .whitespacesAndNewlines) == "file1 content") + let cat2 = try f.doExec(name, cmd: ["cat", "/tmp/hostdir/file2.txt"]) + #expect(cat2.trimmingCharacters(in: .whitespacesAndNewlines) == "file2 content") + } + } + } + + @Test func testCopyDirectoryContainerToHost() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/guestdir && echo -n 'aaa' > /tmp/guestdir/a.txt && echo -n 'bbb' > /tmp/guestdir/b.txt"]) + let dest = f.testDir.appending("guestdir") + try f.run(["copy", "\(name):/tmp/guestdir", dest.string]).check() + let a = try String(contentsOfFile: dest.appending("a.txt").string, encoding: .utf8) + #expect(a == "aaa") + let b = try String(contentsOfFile: dest.appending("b.txt").string, encoding: .utf8) + #expect(b == "bbb") + } + } + } + + @Test func testCopyNestedDirectoryHostToContainer() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("nested") + let subDir = srcDir.appending("sub") + try FileManager.default.createDirectory(atPath: subDir.string, withIntermediateDirectories: true, attributes: nil) + try "root file".write(toFile: srcDir.appending("root.txt").string, atomically: true, encoding: .utf8) + try "nested file".write(toFile: subDir.appending("deep.txt").string, atomically: true, encoding: .utf8) + try f.run(["copy", srcDir.string, "\(name):/tmp/"]).check() + let catRoot = try f.doExec(name, cmd: ["cat", "/tmp/nested/root.txt"]) + #expect(catRoot.trimmingCharacters(in: .whitespacesAndNewlines) == "root file") + let catDeep = try f.doExec(name, cmd: ["cat", "/tmp/nested/sub/deep.txt"]) + #expect(catDeep.trimmingCharacters(in: .whitespacesAndNewlines) == "nested file") + } + } + } + + @Test func testCopyNestedDirectoryContainerToHost() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/nested/sub && echo -n 'root file' > /tmp/nested/root.txt && echo -n 'nested file' > /tmp/nested/sub/deep.txt"]) + let dest = f.testDir.appending("nested") + try f.run(["copy", "\(name):/tmp/nested", dest.string]).check() + let root = try String(contentsOfFile: dest.appending("root.txt").string, encoding: .utf8) + #expect(root == "root file") + let deep = try String(contentsOfFile: dest.appending("sub").appending("deep.txt").string, encoding: .utf8) + #expect(deep == "nested file") + } + } + } + + // MARK: - CopyOut S1: no trailing slash + + @Test func testCopyOutFileToExistingFile() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let content = "container content" + try f.doExec(name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/source.txt"]) + let dest = f.testDir.appending("existing.txt") + try "old content".write(toFile: dest.string, atomically: true, encoding: .utf8) + try f.run(["copy", "\(name):/tmp/source.txt", dest.string]).check() + let result = try String(contentsOfFile: dest.string, encoding: .utf8) + #expect(result == content) + } + } + } + + @Test func testCopyOutDirectoryToExistingFileFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'x' > /tmp/srcdir/file.txt"]) + let dest = f.testDir.appending("existing.txt") + try "x".write(toFile: dest.string, atomically: true, encoding: .utf8) + let result = try f.run(["copy", "\(name):/tmp/srcdir", dest.string]) + #expect(result.status != 0, "expected directory-to-existing-file to fail") + } + } + } + + @Test func testCopyOutFileToExistingDirectory() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let content = "container content" + try f.doExec(name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/source.txt"]) + let destDir = f.testDir.appending("dstdir") + try FileManager.default.createDirectory(atPath: destDir.string, withIntermediateDirectories: true, attributes: nil) + try f.run(["copy", "\(name):/tmp/source.txt", destDir.string]).check() + let result = try String(contentsOfFile: destDir.appending("source.txt").string, encoding: .utf8) + #expect(result == content) + } + } + } + + @Test func testCopyOutDirectoryToExistingDirectory() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) + let destDir = f.testDir.appending("dstdir") + try FileManager.default.createDirectory(atPath: destDir.string, withIntermediateDirectories: true, attributes: nil) + try f.run(["copy", "\(name):/tmp/srcdir", destDir.string]).check() + let result = try String(contentsOfFile: destDir.appending("srcdir").appending("file.txt").string, encoding: .utf8) + #expect(result == "hello") + } + } + } + + // MARK: - CopyOut S2: trailing slash on dst + + @Test func testCopyOutFileToNonExistingTrailingSlashFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "echo -n 'x' > /tmp/source.txt"]) + let dest = f.testDir.appending("nonexistent").string + "/" + let result = try f.run(["copy", "\(name):/tmp/source.txt", dest]) + #expect(result.status != 0, "expected file-to-nonexisting/ to fail") + } + } + } + + @Test func testCopyOutDirectoryToNonExistingTrailingSlash() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) + let destDir = f.testDir.appending("newdir") + try f.run(["copy", "\(name):/tmp/srcdir", destDir.string + "/"]).check() + var isDir: ObjCBool = false + #expect(FileManager.default.fileExists(atPath: destDir.string, isDirectory: &isDir) && isDir.boolValue) + let result = try String(contentsOfFile: destDir.appending("file.txt").string, encoding: .utf8) + #expect(result == "hello") + } + } + } + + @Test func testCopyOutFileToExistingDirectoryTrailingSlash() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let content = "container content" + try f.doExec(name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/source.txt"]) + let destDir = f.testDir.appending("dstdir") + try FileManager.default.createDirectory(atPath: destDir.string, withIntermediateDirectories: true, attributes: nil) + try f.run(["copy", "\(name):/tmp/source.txt", destDir.string + "/"]).check() + let result = try String(contentsOfFile: destDir.appending("source.txt").string, encoding: .utf8) + #expect(result == content) + } + } + } + + @Test func testCopyOutDirectoryToExistingDirectoryTrailingSlash() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) + let destDir = f.testDir.appending("dstdir") + try FileManager.default.createDirectory(atPath: destDir.string, withIntermediateDirectories: true, attributes: nil) + try f.run(["copy", "\(name):/tmp/srcdir", destDir.string + "/"]).check() + let result = try String(contentsOfFile: destDir.appending("srcdir").appending("file.txt").string, encoding: .utf8) + #expect(result == "hello") + } + } + } + + // MARK: - CopyOut S3: trailing slash on src + + @Test func testCopyOutDirectoryContentsToNonExisting() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir/sub && echo -n 'hello' > /tmp/srcdir/file.txt"]) + let destDir = f.testDir.appending("newdir") + try f.run(["copy", "\(name):/tmp/srcdir/", destDir.string]).check() + let result = try String(contentsOfFile: destDir.appending("file.txt").string, encoding: .utf8) + #expect(result == "hello") + var isDir: ObjCBool = false + #expect(FileManager.default.fileExists(atPath: destDir.appending("sub").string, isDirectory: &isDir) && isDir.boolValue) + } + } + } + + @Test func testCopyOutDirectoryContentsToExistingFileFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'x' > /tmp/srcdir/file.txt"]) + let dest = f.testDir.appending("existing.txt") + try "x".write(toFile: dest.string, atomically: true, encoding: .utf8) + let result = try f.run(["copy", "\(name):/tmp/srcdir/", dest.string]) + #expect(result.status != 0, "expected directory/-to-existing-file to fail") + } + } + } + + @Test func testCopyOutDirectoryContentsToExistingDirectory() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) + let destDir = f.testDir.appending("dstdir") + try FileManager.default.createDirectory(atPath: destDir.string, withIntermediateDirectories: true, attributes: nil) + try f.run(["copy", "\(name):/tmp/srcdir/", destDir.string]).check() + let result = try String(contentsOfFile: destDir.appending("srcdir").appending("file.txt").string, encoding: .utf8) + #expect(result == "hello") + } + } + } + + // MARK: - CopyOut S4: trailing slash on both src and dst + + @Test func testCopyOutDirectoryContentsToNonExistingTrailingSlash() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) + let destDir = f.testDir.appending("newdir") + try f.run(["copy", "\(name):/tmp/srcdir/", destDir.string + "/"]).check() + let result = try String(contentsOfFile: destDir.appending("file.txt").string, encoding: .utf8) + #expect(result == "hello") + } + } + } + + @Test func testCopyOutDirectoryContentsToExistingDirectoryTrailingSlash() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) + let destDir = f.testDir.appending("dstdir") + try FileManager.default.createDirectory(atPath: destDir.string, withIntermediateDirectories: true, attributes: nil) + try f.run(["copy", "\(name):/tmp/srcdir/", destDir.string + "/"]).check() + let result = try String(contentsOfFile: destDir.appending("srcdir").appending("file.txt").string, encoding: .utf8) + #expect(result == "hello") + } + } + } + + // MARK: - CopyIn S1: no trailing slash + + @Test func testCopyInFileToExistingFile() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let content = "new content" + let src = f.testDir.appending("source.txt") + try content.write(toFile: src.string, atomically: true, encoding: .utf8) + try f.doExec(name, cmd: ["sh", "-c", "echo -n 'old content' > /tmp/existing.txt"]) + try f.run(["copy", src.string, "\(name):/tmp/existing.txt"]).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/existing.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == content) + } + } + } + + @Test func testCopyInDirectoryToExistingFileFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("srcdir") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) + try "x".write(toFile: srcDir.appending("file.txt").string, atomically: true, encoding: .utf8) + try f.doExec(name, cmd: ["sh", "-c", "echo -n 'x' > /tmp/existing.txt"]) + let result = try f.run(["copy", srcDir.string, "\(name):/tmp/existing.txt"]) + #expect(result.status != 0, "expected directory-to-existing-file to fail") + } + } + } + + @Test func testCopyInFileToExistingDirectory() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let content = "host content" + let src = f.testDir.appending("source.txt") + try content.write(toFile: src.string, atomically: true, encoding: .utf8) + try f.doExec(name, cmd: ["mkdir", "-p", "/tmp/dstdir"]) + try f.run(["copy", src.string, "\(name):/tmp/dstdir"]).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/dstdir/source.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == content) + } + } + } + + @Test func testCopyInDirectoryToExistingDirectory() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("srcdir") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) + try "hello".write(toFile: srcDir.appending("file.txt").string, atomically: true, encoding: .utf8) + try f.doExec(name, cmd: ["mkdir", "-p", "/tmp/dstdir"]) + try f.run(["copy", srcDir.string, "\(name):/tmp/dstdir"]).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/dstdir/srcdir/file.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") + } + } + } + + // MARK: - CopyIn S2: trailing slash on dst + + @Test func testCopyInFileToNonExistingTrailingSlashFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let src = f.testDir.appending("source.txt") + try "x".write(toFile: src.string, atomically: true, encoding: .utf8) + let result = try f.run(["copy", src.string, "\(name):/tmp/nonexistent/"]) + #expect(result.status != 0, "expected file-to-nonexisting/ to fail") + } + } + } + + @Test func testCopyInDirectoryToNonExistingTrailingSlash() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("srcdir") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) + try "hello".write(toFile: srcDir.appending("file.txt").string, atomically: true, encoding: .utf8) + try f.run(["copy", srcDir.string, "\(name):/tmp/newdir/"]).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/newdir/file.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") + } + } + } + + // MARK: - CopyIn S3: trailing slash on src + + @Test func testCopyInDirectoryContentsToNonExisting() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("srcdir") + let subDir = srcDir.appending("sub") + try FileManager.default.createDirectory(atPath: subDir.string, withIntermediateDirectories: true, attributes: nil) + try "hello".write(toFile: srcDir.appending("file.txt").string, atomically: true, encoding: .utf8) + try f.run(["copy", srcDir.string + "/", "\(name):/tmp/newdir"]).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/newdir/file.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") + } + } + } + + @Test func testCopyInDirectoryContentsToExistingFileFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("srcdir") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) + try "x".write(toFile: srcDir.appending("file.txt").string, atomically: true, encoding: .utf8) + try f.doExec(name, cmd: ["sh", "-c", "echo -n 'x' > /tmp/existing.txt"]) + let result = try f.run(["copy", srcDir.string + "/", "\(name):/tmp/existing.txt"]) + #expect(result.status != 0, "expected directory/-to-existing-file to fail") + } + } + } + + @Test func testCopyInDirectoryContentsToExistingDirectory() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("srcdir") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) + try "hello".write(toFile: srcDir.appending("file.txt").string, atomically: true, encoding: .utf8) + try f.doExec(name, cmd: ["mkdir", "-p", "/tmp/dstdir"]) + try f.run(["copy", srcDir.string + "/", "\(name):/tmp/dstdir"]).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/dstdir/srcdir/file.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") + } + } + } + + // MARK: - CopyIn S4: trailing slash on both src and dst + + @Test func testCopyInDirectoryContentsToNonExistingTrailingSlash() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("srcdir") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) + try "hello".write(toFile: srcDir.appending("file.txt").string, atomically: true, encoding: .utf8) + try f.run(["copy", srcDir.string + "/", "\(name):/tmp/newdir/"]).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/newdir/file.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") + } + } + } + + @Test func testCopyInDirectoryContentsToExistingDirectoryTrailingSlash() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("srcdir") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) + try "hello".write(toFile: srcDir.appending("file.txt").string, atomically: true, encoding: .utf8) + try f.doExec(name, cmd: ["mkdir", "-p", "/tmp/dstdir"]) + try f.run(["copy", srcDir.string + "/", "\(name):/tmp/dstdir/"]).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/dstdir/srcdir/file.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") + } + } + } + + // MARK: - Relative path resolution + + @Test func testCopyInRelativeSourcePath() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let content = "relative source" + try content.write(toFile: f.testDir.appending("relfile.txt").string, atomically: true, encoding: .utf8) + try f.run(["copy", "./relfile.txt", "\(name):/tmp/"], currentDirectory: f.testDir).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/relfile.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == content) + } + } + } + + @Test func testCopyOutRelativeDestinationPath() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let content = "relative dest" + try f.doExec(name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/relfile.txt"]) + try f.run(["copy", "\(name):/tmp/relfile.txt", "./"], currentDirectory: f.testDir).check() + let result = try String(contentsOfFile: f.testDir.appending("relfile.txt").string, encoding: .utf8) + #expect(result == content) + } + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLICreate.swift b/Tests/IntegrationTests/Containers/TestCLICreate.swift new file mode 100644 index 000000000..897cb2d26 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLICreate.swift @@ -0,0 +1,108 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@Suite +struct TestCLICreateCommand { + @Test func testCreateArgsPassthrough() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doCreate(name: name, image: image, args: ["echo", "-n", "hello", "world"]) + try f.doRemove(name) + } + } + + @Test func testCreateWithMACAddress() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + let expectedMAC = try MACAddress("02:42:ac:11:00:03") + + try f.doCreate(name: name, image: image, networks: ["default,mac=\(expectedMAC)"]) + f.addCleanup { try? f.doStop(name) } + try f.doStart(name) + try await f.waitForContainerRunning(name) + + let inspect = try f.inspectContainer(name) + #expect(inspect.networks.count > 0, "expected at least one network attachment") + let actualMAC = inspect.networks[0].macAddress?.description ?? "nil" + #expect( + actualMAC == expectedMAC.description, + "expected MAC address \(expectedMAC), got \(actualMAC)") + } + } + + @Test func testPublishPortParserMaxPorts() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + var args: [String] = ["create", "--name", name] + for i in 0..<64 { + args += ["--publish", "127.0.0.1:\(8000 + i):\(9000 + i)"] + } + args += [image, "echo", "\"hello world\""] + + let result = try f.run(args) + f.addCleanup { try? f.doRemove(name) } + #expect(result.status == 0, "expected create with 64 ports to succeed, stderr: \(result.error)") + } + } + + @Test func testPublishPortParserTooManyPorts() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + var args: [String] = ["create", "--name", name] + for i in 0..<65 { + args += ["--publish", "127.0.0.1:\(8000 + i):\(9000 + i)"] + } + args += [image, "echo", "\"hello world\""] + + let result = try f.run(args) + f.addCleanup { try? f.doRemove(name) } + #expect(result.status != 0, "expected create with 65 ports to fail") + } + } + + @Test func testCreateWithFQDNName() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + // Prefix with testID to avoid name collisions; hostname is the first FQDN component. + let name = "\(f.testID).example.com" + let expectedHostname = f.testID + + try f.doCreate(name: name, image: image) + f.addCleanup { try? f.doStop(name) } + try f.doStart(name) + try await f.waitForContainerRunning(name) + + let inspect = try f.inspectContainer(name) + let attachmentHostname = inspect.networks.first?.hostname ?? "" + let gotHostname = + attachmentHostname + .split(separator: ".", maxSplits: 1, omittingEmptySubsequences: true) + .first + .map { String($0) } ?? attachmentHostname + #expect( + gotHostname == expectedHostname, + "expected hostname '\(expectedHostname)' from FQDN '\(name)', got '\(gotHostname)'") + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLIExec.swift b/Tests/IntegrationTests/Containers/TestCLIExec.swift new file mode 100644 index 000000000..d3cbec59c --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIExec.swift @@ -0,0 +1,115 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@Suite +struct TestCLIExecCommand { + @Test func testCreateExecCommand() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doCreate(name: name, image: image) + f.addCleanup { try? f.doStop(name) } + try f.doStart(name) + try await f.waitForContainerRunning(name) + let uname = try f.doExec(name, cmd: ["uname"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(uname == "Linux", "expected OS to be Linux, got \(uname)") + try f.doStop(name) + } + } + + @Test func testExecDetach() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doCreate(name: name, image: image) + f.addCleanup { try? f.doStop(name) } + try f.doStart(name) + try await f.waitForContainerRunning(name) + + let output = try f.doExec(name, cmd: ["sh", "-c", "touch /tmp/detach_test_marker"], detach: true) + try #require( + output.trimmingCharacters(in: .whitespacesAndNewlines) == name, + "exec --detach should print the container name") + + let ls = try f.doExec(name, cmd: ["ls", "/"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + try #require(ls.contains("tmp"), "container should still be running after detached exec") + + // Retry until the detached process creates the marker file. + var markerFound = false + for _ in 0..<3 { + let result = try f.run(["exec", name, "test", "-f", "/tmp/detach_test_marker"]) + if result.status == 0 { + markerFound = true + break + } + try await Task.sleep(for: .seconds(1)) + } + try #require(markerFound, "marker file should be created by detached process within 3 seconds") + + try f.doStop(name) + } + } + + @Test func testExecDetachProcessRunning() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doCreate(name: name, image: image) + f.addCleanup { try? f.doStop(name) } + try f.doStart(name) + try await f.waitForContainerRunning(name) + + let output = try f.doExec(name, cmd: ["sleep", "10"], detach: true) + try #require( + output.trimmingCharacters(in: .whitespacesAndNewlines) == name, + "exec --detach should print the container name") + + let ps = try f.doExec(name, cmd: ["ps", "aux"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + try #require(ps.contains("sleep 10"), "detached 'sleep 10' should appear in ps output") + + try f.doStop(name) + } + } + + @Test func testExecOnExitingContainer() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + // sh exits immediately in detached mode with no stdin; container stops on its own. + try f.doLongRun(name: name, image: image, containerArgs: ["sh"], autoRemove: false) + f.addCleanup { try? f.doRemove(name) } + try await Task.sleep(for: .seconds(1)) + + try f.doStart(name) + let execResult = try f.run(["exec", name, "sleep", "infinity"]) + if execResult.status != 0 { + #expect( + execResult.error.contains("is not running") + || execResult.error.contains("failed to create process"), + "expected 'not running' error, got: \(execResult.error)") + } + + try await Task.sleep(for: .seconds(1)) + // Container should still exist even if exec failed. + _ = try f.getContainerStatus(name) + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLIPruneCommandSerial.swift b/Tests/IntegrationTests/Containers/TestCLIPruneCommandSerial.swift new file mode 100644 index 000000000..cedd48ac8 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIPruneCommandSerial.swift @@ -0,0 +1,78 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Serial prune tests — `container prune` affects all stopped containers regardless of name. +@Suite(.serialized) +struct TestCLIPruneCommandSerial { + @Test(.disabled("flaky — prune picks up containers from concurrent suites; tests being rewritten")) + func testContainerPruneNoContainers() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["prune"]).check() + #expect(result.error.contains("Reclaimed Zero KB in disk space"), "should show no containers message") + } + } + + @Test func testContainerPruneStoppedContainers() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + + // One running container that must survive the prune. + try await f.withContainer(image: image, tag: "running", containerArgs: ["sleep", "3600"]) { npcName in + // Two containers to stop and prune. + try await f.withContainer( + image: image, tag: "prune0", containerArgs: ["sleep", "3600"], autoRemove: false + ) { pc0Name in + try await f.withContainer( + image: image, tag: "prune1", containerArgs: ["sleep", "3600"], autoRemove: false + ) { pc1Name in + let pc0Id = try f.getContainerId(pc0Name) + let pc1Id = try f.getContainerId(pc1Name) + + try f.doStop(pc0Name) + try f.doStop(pc1Name) + + // Poll until both containers reach stopped state. + let deadline = Date().addingTimeInterval(30) + while true { + let s0 = try f.getContainerStatus(pc0Name) + let s1 = try f.getContainerStatus(pc1Name) + if s0 == "stopped" && s1 == "stopped" { break } + guard Date() < deadline else { + throw CommandError.executionFailed( + "Timeout waiting for containers to stop: pc0=\(s0), pc1=\(s1)") + } + try await Task.sleep(for: .milliseconds(200)) + } + + let result = try f.run(["prune"]).check() + #expect( + result.output.contains(pc0Id) && result.output.contains(pc1Id), + "prune output should list stopped container IDs") + #expect( + !result.error.contains("Reclaimed Zero KB in disk space"), + "reclaimed space should not be zero") + + let npcStatus = try f.getContainerStatus(npcName) + #expect(npcStatus == "running", "running container should not be pruned") + } + } + } + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLIRemove.swift b/Tests/IntegrationTests/Containers/TestCLIRemove.swift new file mode 100644 index 000000000..0ef6b8d95 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIRemove.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 + +/// Concurrent removal tests — all use testID-scoped names. +@Suite +struct TestCLIRemove { + @Test func testDeleteStopped() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + // create without --rm so the container persists after being stopped + try f.doCreate(name: name, image: image) + try f.doRemove(name) + let result = try f.run(["inspect", name]) + #expect(result.status != 0, "container should not exist after delete") + } + } + + @Test func testDeleteAlias() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doCreate(name: name, image: image) + try f.run(["rm", name]).check("rm alias failed") + let result = try f.run(["inspect", name]) + #expect(result.status != 0, "container should not exist after rm") + } + } + + @Test func testDeleteForceRunning() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doRemove(name, force: true) + let result = try f.run(["inspect", name]) + #expect(result.status != 0, "container should not exist after force delete") + } + } + } + + @Test func testDeleteNoArgs() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["delete"]) + #expect(result.status != 0, "delete with no args should fail") + } + } + + @Test func testDeleteExplicitIdsConflictWithAll() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["delete", "--all", "some-container"]) + #expect(result.status != 0, "delete --all with explicit ID should fail") + #expect(result.error.contains("conflict")) + } + } + + @Test func testDeleteDuplicateIds() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doCreate(name: name, image: image) + f.addCleanup { try f.doRemoveIfExists(name, force: true, ignoreFailure: true) } + let result = try f.run(["delete", name, name]) + #expect(result.status == 0, "delete with duplicate IDs should succeed, stderr: \(result.error)") + let lines = result.output.split(separator: "\n").filter { $0.contains(name) } + #expect(lines.count == 1, "container should be deleted exactly once, got \(lines.count) lines") + } + } + + @Test func testInspectMissingContainerFails() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["inspect", "definitely-missing-container"]) + #expect(result.status != 0, "inspect of missing container should fail") + #expect(result.error.contains("container not found")) + } + } +} + +/// Serial removal tests that use `delete --all` and affect global container state. +@Suite(.serialized) +struct TestCLIRemoveSerial { + @Test func testDeleteAllStopped() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name1 = "\(f.testID)-c1" + let name2 = "\(f.testID)-c2" + try f.doCreate(name: name1, image: image) + f.addCleanup { try f.doRemoveIfExists(name1, ignoreFailure: true) } + try f.doCreate(name: name2, image: image) + f.addCleanup { try f.doRemoveIfExists(name2, ignoreFailure: true) } + + try f.run(["delete", "--all"]).check() + + #expect(try f.run(["inspect", name1]).status != 0, "name1 should be deleted") + #expect(try f.run(["inspect", name2]).status != 0, "name2 should be deleted") + } + } + + @Test func testDeleteAllSkipsRunning() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let runningName = "\(f.testID)-running" + let stoppedName = "\(f.testID)-stopped" + + try f.doLongRun(name: runningName, image: image, autoRemove: false) + f.addCleanup { + try? f.doStop(runningName) + try? f.doRemove(runningName) + } + try f.doCreate(name: stoppedName, image: image) + f.addCleanup { try f.doRemoveIfExists(stoppedName, ignoreFailure: true) } + + try f.run(["delete", "--all"]).check() + + #expect(try f.getContainerStatus(runningName) == "running", "running container should survive delete --all") + #expect(try f.run(["inspect", stoppedName]).status != 0, "stopped container should be deleted") + } + } + + @Test func testDeleteAllForce() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doLongRun(name: name, image: image, autoRemove: false) + f.addCleanup { try f.doRemoveIfExists(name, force: true, ignoreFailure: true) } + + try f.run(["delete", "--all", "--force"]).check() + + #expect(try f.run(["inspect", name]).status != 0, "container should be deleted by --force") + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLIRmRaceCondition.swift b/Tests/IntegrationTests/Containers/TestCLIRmRaceCondition.swift index e465ed767..d7b7562b6 100644 --- a/Tests/IntegrationTests/Containers/TestCLIRmRaceCondition.swift +++ b/Tests/IntegrationTests/Containers/TestCLIRmRaceCondition.swift @@ -25,7 +25,7 @@ struct TestCLIRmRaceCondition { try f.doCreate(name: name) try f.doStart(name) - try f.waitForContainerRunning(name) + try await f.waitForContainerRunning(name) try f.doStop(name) // Immediately attempt removal — both outcomes are valid: diff --git a/Tests/IntegrationTests/Containers/TestCLIStats.swift b/Tests/IntegrationTests/Containers/TestCLIStats.swift new file mode 100644 index 000000000..3aea626f6 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIStats.swift @@ -0,0 +1,112 @@ +//===----------------------------------------------------------------------===// +// 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 + +@Suite +struct TestCLIStatsCommand { + @Test func testStatsNoStreamJSONFormat() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let result = try f.run(["stats", "--format", "json", "--no-stream", name]).check() + let stats = try JSONDecoder().decode([ContainerStats].self, from: result.outputData) + #expect(stats.count == 1, "expected stats for one container") + #expect(stats[0].id == name, "container ID should match") + let memoryUsageBytes = try #require(stats[0].memoryUsageBytes) + let numProcesses = try #require(stats[0].numProcesses) + #expect(memoryUsageBytes > 0, "memory usage should be non-zero") + #expect(numProcesses >= 1, "should have at least one process") + } + } + } + + @Test func testStatsIdleCPUPercentage() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image, containerArgs: ["sleep", "3600"]) { name in + let result = try f.run(["stats", "--no-stream", name]).check() + let lines = result.output.components(separatedBy: .newlines) + #expect(lines.count >= 2, "should have at least header and one data row") + let dataLine = try #require(lines.first { $0.contains(name) }, "should find container data row") + let columns = dataLine.split(separator: " ").filter { !$0.isEmpty } + #expect(columns.count >= 2, "should have at least 2 columns") + let cpuString = String(columns[1]) + #expect(cpuString.hasSuffix("%"), "CPU column should end with %") + let cpuValue = try #require(Double(cpuString.dropLast()), "should parse CPU percentage") + #expect(cpuValue < 5.0, "idle container CPU should be < 5%, got \(cpuValue)%") + } + } + } + + @Test func testStatsHighCPUPercentage() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image, containerArgs: ["sh", "-c", "while true; do :; done"]) { name in + let result = try f.run(["stats", "--no-stream", name]).check() + let lines = result.output.components(separatedBy: .newlines) + #expect(lines.count >= 2, "should have at least header and one data row") + let dataLine = try #require(lines.first { $0.contains(name) }, "should find container data row") + let columns = dataLine.split(separator: " ").filter { !$0.isEmpty } + #expect(columns.count >= 2, "should have at least 2 columns") + let cpuString = String(columns[1]) + #expect(cpuString.hasSuffix("%"), "CPU column should end with %") + let cpuValue = try #require(Double(cpuString.dropLast()), "should parse CPU percentage") + #expect(cpuValue > 50.0, "busy container CPU should be > 50%, got \(cpuValue)%") + #expect(cpuValue < 150.0, "single busy loop should not exceed 150%, got \(cpuValue)%") + } + } + } + + @Test func testStatsTableFormat() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let result = try f.run(["stats", "--no-stream", name]).check() + #expect(result.output.contains("Container ID"), "output should contain table header") + #expect(result.output.contains("Cpu %"), "output should contain CPU column") + #expect(result.output.contains("Memory Usage"), "output should contain Memory column") + #expect(result.output.contains(name), "output should contain container name") + } + } + } + + @Test func testStatsAllContainers() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + // Run two containers simultaneously so both appear in the global stats snapshot. + try await f.withContainer(image: image, tag: "c1") { name1 in + try await f.withContainer(image: image, tag: "c2") { name2 in + let result = try f.run(["stats", "--format", "json", "--no-stream"]).check() + let stats = try JSONDecoder().decode([ContainerStats].self, from: result.outputData) + try #require(stats.count >= 2, "should have stats for at least 2 containers") + let ids = stats.map { $0.id } + #expect(ids.contains(name1), "should include first container") + #expect(ids.contains(name2), "should include second container") + } + } + } + } + + @Test func testStatsNonExistentContainer() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["stats", "--no-stream", "nonexistent-container-xyz"]) + #expect(result.status != 0, "stats should fail for non-existent container") + } + } +} diff --git a/Tests/IntegrationTests/Demo/DemoGlobalTests.swift b/Tests/IntegrationTests/Demo/DemoGlobalTests.swift deleted file mode 100644 index 670107cc3..000000000 --- a/Tests/IntegrationTests/Demo/DemoGlobalTests.swift +++ /dev/null @@ -1,41 +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 Testing - -/// Demonstration suite for the serial global test pass. -/// -/// These two tests are structurally identical to ``DemoConcurrentTests`` but -/// run under ``--experimental-maximum-parallelization-width 1`` in the Makefile -/// to show serial execution. Total wall-clock time should be approximately the -/// sum of the individual durations rather than the maximum. -/// -/// Real global tests (image prune, system df, kernel set, etc.) will live here -/// once migrated. Delete this suite at that point. -@Suite -struct DemoGlobalTests { - @Test func globalTest1() async throws { try await runDemo() } - @Test func globalTest2() async throws { try await runDemo() } - - private func runDemo() async throws { - try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) - try await f.withContainer(image: image) { _ in - try await Task.sleep(for: .seconds(Int.random(in: 2...4))) - } - } - } -} diff --git a/Tests/IntegrationTests/Run/TestCLIRunLifecycle.swift b/Tests/IntegrationTests/Run/TestCLIRunLifecycle.swift new file mode 100644 index 000000000..78a95fa6b --- /dev/null +++ b/Tests/IntegrationTests/Run/TestCLIRunLifecycle.swift @@ -0,0 +1,163 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Concurrent lifecycle tests for `container run` / `start` / `exec`. +@Suite +struct TestCLIRunLifecycle { + @Test func testRunFailureCleanup() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + + // First attempt with an invalid user — must fail. + let failResult = try f.run([ + "run", "--rm", "--name", name, "-d", + "--user", f.testID, // f.testID won't exist in /etc/passwd + image, "sleep", "infinity", + ]) + #expect(failResult.status != 0, "expected run to fail with invalid user") + + // Second attempt with the same name and no user — must succeed. + try await f.withContainer(image: image) { containerName in + _ = try f.doExec(containerName, cmd: ["date"]) + } + } + } + + @Test func testStartIdempotent() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let result = try f.run(["start", name]) + #expect(result.status == 0, "expected start to succeed on already running container") + #expect( + result.output.trimmingCharacters(in: .whitespacesAndNewlines) == name, + "expected output to be container name") + _ = try f.inspectContainer(name) + } + } + } + + @Test func testStartIdempotentAttachFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let result = try f.run(["start", "-a", name]) + #expect( + result.status != 0, + "expected start with attach to fail on already running container") + #expect(result.error.contains("attach is currently unsupported on already running containers")) + } + } + } + + @Test func testRunInvalidExecutable() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + f.addCleanup { try f.doRemoveIfExists(name, force: true, ignoreFailure: true) } + let result = try f.run(["run", "--rm", "--name", name, "-d", image, "foobarbaz"]) + #expect(result.status != 0, "running invalid executable must fail, not hang") + } + } + + @Test func testExecInvalidExecutable() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let result = try f.run(["exec", name, "foobarbaz"]) + #expect(result.status != 0, "executing invalid executable must fail, not hang") + } + } + } + + @Test func testSSHForwarding() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + + // Create a fake SSH agent socket in the test scratch directory. + let socketDir = f.testDir.appending("ssh-agent") + try FileManager.default.createDirectory( + atPath: socketDir.string, withIntermediateDirectories: true, attributes: nil) + let socketPath = socketDir.appending("ssh-auth.sock").string + + let serverFd = socket(AF_UNIX, SOCK_STREAM, 0) + guard serverFd >= 0 else { + Issue.record("socket() failed with errno \(errno)") + return + } + defer { Darwin.close(serverFd) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutableBytes(of: &addr.sun_path) { bytes in + socketPath.withCString { cStr in + bytes.copyMemory( + from: UnsafeRawBufferPointer(start: cStr, count: socketPath.utf8.count + 1)) + } + } + let bindResult = withUnsafePointer(to: addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(serverFd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + Issue.record("bind() failed with errno \(errno) for path \(socketPath)") + return + } + guard listen(serverFd, 5) == 0 else { + Issue.record("listen() failed with errno \(errno)") + return + } + + let acceptThread = Thread { + while true { + let clientFd = accept(serverFd, nil, nil) + if clientFd < 0 { break } + Darwin.close(clientFd) + } + } + acceptThread.start() + + // Run container with --ssh; SSH_AUTH_SOCK must be in the CLI process environment. + let name = "\(f.testID)-c" + f.addCleanup { try? f.doStop(name) } + try f.run( + ["run", "--rm", "--name", name, "-d", "--ssh", image, "sleep", "infinity"], + env: ["SSH_AUTH_SOCK": socketPath] + ).check() + try await f.waitForContainerRunning(name) + + let sshSockValue = try f.doExec(name, cmd: ["sh", "-c", "echo $SSH_AUTH_SOCK"]) + #expect( + sshSockValue.trimmingCharacters(in: .whitespacesAndNewlines) + == "/var/host-services/ssh-auth.sock", + "expected SSH_AUTH_SOCK to point to guest socket path") + + let socketCheck = try f.doExec( + name, cmd: ["sh", "-c", "[ -S /var/host-services/ssh-auth.sock ] && echo exists || echo missing"]) + #expect( + socketCheck.trimmingCharacters(in: .whitespacesAndNewlines) == "exists", + "expected forwarded SSH socket to exist in container") + + try f.doStop(name) + } + } +} diff --git a/Tests/IntegrationTests/Run/TestCLIRunLifecycleSerial.swift b/Tests/IntegrationTests/Run/TestCLIRunLifecycleSerial.swift new file mode 100644 index 000000000..b63db8f0a --- /dev/null +++ b/Tests/IntegrationTests/Run/TestCLIRunLifecycleSerial.swift @@ -0,0 +1,48 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Serial lifecycle tests that require non-warmup images or exclusive port binding. +@Suite(.serialized) +struct TestCLIRunLifecycleSerial { + @Test func testStartPortBindFails() async throws { + try await ContainerFixture.with { f in + let port = UInt16.random(in: 50000..<60000) + let serverImage = "docker.io/library/python:alpine" + try f.run(["image", "pull", serverImage]).check() + + let name = "\(f.testID)-c" + try f.doCreate(name: name, ports: ["\(port)"]) + f.addCleanup { try? f.doRemove(name) } + + let server = "\(f.testID)-server" + try f.doLongRun( + name: server, + image: serverImage, + args: ["--publish", "\(port):\(port)"], + containerArgs: ["python3", "-m", "http.server", "\(port)"]) + f.addCleanup { try? f.doStop(server) } + + let startResult = try f.run(["start", name]) + #expect(startResult.status != 0, "expected start to fail when port is already bound") + + let status = try f.getContainerStatus(name) + #expect(status == "stopped") + } + } +} diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+ContainerHelpers.swift b/Tests/IntegrationTests/Utilities/ContainerFixture+ContainerHelpers.swift index 8e9b5e049..e628cd4c1 100644 --- a/Tests/IntegrationTests/Utilities/ContainerFixture+ContainerHelpers.swift +++ b/Tests/IntegrationTests/Utilities/ContainerFixture+ContainerHelpers.swift @@ -46,13 +46,16 @@ extension ContainerFixture { } /// Starts a detached container. Uses the first warmup image when `image` is nil. + /// + /// `containerEnv` injects environment variables into the container via `-e` flags. + /// To set the CLI subprocess environment (e.g. for `--ssh`), use ``run(_:env:)`` directly. func doLongRun( name: String, image: String? = nil, args: [String] = [], containerArgs: [String] = ["sleep", "infinity"], autoRemove: Bool = true, - env: [String: String] = [:] + containerEnv: [String: String] = [:] ) throws { let imageRef = image ?? ContainerFixture.warmupImages[0] var runArgs = ["run"] @@ -60,7 +63,7 @@ extension ContainerFixture { runArgs += ["--name", name, "-d"] runArgs += proxyEnvironmentArgs runArgs += args - for (k, v) in env { runArgs += ["-e", "\(k)=\(v)"] } + for (k, v) in containerEnv { runArgs += ["-e", "\(k)=\(v)"] } runArgs.append(imageRef) runArgs += containerArgs try run(runArgs).check() diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture.swift b/Tests/IntegrationTests/Utilities/ContainerFixture.swift index 1bcae34c3..a19199e94 100644 --- a/Tests/IntegrationTests/Utilities/ContainerFixture.swift +++ b/Tests/IntegrationTests/Utilities/ContainerFixture.swift @@ -15,7 +15,6 @@ //===----------------------------------------------------------------------===// import ContainerLog -import Darwin import Foundation import Logging import Synchronization @@ -75,7 +74,8 @@ 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=true`. + /// Created at fixture init; removed on cleanup unless `CLITEST_PRESERVE_SCRATCH` + /// is set in the environment. let testDir: FilePath // MARK: - Unstructured API @@ -91,20 +91,15 @@ 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 = @@ -122,7 +117,7 @@ final class ContainerFixture: Sendable { let fixture = ContainerFixture(testID: testID, testDir: testDir, log: logger) - if ProcessInfo.processInfo.environment["CLITEST_PRESERVE_SCRATCH"] != "true" { + if ProcessInfo.processInfo.environment["CLITEST_PRESERVE_SCRATCH"] == nil { fixture.addCleanup { try? FileManager.default.removeItem(atPath: testDir.string) } @@ -262,7 +257,7 @@ final class ContainerFixture: Sendable { /// Call this directly only when using ``doCreate(_:image:args:volumes:networks:ports:)`` /// and ``doStart(_:)`` — ``withContainer(image:tag:runArgs:containerArgs:autoRemove:_:)`` /// waits automatically. - func waitForContainerRunning(_ name: String, attempts: Int = 30) throws { + func waitForContainerRunning(_ name: String, attempts: Int = 30) async throws { for _ in 0..