Migrate some container tests, remove concurrent demo tests. (#1840)

- Part of #1833.
- Tweaks `ContainerFixture.withContainer()` to support the legacy
`longRun()` pattern without boilerplate.
This commit is contained in:
J Logan
2026-06-26 14:17:18 -07:00
committed by GitHub
parent 649164d9e2
commit be3b1f20c4
11 changed files with 515 additions and 417 deletions
+16 -4
View File
@@ -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 \
+3
View File
@@ -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"
@@ -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)
}
}
@@ -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
}
}
}
@@ -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")
}
}
@@ -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)
}
}
}
}
@@ -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
}
}
}
}
}
@@ -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")
}
}
}
}
@@ -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)))
}
}
}
}
@@ -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 <name>`.
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
}
}
@@ -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<Int> = .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..<attempts {
if let result = try? run(["inspect", name]),
@@ -290,4 +250,77 @@ final class ContainerFixture: Sendable {
}
throw CommandError.executionFailed("container '\(name)' did not reach running state")
}
// MARK: - Structured API
/// Starts a detached container, waits for `running`, calls `body`, then
/// stops and removes the container.
///
/// The container name is `{testID}-{tag}`. Supply a distinct `tag` when a
/// test needs more than one container simultaneously.
///
/// When `autoRemove` is `true` (default), `--rm` is passed so the runtime
/// removes the container on stop. Set `autoRemove: false` when the test
/// needs to inspect the container's stopped state cleanup will then stop
/// *and* delete it.
func withContainer(
image: String,
tag: String = "c",
runArgs: [String] = [],
containerArgs: [String] = ["sleep", "infinity"],
autoRemove: Bool = true,
_ body: (String) async throws -> 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<Int> = .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)
}
}
}