mirror of
https://github.com/apple/container.git
synced 2026-09-22 23:55:34 +00:00
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:
@@ -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 \
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user