From 72790daf45080c9702e514860c8ef980842ec5c6 Mon Sep 17 00:00:00 2001 From: John Logan Date: Fri, 26 Jun 2026 17:07:47 -0700 Subject: [PATCH 1/4] Migrates CLI create and run-lifecycle tests. --- Makefile | 11 +- .../Containers/TestCLICreate.swift | 126 ----------- .../Subcommands/Run/TestCLIRunLifecycle.swift | 209 ------------------ .../Containers/TestCLICreate.swift | 108 +++++++++ .../Run/TestCLIRunLifecycle.swift | 193 ++++++++++++++++ .../ContainerFixture+ContainerHelpers.swift | 7 +- 6 files changed, 313 insertions(+), 341 deletions(-) delete mode 100644 Tests/CLITests/Subcommands/Containers/TestCLICreate.swift delete mode 100644 Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift create mode 100644 Tests/IntegrationTests/Containers/TestCLICreate.swift create mode 100644 Tests/IntegrationTests/Run/TestCLIRunLifecycle.swift diff --git a/Makefile b/Makefile index c357685dc..0aa668bdd 100644 --- a/Makefile +++ b/Makefile @@ -204,10 +204,15 @@ WARMUP_FILTER = ImageWarmup CONCURRENT_TEST_SUITES ?= \ TestCLIStop \ TestCLIRmRaceCondition \ - TestCLIExportCommand + TestCLIExportCommand \ + TestCLICreateCommand \ + TestCLIRunLifecycle CONCURRENT_FILTER = $(subst $(space),|,$(strip $(CONCURRENT_TEST_SUITES))) -GLOBAL_FILTER = DemoGlobalTests +GLOBAL_TEST_SUITES ?= \ + DemoGlobalTests \ + TestCLIRunLifecycleSerial +GLOBAL_FILTER = $(subst $(space),|,$(strip $(GLOBAL_TEST_SUITES))) INTEGRATION_SWIFT_EXTRA ?= INTEGRATION_POST_TEST ?= @@ -262,10 +267,8 @@ INTEGRATION_TEST_SUITES ?= \ TestCLIStatus \ TestCLIVersion \ TestCLINetwork \ - TestCLIRunLifecycle \ TestCLIRunCapabilities \ TestCLIExecCommand \ - TestCLICreateCommand \ TestCLIRunCommand1 \ TestCLIRunCommand2 \ TestCLIRunCommand3 \ 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 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/Containers/TestCLICreate.swift b/Tests/IntegrationTests/Containers/TestCLICreate.swift new file mode 100644 index 000000000..ecbe012a0 --- /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 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 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/Run/TestCLIRunLifecycle.swift b/Tests/IntegrationTests/Run/TestCLIRunLifecycle.swift new file mode 100644 index 000000000..c5181c6f9 --- /dev/null +++ b/Tests/IntegrationTests/Run/TestCLIRunLifecycle.swift @@ -0,0 +1,193 @@ +//===----------------------------------------------------------------------===// +// 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 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) + } + } +} + +/// 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() From 46d9e330162ba465cc6544c8fe3b0330883a54eb Mon Sep 17 00:00:00 2001 From: John Logan Date: Fri, 26 Jun 2026 17:36:51 -0700 Subject: [PATCH 2/4] Migrates CLI create, exec, remove tests. --- Makefile | 11 +- .../Subcommands/Containers/TestCLIExec.swift | 140 ----------------- .../Containers/TestCLIRemove.swift | 141 ----------------- .../Containers/TestCLICreate.swift | 4 +- .../Containers/TestCLIExec.swift | 115 ++++++++++++++ .../Containers/TestCLIRemove.swift | 147 ++++++++++++++++++ .../Containers/TestCLIRmRaceCondition.swift | 2 +- .../Demo/DemoGlobalTests.swift | 41 ----- .../Run/TestCLIRunLifecycle.swift | 2 +- .../Utilities/ContainerFixture.swift | 6 +- 10 files changed, 275 insertions(+), 334 deletions(-) delete mode 100644 Tests/CLITests/Subcommands/Containers/TestCLIExec.swift delete mode 100644 Tests/CLITests/Subcommands/Containers/TestCLIRemove.swift create mode 100644 Tests/IntegrationTests/Containers/TestCLIExec.swift create mode 100644 Tests/IntegrationTests/Containers/TestCLIRemove.swift delete mode 100644 Tests/IntegrationTests/Demo/DemoGlobalTests.swift diff --git a/Makefile b/Makefile index 0aa668bdd..42482f0f0 100644 --- a/Makefile +++ b/Makefile @@ -206,12 +206,14 @@ CONCURRENT_TEST_SUITES ?= \ TestCLIRmRaceCondition \ TestCLIExportCommand \ TestCLICreateCommand \ - TestCLIRunLifecycle + TestCLIRunLifecycle \ + TestCLIRemove \ + TestCLIExecCommand CONCURRENT_FILTER = $(subst $(space),|,$(strip $(CONCURRENT_TEST_SUITES))) GLOBAL_TEST_SUITES ?= \ - DemoGlobalTests \ - TestCLIRunLifecycleSerial + TestCLIRunLifecycleSerial \ + TestCLIRemoveSerial GLOBAL_FILTER = $(subst $(space),|,$(strip $(GLOBAL_TEST_SUITES))) INTEGRATION_SWIFT_EXTRA ?= @@ -242,7 +244,7 @@ define RUN_INTEGRATION echo "==> Concurrent pass (width=$(PARALLEL_WIDTH))" && \ $(SWIFT) test $(INTEGRATION_SWIFT_EXTRA) -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --experimental-maximum-parallelization-width $(PARALLEL_WIDTH) --filter "$(CONCURRENT_FILTER)" && \ echo "==> Global pass (serial)" && \ - $(SWIFT) test $(INTEGRATION_SWIFT_EXTRA) -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter "$(GLOBAL_FILTER)" ; \ + $(SWIFT) test $(INTEGRATION_SWIFT_EXTRA) -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --experimental-maximum-parallelization-width 1 --filter "$(GLOBAL_FILTER)" ; \ exit_code=$$? ; \ $(INTEGRATION_POST_TEST) \ echo Ensuring apiserver stopped after the CLI integration tests ; \ @@ -268,7 +270,6 @@ INTEGRATION_TEST_SUITES ?= \ TestCLIVersion \ TestCLINetwork \ TestCLIRunCapabilities \ - TestCLIExecCommand \ TestCLIRunCommand1 \ TestCLIRunCommand2 \ TestCLIRunCommand3 \ diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift b/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift deleted file mode 100644 index 0ec93558d..000000000 --- a/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift +++ /dev/null @@ -1,140 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025-2026 Apple Inc. and the container project authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -//===----------------------------------------------------------------------===// - -import Foundation -import Testing - -@Suite(.serialSuites) -class TestCLIExecCommand: CLITest { - private func getTestName() -> 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/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/IntegrationTests/Containers/TestCLICreate.swift b/Tests/IntegrationTests/Containers/TestCLICreate.swift index ecbe012a0..897cb2d26 100644 --- a/Tests/IntegrationTests/Containers/TestCLICreate.swift +++ b/Tests/IntegrationTests/Containers/TestCLICreate.swift @@ -38,7 +38,7 @@ struct TestCLICreateCommand { try f.doCreate(name: name, image: image, networks: ["default,mac=\(expectedMAC)"]) f.addCleanup { try? f.doStop(name) } try f.doStart(name) - try f.waitForContainerRunning(name) + try await f.waitForContainerRunning(name) let inspect = try f.inspectContainer(name) #expect(inspect.networks.count > 0, "expected at least one network attachment") @@ -91,7 +91,7 @@ struct TestCLICreateCommand { try f.doCreate(name: name, image: image) f.addCleanup { try? f.doStop(name) } try f.doStart(name) - try f.waitForContainerRunning(name) + try await f.waitForContainerRunning(name) let inspect = try f.inspectContainer(name) let attachmentHostname = inspect.networks.first?.hostname ?? "" 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/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/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 index c5181c6f9..ea38304fc 100644 --- a/Tests/IntegrationTests/Run/TestCLIRunLifecycle.swift +++ b/Tests/IntegrationTests/Run/TestCLIRunLifecycle.swift @@ -143,7 +143,7 @@ struct TestCLIRunLifecycle { ["run", "--rm", "--name", name, "-d", "--ssh", image, "sleep", "infinity"], env: ["SSH_AUTH_SOCK": socketPath] ).check() - try f.waitForContainerRunning(name) + try await f.waitForContainerRunning(name) let sshSockValue = try f.doExec(name, cmd: ["sh", "-c", "echo $SSH_AUTH_SOCK"]) #expect( diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture.swift b/Tests/IntegrationTests/Utilities/ContainerFixture.swift index 151d8db14..5413ab017 100644 --- a/Tests/IntegrationTests/Utilities/ContainerFixture.swift +++ b/Tests/IntegrationTests/Utilities/ContainerFixture.swift @@ -238,7 +238,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.. Date: Fri, 26 Jun 2026 18:22:02 -0700 Subject: [PATCH 3/4] Migrate container prune and stats. - Append slash to test include specs to prevent prefix matching of Foo against both Foo and Foo*. --- Makefile | 24 +- .../Subcommands/Containers/TestCLIPrune.swift | 88 ------- .../Subcommands/Containers/TestCLIStats.swift | 219 ------------------ .../TestCLIPruneCommandSerial.swift | 78 +++++++ .../Containers/TestCLIStats.swift | 112 +++++++++ 5 files changed, 202 insertions(+), 319 deletions(-) delete mode 100644 Tests/CLITests/Subcommands/Containers/TestCLIPrune.swift delete mode 100644 Tests/CLITests/Subcommands/Containers/TestCLIStats.swift create mode 100644 Tests/IntegrationTests/Containers/TestCLIPruneCommandSerial.swift create mode 100644 Tests/IntegrationTests/Containers/TestCLIStats.swift diff --git a/Makefile b/Makefile index 42482f0f0..aa2b4557d 100644 --- a/Makefile +++ b/Makefile @@ -199,21 +199,23 @@ 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 +WARMUP_FILTER = ImageWarmup/ CONCURRENT_TEST_SUITES ?= \ - TestCLIStop \ - TestCLIRmRaceCondition \ - TestCLIExportCommand \ - TestCLICreateCommand \ - TestCLIRunLifecycle \ - TestCLIRemove \ - TestCLIExecCommand + TestCLIStop/ \ + TestCLIRmRaceCondition/ \ + TestCLIExportCommand/ \ + TestCLICreateCommand/ \ + TestCLIRunLifecycle/ \ + TestCLIRemove/ \ + TestCLIExecCommand/ \ + TestCLIStatsCommand/ CONCURRENT_FILTER = $(subst $(space),|,$(strip $(CONCURRENT_TEST_SUITES))) GLOBAL_TEST_SUITES ?= \ - TestCLIRunLifecycleSerial \ - TestCLIRemoveSerial + TestCLIRunLifecycleSerial/ \ + TestCLIRemoveSerial/ \ + TestCLIPruneCommandSerial/ GLOBAL_FILTER = $(subst $(space),|,$(strip $(GLOBAL_TEST_SUITES))) INTEGRATION_SWIFT_EXTRA ?= @@ -273,9 +275,7 @@ INTEGRATION_TEST_SUITES ?= \ TestCLIRunCommand1 \ TestCLIRunCommand2 \ TestCLIRunCommand3 \ - TestCLIPruneCommand \ TestCLIRegistry \ - TestCLIStatsCommand \ TestCLIImagesCommand \ TestCLIRunBase \ TestCLIRunInitImage \ 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/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/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/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") + } + } +} From 0ff39eefa3537dd23d4defe7f9ea4083b77fc1e1 Mon Sep 17 00:00:00 2001 From: John Logan Date: Fri, 26 Jun 2026 18:31:00 -0700 Subject: [PATCH 4/4] Migrate container copy tests. --- Makefile | 6 +- .../Subcommands/Containers/TestCLICopy.swift | 958 ------------------ .../Containers/TestCLICopy.swift | 546 ++++++++++ 3 files changed, 549 insertions(+), 961 deletions(-) delete mode 100644 Tests/CLITests/Subcommands/Containers/TestCLICopy.swift create mode 100644 Tests/IntegrationTests/Containers/TestCLICopy.swift diff --git a/Makefile b/Makefile index aa2b4557d..ed6e2584f 100644 --- a/Makefile +++ b/Makefile @@ -209,7 +209,8 @@ CONCURRENT_TEST_SUITES ?= \ TestCLIRunLifecycle/ \ TestCLIRemove/ \ TestCLIExecCommand/ \ - TestCLIStatsCommand/ + TestCLIStatsCommand/ \ + TestCLICopyCommand/ CONCURRENT_FILTER = $(subst $(space),|,$(strip $(CONCURRENT_TEST_SUITES))) GLOBAL_TEST_SUITES ?= \ @@ -287,8 +288,7 @@ INTEGRATION_TEST_SUITES ?= \ TestCLISystemDF \ TestCLIMachineCommand \ TestCLIMachineRuntime \ - TestCLINoParallelCases \ - TestCLICopyCommand + TestCLINoParallelCases 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/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) + } + } + } +}