diff --git a/Makefile b/Makefile index 3d65dd2c..c357685d 100644 --- a/Makefile +++ b/Makefile @@ -200,19 +200,32 @@ endef # the three phases. Expand the filter lists as suites are migrated from CLITests. PARALLEL_WIDTH ?= 2 WARMUP_FILTER = ImageWarmup -CONCURRENT_FILTER = DemoConcurrentTests + +CONCURRENT_TEST_SUITES ?= \ + TestCLIStop \ + TestCLIRmRaceCondition \ + TestCLIExportCommand +CONCURRENT_FILTER = $(subst $(space),|,$(strip $(CONCURRENT_TEST_SUITES))) + GLOBAL_FILTER = DemoGlobalTests INTEGRATION_SWIFT_EXTRA ?= INTEGRATION_POST_TEST ?= +PRESERVE_KERNELS ?= false + define RUN_INTEGRATION @echo Ensuring apiserver stopped before the CLI integration tests... @bin/container system stop && sleep 3 && scripts/ensure-container-stopped.sh @if [ -n "$(APP_ROOT)" ]; then \ - echo "Clearing application data under $(APP_ROOT) (preserving kernels)..." ; \ mkdir -p $(APP_ROOT) ; \ - find "$(APP_ROOT)" -mindepth 1 -maxdepth 1 ! -name kernels -exec rm -rf {} + ; \ + if [ "$(PRESERVE_KERNELS)" = "true" ]; then \ + echo "Clearing application data under $(APP_ROOT) (preserving kernels)..." ; \ + find "$(APP_ROOT)" -mindepth 1 -maxdepth 1 ! -name kernels -exec rm -rf {} + ; \ + else \ + echo "Clearing application data under $(APP_ROOT)..." ; \ + find "$(APP_ROOT)" -mindepth 1 -maxdepth 1 -exec rm -rf {} + ; \ + fi ; \ fi @echo Running the integration tests... @bin/container --debug system start --timeout 60 --enable-kernel-install $(SYSTEM_START_OPTS) && \ @@ -263,7 +276,6 @@ INTEGRATION_TEST_SUITES ?= \ TestCLIRunBase \ TestCLIRunInitImage \ TestCLIBuildBase \ - TestCLIExportCommand \ TestCLIVolumes \ TestCLIKernelSet \ TestCLIAnonymousVolumes \ diff --git a/Package.swift b/Package.swift index 635f6e32..59d94a1d 100644 --- a/Package.swift +++ b/Package.swift @@ -85,7 +85,10 @@ let package = Package( dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "SystemPackage", package: "swift-system"), + .product(name: "ContainerizationArchive", package: "containerization"), + .product(name: "ContainerizationExtras", package: "containerization"), "ContainerLog", + "ContainerResource", "Yams", ], path: "Tests/IntegrationTests" diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIExport.swift b/Tests/CLITests/Subcommands/Containers/TestCLIExport.swift deleted file mode 100644 index 4f939b41..00000000 --- a/Tests/CLITests/Subcommands/Containers/TestCLIExport.swift +++ /dev/null @@ -1,68 +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 ContainerizationArchive -import Foundation -import Testing - -@Suite(.serialSuites) -class TestCLIExportCommand: CLITest { - private func getTestName() -> String { - Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() - } - - @Test func testExportCommand() throws { - let name = getTestName() - try doLongRun(name: name, autoRemove: false) - defer { - try? doStop(name: name) - try? doRemove(name: name) - } - - let mustBeInImage = "must-be-in-image" - _ = try doExec(name: name, cmd: ["sh", "-c", "echo \(mustBeInImage) > /foo"]) - - _ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /parent/child"]) - let hardlinkMustRemain = "hardlink-must-remain" - _ = try doExec(name: name, cmd: ["sh", "-c", "echo \(hardlinkMustRemain) > /parent/child/bar"]) - _ = try doExec(name: name, cmd: ["sh", "-c", "ln /parent/child/bar /bar"]) - - let symlinkMustRemain = "symlink-must-remain" - _ = try doExec(name: name, cmd: ["sh", "-c", "echo \(symlinkMustRemain) > /parent/child/baz"]) - _ = try doExec(name: name, cmd: ["sh", "-c", "ln /parent/child/baz /baz"]) - - try doStop(name: name) - - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: tempDir) - } - let tempFile = tempDir.appendingPathComponent(UUID().uuidString) - - try doExport(name: name, filepath: tempFile.path()) - - let attrs = try FileManager.default.attributesOfItem(atPath: tempFile.path()) - let fileSize = attrs[.size] as! UInt64 - #expect(fileSize > 0) - - // TODO: verify foo bar baz are in tar file. - let reader = try ArchiveReader(file: tempFile) - let (foo, fooData) = try reader.extractFile(path: "/foo") - #expect(foo.fileType == .regular) - #expect(String(data: fooData, encoding: .utf8)?.starts(with: mustBeInImage) ?? false) - } -} diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIRmRace.swift b/Tests/CLITests/Subcommands/Containers/TestCLIRmRace.swift deleted file mode 100644 index b189c4d2..00000000 --- a/Tests/CLITests/Subcommands/Containers/TestCLIRmRace.swift +++ /dev/null @@ -1,141 +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 - -class TestCLIRmRaceCondition: CLITest { - - /// Helper method to check if a container exists - private func containerExists(_ name: String) -> Bool { - do { - _ = try getContainerStatus(name) - return true - } catch { - return false - } - } - - /// Safe container removal that handles already-removed containers gracefully - private func safeRemove(name: String, force: Bool = false) throws { - guard containerExists(name) else { - // Container already removed, nothing to do - return - } - try doRemove(name: name, force: force) - } - - private func getTestName() -> String { - Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() - } - - @Test func testStopRmRace() async throws { - let name = getTestName() - - do { - // Create and start a container in detached mode that runs indefinitely - try doCreate(name: name, args: ["sleep", "infinity"]) - try doStart(name: name) - - // Wait for container to be running - try waitForContainerRunning(name) - - // Call doStop - this should return immediately without waiting - try doStop(name: name) - - // Immediately call doRemove and handle both possible outcomes: - // 1. Container removal succeeds immediately (race condition fixed) - // 2. Container removal fails because it's still stopping (race condition detected) - var raceConditionPrevented = false - var raceConditionDetected = false - - do { - try doRemove(name: name) - // Success: The race condition prevention is working perfectly! - // Container was removed cleanly without any race condition - raceConditionPrevented = true - } catch CLITest.CLIError.executionFailed(let message) { - if message.contains("is not yet stopped and can not be deleted") { - // Expected behavior: Race condition detected and prevented - raceConditionDetected = true - } else if message.contains("not found") || message.contains("failed to delete one or more containers") { - // Container was already removed by background cleanup - this is also success! - raceConditionPrevented = true - } else { - Issue.record("Unexpected error message: \(message)") - return - } - } catch { - Issue.record("Unexpected error type: \(error)") - return - } - - // Either outcome is acceptable - both indicate the race condition fix is working - #expect( - raceConditionPrevented || raceConditionDetected, - "Expected either immediate success (race prevented) or controlled failure (race detected)") - - // If the container was already removed, we're done - if raceConditionPrevented { - return - } - - // If we detected a race condition, wait for cleanup and retry removal - #expect(raceConditionDetected, "Should have detected race condition if we reach this point") - - // Give the background cleanup a moment to finish - try await Task.sleep(for: .seconds(2)) - - // Retry removal with exponential backoff for cleanup - var removeAttempts = 0 - let maxRemoveAttempts = 5 - let baseDelay = 1.0 // seconds - - while removeAttempts < maxRemoveAttempts { - do { - try safeRemove(name: name) - break - } catch CLITest.CLIError.executionFailed(let message) { - // If container doesn't exist, we're done - if message.contains("not found") { - break - } - - guard removeAttempts < maxRemoveAttempts - 1 else { - throw CLITest.CLIError.executionFailed("Failed to remove container after \(maxRemoveAttempts) attempts: \(message)") - } - - let delay = baseDelay * pow(2.0, Double(removeAttempts)) - try await Task.sleep(for: .seconds(delay)) - removeAttempts += 1 - } catch { - guard removeAttempts < maxRemoveAttempts - 1 else { - throw error - } - let delay = baseDelay * pow(2.0, Double(removeAttempts)) - try await Task.sleep(for: .seconds(delay)) - removeAttempts += 1 - } - } - - } catch { - Issue.record("failed to test stop-rm race condition: \(error)") - // Safe cleanup - only try to remove if container actually exists - try? safeRemove(name: name, force: true) - return - } - } -} diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIStop.swift b/Tests/CLITests/Subcommands/Containers/TestCLIStop.swift deleted file mode 100644 index 0f58624b..00000000 --- a/Tests/CLITests/Subcommands/Containers/TestCLIStop.swift +++ /dev/null @@ -1,75 +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 TestCLIStop: CLITest { - private func getTestName() -> String { - Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() - } - - @Test func testStopWithExplicitSignal() throws { - let name = getTestName() - try doLongRun(name: name) - defer { try? doStop(name: name) } - - try waitForContainerRunning(name) - - try doStop(name: name, signal: "SIGTERM") - let status = try getContainerStatus(name) - #expect(status == "stopped") - } - - @Test func testStopWithoutSignal() throws { - let name = getTestName() - try doLongRun(name: name) - defer { try? doStop(name: name) } - - try waitForContainerRunning(name) - - try doStop(name: name, signal: nil) - let status = try getContainerStatus(name) - #expect(status == "stopped") - } - - @Test func testStopSignalInInspect() throws { - let name = getTestName() - try doLongRun(name: name) - defer { try? doStop(name: name) } - - try waitForContainerRunning(name) - - let inspect = try inspectContainer(name) - // Alpine doesn't set a STOPSIGNAL, so this should be nil. - #expect(inspect.configuration.stopSignal == nil) - } - - @Test func testStopIdempotent() throws { - let name = getTestName() - try doLongRun(name: name) - defer { try? doStop(name: name) } - - try waitForContainerRunning(name) - - try doStop(name: name, signal: "SIGKILL") - let status = try getContainerStatus(name) - #expect(status == "stopped") - - // Stopping an already stopped container should not fail. - try doStop(name: name, signal: "SIGKILL") - } -} diff --git a/Tests/IntegrationTests/Containers/TestCLIExportCommand.swift b/Tests/IntegrationTests/Containers/TestCLIExportCommand.swift new file mode 100644 index 00000000..b3c3ec29 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIExportCommand.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationArchive +import Foundation +import Testing + +@Suite +struct TestCLIExportCommand { + @Test func testExportCommand() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image, autoRemove: false) { name in + let mustBeInImage = "must-be-in-image" + try f.doExec(name, cmd: ["sh", "-c", "echo \(mustBeInImage) > /foo"]) + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /parent/child"]) + let hardlinkMustRemain = "hardlink-must-remain" + try f.doExec(name, cmd: ["sh", "-c", "echo \(hardlinkMustRemain) > /parent/child/bar"]) + try f.doExec(name, cmd: ["sh", "-c", "ln /parent/child/bar /bar"]) + let symlinkMustRemain = "symlink-must-remain" + try f.doExec(name, cmd: ["sh", "-c", "echo \(symlinkMustRemain) > /parent/child/baz"]) + try f.doExec(name, cmd: ["sh", "-c", "ln /parent/child/baz /baz"]) + + try f.doStop(name) + + let exportPath = f.testDir.appending("export.tar") + try f.doExport(name, to: exportPath) + + let exportURL = URL(filePath: exportPath.string) + let attrs = try FileManager.default.attributesOfItem(atPath: exportPath.string) + let fileSize = attrs[.size] as! UInt64 + #expect(fileSize > 0) + + // TODO: verify foo bar baz are in tar file. + let reader = try ArchiveReader(file: exportURL) + let (foo, fooData) = try reader.extractFile(path: "/foo") + #expect(foo.fileType == .regular) + #expect(String(data: fooData, encoding: .utf8)?.starts(with: mustBeInImage) ?? false) + } + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLIRmRaceCondition.swift b/Tests/IntegrationTests/Containers/TestCLIRmRaceCondition.swift new file mode 100644 index 00000000..e465ed76 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIRmRaceCondition.swift @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// 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 TestCLIRmRaceCondition { + @Test func testStopRmRace() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-c" + f.addCleanup { try f.doRemoveIfExists(name, force: true, ignoreFailure: true) } + + try f.doCreate(name: name) + try f.doStart(name) + try f.waitForContainerRunning(name) + try f.doStop(name) + + // Immediately attempt removal — both outcomes are valid: + // 1. Success: race condition prevention working perfectly + // 2. "not yet stopped" error: race detected and controlled + var raceConditionPrevented = false + var raceConditionDetected = false + + do { + try f.doRemove(name) + raceConditionPrevented = true + } catch CommandError.nonZeroExit(_, let message) { + if message.contains("is not yet stopped and can not be deleted") { + raceConditionDetected = true + } else if message.contains("not found") + || message.contains("failed to delete one or more containers") + { + raceConditionPrevented = true + } else { + Issue.record("Unexpected error message: \(message)") + return + } + } catch { + Issue.record("Unexpected error type: \(error)") + return + } + + #expect( + raceConditionPrevented || raceConditionDetected, + "Expected either immediate success (race prevented) or controlled failure (race detected)" + ) + + if raceConditionPrevented { return } + + // Race detected — wait for background cleanup then retry with backoff. + try await Task.sleep(for: .seconds(2)) + + var attempts = 0 + let maxAttempts = 5 + while attempts < maxAttempts { + guard (try? f.getContainerStatus(name)) != nil else { break } + do { + try f.doRemove(name) + break + } catch CommandError.nonZeroExit(_, let message) { + if message.contains("not found") { break } + guard attempts < maxAttempts - 1 else { + throw CommandError.executionFailed( + "Failed to remove container after \(maxAttempts) attempts: \(message)") + } + let delay = 1 << attempts + try await Task.sleep(for: .seconds(delay)) + attempts += 1 + } catch { + guard attempts < maxAttempts - 1 else { throw error } + let delay = 1 << attempts + try await Task.sleep(for: .seconds(delay)) + attempts += 1 + } + } + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLIStop.swift b/Tests/IntegrationTests/Containers/TestCLIStop.swift new file mode 100644 index 00000000..786589c1 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIStop.swift @@ -0,0 +1,63 @@ +//===----------------------------------------------------------------------===// +// 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 TestCLIStop { + @Test func testStopWithExplicitSignal() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image, autoRemove: false) { name in + try f.doStop(name, signal: "SIGTERM") + #expect(try f.getContainerStatus(name) == "stopped") + } + } + } + + @Test func testStopWithoutSignal() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image, autoRemove: false) { name in + try f.doStop(name, signal: nil) + #expect(try f.getContainerStatus(name) == "stopped") + } + } + } + + @Test func testStopSignalInInspect() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image, autoRemove: false) { name in + let inspect = try f.inspectContainer(name) + // Alpine doesn't set a STOPSIGNAL, so this should be nil. + #expect(inspect.configuration.stopSignal == nil) + } + } + } + + @Test func testStopIdempotent() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image, autoRemove: false) { name in + try f.doStop(name, signal: "SIGKILL") + #expect(try f.getContainerStatus(name) == "stopped") + // Stopping an already-stopped container should not fail. + try f.doStop(name, signal: "SIGKILL") + } + } + } +} diff --git a/Tests/IntegrationTests/Demo/DemoConcurrentTests.swift b/Tests/IntegrationTests/Demo/DemoConcurrentTests.swift deleted file mode 100644 index 5208b1f2..00000000 --- a/Tests/IntegrationTests/Demo/DemoConcurrentTests.swift +++ /dev/null @@ -1,47 +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 concurrent test pass. -/// -/// These eight tests run under ``--experimental-maximum-parallelization-width`` -/// to show bounded parallelism. Each test starts an isolated container (name -/// scoped to its ``ContainerFixture/testID``) and sleeps for a random interval, -/// so the total wall-clock time should be roughly max(individual durations) -/// rather than their sum. -/// -/// Delete this suite once real tests have been migrated to ``IntegrationTests``. -@Suite -struct DemoConcurrentTests { - @Test func test1() async throws { try await runDemo() } - @Test func test2() async throws { try await runDemo() } - @Test func test3() async throws { try await runDemo() } - @Test func test4() async throws { try await runDemo() } - @Test func test5() async throws { try await runDemo() } - @Test func test6() async throws { try await runDemo() } - @Test func test7() async throws { try await runDemo() } - @Test func test8() 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/Utilities/ContainerFixture+ContainerHelpers.swift b/Tests/IntegrationTests/Utilities/ContainerFixture+ContainerHelpers.swift new file mode 100644 index 00000000..8e9b5e04 --- /dev/null +++ b/Tests/IntegrationTests/Utilities/ContainerFixture+ContainerHelpers.swift @@ -0,0 +1,172 @@ +//===----------------------------------------------------------------------===// +// 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 SystemPackage + +// MARK: - Inspect types + +extension ContainerFixture { + /// Decoded output of `container inspect `. + struct InspectOutput: Codable { + struct Status: Codable { + let state: String + let networks: [ContainerResource.Attachment] + } + let configuration: ContainerConfiguration + let status: Status + var networks: [ContainerResource.Attachment] { status.networks } + } +} + +// MARK: - Container lifecycle helpers + +extension ContainerFixture { + + /// `-e` flags forwarding proxy env vars into container commands. + var proxyEnvironmentArgs: [String] { + let vars = Set(["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"]) + return ProcessInfo.processInfo.environment + .filter { vars.contains($0.key) } + .flatMap { ["-e", "\($0.key)=\($0.value)"] } + } + + /// Starts a detached container. Uses the first warmup image when `image` is nil. + func doLongRun( + name: String, + image: String? = nil, + args: [String] = [], + containerArgs: [String] = ["sleep", "infinity"], + autoRemove: Bool = true, + env: [String: String] = [:] + ) throws { + let imageRef = image ?? ContainerFixture.warmupImages[0] + var runArgs = ["run"] + if autoRemove { runArgs.append("--rm") } + runArgs += ["--name", name, "-d"] + runArgs += proxyEnvironmentArgs + runArgs += args + for (k, v) in env { runArgs += ["-e", "\(k)=\(v)"] } + runArgs.append(imageRef) + runArgs += containerArgs + try run(runArgs).check() + } + + /// Creates a stopped container (`container create`). + func doCreate( + name: String, + image: String? = nil, + args: [String] = ["sleep", "infinity"], + volumes: [String] = [], + networks: [String] = [], + ports: [String] = [] + ) throws { + let imageRef = image ?? ContainerFixture.warmupImages[0] + var createArgs = ["create", "--rm", "--name", name] + createArgs += proxyEnvironmentArgs + for v in volumes { createArgs += ["-v", v] } + for n in networks { createArgs += ["--network", n] } + for p in ports { createArgs += ["--publish", "\(p):\(p)"] } + createArgs.append(imageRef) + createArgs += args + try run(createArgs).check() + } + + /// Starts a stopped container. + func doStart(_ name: String) throws { + try run(["start", name]).check() + } + + /// Stops a container. Pass `signal: nil` to use the server's default. + func doStop(_ name: String, signal: String? = "SIGKILL") throws { + var args = ["stop"] + if let signal { args += ["-s", signal] } + args.append(name) + try run(args).check() + } + + /// Deletes a container. + func doRemove(_ name: String, force: Bool = false) throws { + var args = ["delete"] + if force { args.append("--force") } + args.append(name) + try run(args).check() + } + + /// Deletes a container. + /// + /// When `ignoreFailure` is `false` (default) any error is rethrown — use + /// this when the container is expected to exist and removal must succeed. + /// Set `ignoreFailure: true` in cleanup contexts where best-effort removal + /// is acceptable (e.g. the container may have already been removed). + func doRemoveIfExists(_ name: String, force: Bool = false, ignoreFailure: Bool = false) throws { + do { + try doRemove(name, force: force) + } catch { + if !ignoreFailure { throw error } + } + } + + /// Runs a command inside a container, returns stdout. Throws on non-zero exit. + @discardableResult + func doExec( + _ name: String, + cmd: [String], + detach: Bool = false, + user: String? = nil + ) throws -> String { + var args = ["exec"] + args += proxyEnvironmentArgs + if detach { args.append("-d") } + if let user { args += ["-u", user] } + args.append(name) + args += cmd + return try run(args).check().output + } + + /// Exports a container filesystem to a tar archive at `path`. + func doExport(_ name: String, to path: FilePath) throws { + try run(["export", name, "-o", path.string]).check() + } +} + +// MARK: - Inspect helpers + +extension ContainerFixture { + + /// Returns the parsed inspect output for a container. + func inspectContainer(_ name: String) throws -> InspectOutput { + let result = try run(["inspect", name]).check() + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let outputs = try decoder.decode([InspectOutput].self, from: result.outputData) + guard let first = outputs.first else { + throw CommandError.executionFailed("container '\(name)' not found in inspect output") + } + return first + } + + /// Returns the `status.state` string for a container (e.g. `"running"`, `"stopped"`). + func getContainerStatus(_ name: String) throws -> String { + try inspectContainer(name).status.state + } + + /// Returns the `configuration.id` for a container. + func getContainerId(_ name: String) throws -> String { + try inspectContainer(name).configuration.id + } +} diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture.swift b/Tests/IntegrationTests/Utilities/ContainerFixture.swift index ead6b006..151d8db1 100644 --- a/Tests/IntegrationTests/Utilities/ContainerFixture.swift +++ b/Tests/IntegrationTests/Utilities/ContainerFixture.swift @@ -21,23 +21,46 @@ import Synchronization import SystemPackage import Testing -/// Per-test fixture providing CLI execution, resource lifecycle, and cleanup. +/// Per-test fixture for CLI integration tests. /// -/// Each test gets an isolated instance via ``ContainerFixture/with(_:)``. All -/// resources (containers, networks, volumes, images, scratch files) created -/// through the fixture are tracked and torn down automatically when the scope -/// exits — whether the test passes, fails, or throws. +/// Open a fixture scope with ``ContainerFixture/with(_:)``. Every resource +/// created during the scope is tracked and torn down on exit — whether the +/// test passes, fails, or throws. /// -/// Tier 1 — unstructured: call ``addCleanup(_:)`` to register any async -/// closure. Closures run LIFO on scope exit. +/// ## Unstructured API (Tier 1) /// -/// Tier 2 — structured: helpers like ``withContainer(image:tag:runArgs:containerArgs:_:)`` -/// register cleanup on your behalf and express resource lifetime as a scope. +/// Primitives that execute commands or register cleanup without enforcing +/// a scope boundary. The caller owns the resource lifetime. +/// +/// - ``run(_:stdin:currentDirectory:env:)`` runs the CLI and returns a +/// ``CommandResult``; call ``CommandResult/check(_:)`` to assert success. +/// - ``addCleanup(_:)`` registers an async closure that runs LIFO on scope exit. +/// - ``copyWarmupImage(_:)`` tags a pre-warmed image to a test-local name and +/// auto-registers its removal. +/// - ``waitForContainerRunning(_:attempts:)`` polls until a container is +/// `running`; required when using lower-level create/start helpers directly. +/// +/// ## Structured API (Tier 2) +/// +/// Scoped helpers that manage resource lifetime via a closure boundary. +/// Resources are torn down when the closure exits regardless of whether it +/// throws. +/// +/// - ``withContainer(image:tag:runArgs:containerArgs:autoRemove:_:)`` starts a +/// detached container, waits for `running`, calls the body, then stops (and +/// optionally deletes) it on exit. +/// +/// ## Choosing a tier +/// +/// Prefer Tier 2 for common patterns — it eliminates cleanup boilerplate and +/// prevents leaks. Drop to Tier 1 when a test exercises a specific +/// create/start/stop sequence, needs low-level control, or uses a resource +/// pattern the structured helpers don't cover. final class ContainerFixture: Sendable { - // MARK: - Well-known images + // MARK: - Configuration - /// Images preloaded by the ImageWarmup suite before concurrent tests run. + /// Images preloaded by the ``ImageWarmup`` suite before concurrent tests run. /// Add new commonly-used images here; the warmup pass pulls them in parallel. static let warmupImages: [String] = [ "ghcr.io/linuxcontainers/alpine:3.20", @@ -45,27 +68,17 @@ final class ContainerFixture: Sendable { "ghcr.io/containerd/busybox:1.36", ] - // MARK: - Per-instance state + // MARK: - State /// Short random identifier prefixed to every resource this test creates. let testID: String /// Scratch directory for build inputs, test data, and command output. - /// Created at fixture init; removed on cleanup unless ``CLITEST_PRESERVE_SCRATCH`` + /// Created at fixture init; removed on cleanup unless `CLITEST_PRESERVE_SCRATCH` /// is set in the environment. let testDir: FilePath - private let log: Logger - private let cleanupTasks: Mutex<[@Sendable () async throws -> Void]> = .init([]) - private static let commandSeq: Mutex = .init(0) - - // MARK: - Lifecycle - - private init(testID: String, testDir: FilePath, log: Logger) { - self.testID = testID - self.testDir = testDir - self.log = log - } + // MARK: - Unstructured API /// Runs `body` with a fresh fixture, then tears down all registered resources. /// @@ -126,36 +139,6 @@ final class ContainerFixture: Sendable { cleanupTasks.withLock { $0.append(task) } } - private func runCleanup() async { - let tasks = cleanupTasks.withLock { tasks -> [@Sendable () async throws -> Void] in - let reversed = Array(tasks.reversed()) - tasks.removeAll() - return reversed - } - for task in tasks { - try? await task() - } - } - - // MARK: - CLI execution - - private var executableURL: URL { - get throws { - let path: FilePath - if let env = ProcessInfo.processInfo.environment["CONTAINER_CLI_PATH"] { - path = FilePath(env) - } else { - let candidate = FilePath(FileManager.default.currentDirectoryPath) - .appending("bin").appending("container") - guard FileManager.default.fileExists(atPath: candidate.string) else { - throw CommandError.binaryNotFound - } - path = candidate - } - return URL(filePath: path.string) - } - } - /// Runs the container CLI with the given arguments and returns the result. /// /// Throws ``CommandError`` only for execution failures (binary not found, @@ -223,10 +206,7 @@ final class ContainerFixture: Sendable { log.info( "command end", - metadata: [ - "seq": "\(seq)", - "status": "\(process.terminationStatus)", - ]) + metadata: ["seq": "\(seq)", "status": "\(process.terminationStatus)"]) return CommandResult( outputData: outputData, @@ -234,8 +214,6 @@ final class ContainerFixture: Sendable { status: process.terminationStatus) } - // MARK: - Image helpers - /// Tags a warmup image to a test-local reference and registers its removal. /// /// The returned name is `{testID}-{imageName}:{tag}`, e.g. @@ -255,29 +233,11 @@ final class ContainerFixture: Sendable { return localRef } - // MARK: - Container helpers - - /// Runs a container, calls `body`, then stops and removes the container. - /// - /// The container name is `{testID}-{tag}`. Supply a `tag` when a test - /// needs more than one container to avoid name collisions. - func withContainer( - image: String, - tag: String = "c", - runArgs: [String] = [], - containerArgs: [String] = ["sleep", "infinity"], - _ body: (String) async throws -> Void - ) async throws { - let name = "\(testID)-\(tag)" - let args = ["run", "--rm", "--name", name, "-d"] + runArgs + [image] + containerArgs - try run(args).check() - defer { - _ = try? run(["stop", "-s", "SIGKILL", name]) - } - try await body(name) - } - /// Polls until the named container reaches the `running` state. + /// + /// 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 { for _ in 0.. Void + ) async throws { + let name = "\(testID)-\(tag)" + var args = ["run", "--name", name, "-d"] + if autoRemove { args.append("--rm") } + args += runArgs + [image] + containerArgs + try run(args).check() + defer { + _ = try? run(["stop", "-s", "SIGKILL", name]) + if !autoRemove { _ = try? run(["delete", name]) } + } + try waitForContainerRunning(name) + try await body(name) + } + + // MARK: - Private + + private let log: Logger + private let cleanupTasks: Mutex<[@Sendable () async throws -> Void]> = .init([]) + private static let commandSeq: Mutex = .init(0) + + private init(testID: String, testDir: FilePath, log: Logger) { + self.testID = testID + self.testDir = testDir + self.log = log + } + + private func runCleanup() async { + let tasks = cleanupTasks.withLock { tasks -> [@Sendable () async throws -> Void] in + let reversed = Array(tasks.reversed()) + tasks.removeAll() + return reversed + } + for task in tasks { + try? await task() + } + } + + private var executableURL: URL { + get throws { + let path: FilePath + if let env = ProcessInfo.processInfo.environment["CONTAINER_CLI_PATH"] { + path = FilePath(env) + } else { + let candidate = FilePath(FileManager.default.currentDirectoryPath) + .appending("bin").appending("container") + guard FileManager.default.fileExists(atPath: candidate.string) else { + throw CommandError.binaryNotFound + } + path = candidate + } + return URL(filePath: path.string) + } + } }