mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
Enhanced test fixtures for integration tests. (#1834)
- Part of #1833. - Adds `ContainerFixture` with scoped resource lifecycle and cleanup in place of implementation inheritance for test support functions. The fixture also handles resource prefixing and uses a more ergonomic `CommandResult` in place of a tuple for return values. - `ImageWarmup` suite pre-pulls well-known images, and `copyWarmupImage()` tags test-local refs, keeping the canonical image store untouched. - Three-phase `integration-new`: warmup, followed by concurrent tests (managed by the swift test `--experimental-maximum-parallelization-width` flag), followed by serialized tests. - `coverage-new` merges unit + integration-new profraw, replacing `coverage` in CI as a migration progress indicator. - Updates GH workflow so non-coverage invokes both the `integration` and `integration-new` Makefile targets, while coverage runs invoke the `coverage-new` target.
This commit is contained in:
@@ -103,11 +103,11 @@ jobs:
|
||||
|
||||
- name: Test the container project
|
||||
if: ${{ !inputs.coverage }}
|
||||
run: make APP_ROOT="${APP_ROOT}" LOG_ROOT="${LOG_ROOT}" test install-kernel integration
|
||||
run: make APP_ROOT="${APP_ROOT}" LOG_ROOT="${LOG_ROOT}" test install-kernel integration integration-new
|
||||
|
||||
- name: Test the container project with coverage
|
||||
if: ${{ inputs.coverage }}
|
||||
run: make APP_ROOT="${APP_ROOT}" LOG_ROOT="${LOG_ROOT}" install-kernel coverage
|
||||
run: make APP_ROOT="${APP_ROOT}" LOG_ROOT="${LOG_ROOT}" install-kernel coverage-new
|
||||
|
||||
- name: Extract coverage percentages
|
||||
if: ${{ inputs.coverage }}
|
||||
|
||||
@@ -151,7 +151,7 @@ dsym:
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --skip TestCLI
|
||||
@$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --skip TestCLI --skip IntegrationTests
|
||||
|
||||
.PHONY: install-kernel
|
||||
install-kernel:
|
||||
@@ -194,6 +194,56 @@ define GENERATE_COV_REPORTS
|
||||
@cat $(COVERAGE_OUTPUT_DIR)/$(2)/coverage-percent.txt
|
||||
endef
|
||||
|
||||
# New integration test infrastructure.
|
||||
# PARALLEL_WIDTH controls --experimental-maximum-parallelization-width for the
|
||||
# concurrent pass. WARMUP_FILTER, CONCURRENT_FILTER, and GLOBAL_FILTER select
|
||||
# the three phases. Expand the filter lists as suites are migrated from CLITests.
|
||||
PARALLEL_WIDTH ?= 2
|
||||
WARMUP_FILTER = ImageWarmup
|
||||
CONCURRENT_FILTER = DemoConcurrentTests
|
||||
GLOBAL_FILTER = DemoGlobalTests
|
||||
|
||||
INTEGRATION_SWIFT_EXTRA ?=
|
||||
INTEGRATION_POST_TEST ?=
|
||||
|
||||
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 {} + ; \
|
||||
fi
|
||||
@echo Running the integration tests...
|
||||
@bin/container --debug system start --timeout 60 --enable-kernel-install $(SYSTEM_START_OPTS) && \
|
||||
{ \
|
||||
CLITEST_LOG_ROOT=$(LOG_ROOT) && export CLITEST_LOG_ROOT ; \
|
||||
CONTAINER_CLI_PATH=$(ROOT_DIR)/bin/container && export CONTAINER_CLI_PATH ; \
|
||||
echo "==> Warmup pass" && \
|
||||
$(SWIFT) test $(INTEGRATION_SWIFT_EXTRA) -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter "$(WARMUP_FILTER)" && \
|
||||
echo "==> Concurrent pass (width=$(PARALLEL_WIDTH))" && \
|
||||
$(SWIFT) test $(INTEGRATION_SWIFT_EXTRA) -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --experimental-maximum-parallelization-width $(PARALLEL_WIDTH) --filter "$(CONCURRENT_FILTER)" && \
|
||||
echo "==> Global pass (serial)" && \
|
||||
$(SWIFT) test $(INTEGRATION_SWIFT_EXTRA) -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter "$(GLOBAL_FILTER)" ; \
|
||||
exit_code=$$? ; \
|
||||
$(INTEGRATION_POST_TEST) \
|
||||
echo Ensuring apiserver stopped after the CLI integration tests ; \
|
||||
scripts/ensure-container-stopped.sh ; \
|
||||
exit $${exit_code} ; \
|
||||
}
|
||||
endef
|
||||
|
||||
.PHONY: integration-new
|
||||
integration-new: init-block
|
||||
$(RUN_INTEGRATION)
|
||||
|
||||
.PHONY: coverage-integration-new
|
||||
coverage-integration-new: INTEGRATION_SWIFT_EXTRA = --skip-build --enable-code-coverage
|
||||
coverage-integration-new: INTEGRATION_POST_TEST = cp $(COV_DATA_DIR)/*.profraw $(COVERAGE_OUTPUT_DIR)/integration/ ;
|
||||
coverage-integration-new: all
|
||||
@mkdir -p $(COVERAGE_OUTPUT_DIR)/integration
|
||||
$(RUN_INTEGRATION)
|
||||
|
||||
INTEGRATION_TEST_SUITES ?= \
|
||||
TestCLIHelp \
|
||||
TestCLIStatus \
|
||||
@@ -228,6 +278,21 @@ empty :=
|
||||
space := $(empty) $(empty)
|
||||
INTEGRATION_FILTER := $(subst $(space),|,$(strip $(INTEGRATION_TEST_SUITES)))
|
||||
|
||||
.PHONY: coverage-new
|
||||
# Merges unit coverage with integration-new coverage. Use this during migration;
|
||||
# replace coverage with coverage-new in CI until all legacy tests are removed.
|
||||
coverage-new: coverage-build coverage-unit coverage-integration-new
|
||||
@echo Merging integration coverage profdata...
|
||||
@xcrun llvm-profdata merge -sparse $(COVERAGE_OUTPUT_DIR)/integration/*.profraw -o $(COVERAGE_OUTPUT_DIR)/integration/default.profdata
|
||||
$(call GENERATE_COV_REPORTS,$(COVERAGE_OUTPUT_DIR)/integration/default.profdata,integration)
|
||||
@echo Merging combined coverage profdata...
|
||||
@mkdir -p $(COVERAGE_OUTPUT_DIR)/combined
|
||||
@xcrun llvm-profdata merge -sparse \
|
||||
$(COVERAGE_OUTPUT_DIR)/unit/default.profdata \
|
||||
$(COVERAGE_OUTPUT_DIR)/integration/default.profdata \
|
||||
-o $(COVERAGE_OUTPUT_DIR)/combined/default.profdata
|
||||
$(call GENERATE_COV_REPORTS,$(COVERAGE_OUTPUT_DIR)/combined/default.profdata,combined)
|
||||
|
||||
.PHONY: coverage-build
|
||||
coverage-build:
|
||||
@echo Building tests with coverage instrumentation...
|
||||
@@ -249,7 +314,7 @@ coverage-unit:
|
||||
@echo Running unit test coverage...
|
||||
@rm -f $(COV_DATA_DIR)/*.profraw
|
||||
@mkdir -p $(COVERAGE_OUTPUT_DIR)/unit
|
||||
@$(SWIFT) test --skip-build --enable-code-coverage -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --skip TestCLI
|
||||
@$(SWIFT) test --skip-build --enable-code-coverage -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --skip TestCLI --skip IntegrationTests
|
||||
@echo Merging unit coverage profdata...
|
||||
@xcrun llvm-profdata merge -sparse $(COV_DATA_DIR)/*.profraw -o $(COVERAGE_OUTPUT_DIR)/unit/default.profdata
|
||||
$(call GENERATE_COV_REPORTS,$(COVERAGE_OUTPUT_DIR)/unit/default.profdata,unit)
|
||||
|
||||
@@ -80,6 +80,16 @@ let package = Package(
|
||||
],
|
||||
path: "Sources/CLI"
|
||||
),
|
||||
.testTarget(
|
||||
name: "IntegrationTests",
|
||||
dependencies: [
|
||||
.product(name: "Logging", package: "swift-log"),
|
||||
.product(name: "SystemPackage", package: "swift-system"),
|
||||
"ContainerLog",
|
||||
"Yams",
|
||||
],
|
||||
path: "Tests/IntegrationTests"
|
||||
),
|
||||
.testTarget(
|
||||
name: "CLITests",
|
||||
dependencies: [
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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,41 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Testing
|
||||
|
||||
/// Demonstration suite for the serial global test pass.
|
||||
///
|
||||
/// These two tests are structurally identical to ``DemoConcurrentTests`` but
|
||||
/// run under ``--experimental-maximum-parallelization-width 1`` in the Makefile
|
||||
/// to show serial execution. Total wall-clock time should be approximately the
|
||||
/// sum of the individual durations rather than the maximum.
|
||||
///
|
||||
/// Real global tests (image prune, system df, kernel set, etc.) will live here
|
||||
/// once migrated. Delete this suite at that point.
|
||||
@Suite
|
||||
struct DemoGlobalTests {
|
||||
@Test func globalTest1() async throws { try await runDemo() }
|
||||
@Test func globalTest2() async throws { try await runDemo() }
|
||||
|
||||
private func runDemo() async throws {
|
||||
try await ContainerFixture.with { f in
|
||||
let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0])
|
||||
try await f.withContainer(image: image) { _ in
|
||||
try await Task.sleep(for: .seconds(Int.random(in: 2...4)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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
|
||||
|
||||
struct CommandResult: Sendable {
|
||||
let outputData: Data
|
||||
let errorData: Data
|
||||
let status: Int32
|
||||
|
||||
var output: String {
|
||||
String(data: outputData, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
var error: String {
|
||||
String(data: errorData, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func check(_ message: String? = nil) throws -> CommandResult {
|
||||
guard status == 0 else {
|
||||
let detail = message ?? error.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
throw CommandError.nonZeroExit(status, detail)
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
enum CommandError: Error {
|
||||
case binaryNotFound
|
||||
case executionFailed(String)
|
||||
case nonZeroExit(Int32, String)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerLog
|
||||
import Foundation
|
||||
import Logging
|
||||
import Synchronization
|
||||
import SystemPackage
|
||||
import Testing
|
||||
|
||||
/// Per-test fixture providing CLI execution, resource lifecycle, and cleanup.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// Tier 1 — unstructured: call ``addCleanup(_:)`` to register any async
|
||||
/// closure. Closures run LIFO on scope exit.
|
||||
///
|
||||
/// Tier 2 — structured: helpers like ``withContainer(image:tag:runArgs:containerArgs:_:)``
|
||||
/// register cleanup on your behalf and express resource lifetime as a scope.
|
||||
final class ContainerFixture: Sendable {
|
||||
|
||||
// MARK: - Well-known images
|
||||
|
||||
/// 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",
|
||||
"ghcr.io/linuxcontainers/alpine:3.18",
|
||||
"ghcr.io/containerd/busybox:1.36",
|
||||
]
|
||||
|
||||
// MARK: - Per-instance 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``
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Runs `body` with a fresh fixture, then tears down all registered resources.
|
||||
///
|
||||
/// Cleanup runs in LIFO order regardless of whether `body` throws.
|
||||
@discardableResult
|
||||
static func with<T>(_ body: (ContainerFixture) async throws -> T) async throws -> T {
|
||||
let testID = String(UUID().uuidString.prefix(8)).lowercased()
|
||||
|
||||
let scratchRoot =
|
||||
ProcessInfo.processInfo.environment["CLITEST_SCRATCH_ROOT"]
|
||||
.map { FilePath($0) }
|
||||
?? FilePath(FileManager.default.temporaryDirectory.path)
|
||||
let testDir = scratchRoot.appending(testID)
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: testDir.string, withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
let testName =
|
||||
Test.current.map { $0.name.hasSuffix("()") ? String($0.name.dropLast(2)) : $0.name }
|
||||
?? testID
|
||||
let suiteName = Test.current.map { "\(type(of: $0))" } ?? "unknown"
|
||||
|
||||
var logger = Logger(label: "com.apple.container.test") { label in
|
||||
if let root = ProcessInfo.processInfo.environment["CLITEST_LOG_ROOT"], !root.isEmpty {
|
||||
let path =
|
||||
FilePath(root)
|
||||
.appending("clitests")
|
||||
.appending(suiteName)
|
||||
.appending(testName + ".log")
|
||||
if let handler = try? FileLogHandler(label: label, category: "clitests", path: path) {
|
||||
return handler
|
||||
}
|
||||
}
|
||||
return StderrLogHandler()
|
||||
}
|
||||
logger[metadataKey: "testID"] = "\(testID)"
|
||||
|
||||
let fixture = ContainerFixture(testID: testID, testDir: testDir, log: logger)
|
||||
|
||||
if ProcessInfo.processInfo.environment["CLITEST_PRESERVE_SCRATCH"] == nil {
|
||||
fixture.addCleanup {
|
||||
try? FileManager.default.removeItem(atPath: testDir.string)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await body(fixture)
|
||||
await fixture.runCleanup()
|
||||
return result
|
||||
} catch {
|
||||
await fixture.runCleanup()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a cleanup closure to run when the fixture scope exits.
|
||||
/// Closures execute in LIFO order.
|
||||
func addCleanup(_ task: @escaping @Sendable () async throws -> Void) {
|
||||
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,
|
||||
/// process launch error). A non-zero exit status is represented in
|
||||
/// ``CommandResult/status`` — call ``CommandResult/check(_:)`` to turn it
|
||||
/// into a thrown error.
|
||||
func run(
|
||||
_ arguments: [String],
|
||||
stdin: Data? = nil,
|
||||
currentDirectory: FilePath? = nil,
|
||||
env: [String: String] = [:]
|
||||
) throws -> CommandResult {
|
||||
let seq = Self.commandSeq.withLock { n in
|
||||
defer { n += 1 }
|
||||
return n
|
||||
}
|
||||
log.info(
|
||||
"command start",
|
||||
metadata: ["seq": "\(seq)", "args": "\(arguments.joined(separator: " "))"])
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = try executableURL
|
||||
process.arguments = arguments
|
||||
if let dir = currentDirectory { process.currentDirectoryURL = URL(filePath: dir.string) }
|
||||
if !env.isEmpty {
|
||||
var e = ProcessInfo.processInfo.environment
|
||||
for (k, v) in env { e[k] = v }
|
||||
process.environment = e
|
||||
}
|
||||
|
||||
let inputPipe = Pipe()
|
||||
process.standardInput = inputPipe
|
||||
|
||||
// Write stdout/stderr to temp files to avoid blocking on full pipe buffers.
|
||||
let tmpDir = FilePath(FileManager.default.temporaryDirectory.path)
|
||||
.appending(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: tmpDir.string, withIntermediateDirectories: true, attributes: nil)
|
||||
defer { try? FileManager.default.removeItem(atPath: tmpDir.string) }
|
||||
|
||||
let stdoutPath = tmpDir.appending("stdout")
|
||||
let stderrPath = tmpDir.appending("stderr")
|
||||
FileManager.default.createFile(atPath: stdoutPath.string, contents: nil)
|
||||
FileManager.default.createFile(atPath: stderrPath.string, contents: nil)
|
||||
|
||||
let stdoutHandle = try FileHandle(forWritingTo: URL(filePath: stdoutPath.string))
|
||||
defer { try? stdoutHandle.close() }
|
||||
let stderrHandle = try FileHandle(forWritingTo: URL(filePath: stderrPath.string))
|
||||
defer { try? stderrHandle.close() }
|
||||
|
||||
process.standardOutput = stdoutHandle
|
||||
process.standardError = stderrHandle
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
throw CommandError.executionFailed("process launch failed: \(error)")
|
||||
}
|
||||
if let data = stdin { inputPipe.fileHandleForWriting.write(data) }
|
||||
inputPipe.fileHandleForWriting.closeFile()
|
||||
process.waitUntilExit()
|
||||
|
||||
let outputData = (try? Data(contentsOf: URL(filePath: stdoutPath.string))) ?? Data()
|
||||
let errorData = (try? Data(contentsOf: URL(filePath: stderrPath.string))) ?? Data()
|
||||
|
||||
log.info(
|
||||
"command end",
|
||||
metadata: [
|
||||
"seq": "\(seq)",
|
||||
"status": "\(process.terminationStatus)",
|
||||
])
|
||||
|
||||
return CommandResult(
|
||||
outputData: outputData,
|
||||
errorData: errorData,
|
||||
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.
|
||||
/// `a3f7c2b1-alpine:3.20`. Tests operate freely on this reference;
|
||||
/// the canonical warmup image is never touched.
|
||||
func copyWarmupImage(_ canonical: String) throws -> String {
|
||||
let lastComponent = canonical.split(separator: "/").last.map(String.init) ?? canonical
|
||||
let parts = lastComponent.split(separator: ":", maxSplits: 1)
|
||||
let name = String(parts[0])
|
||||
let tag = parts.count > 1 ? String(parts[1]) : "latest"
|
||||
let localRef = "\(testID)-\(name):\(tag)"
|
||||
|
||||
try run(["image", "tag", canonical, localRef]).check()
|
||||
addCleanup {
|
||||
_ = try? self.run(["image", "rm", localRef])
|
||||
}
|
||||
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.
|
||||
func waitForContainerRunning(_ name: String, attempts: Int = 30) throws {
|
||||
for _ in 0..<attempts {
|
||||
if let result = try? run(["inspect", name]),
|
||||
result.status == 0,
|
||||
result.output.contains("\"running\"")
|
||||
{
|
||||
return
|
||||
}
|
||||
sleep(1)
|
||||
}
|
||||
throw CommandError.executionFailed("container '\(name)' did not reach running state")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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
|
||||
|
||||
/// Pulls each image in ``ContainerFixture/warmupImages`` in parallel before
|
||||
/// concurrent integration tests run. The Makefile's warmup pass runs this
|
||||
/// suite first so that ``ContainerFixture/copyWarmupImage(_:)`` can tag
|
||||
/// from a pre-populated store rather than pulling on demand.
|
||||
@Suite
|
||||
struct ImageWarmup {
|
||||
@Test(arguments: ContainerFixture.warmupImages)
|
||||
func pull(image: String) async throws {
|
||||
try await ContainerFixture.with { f in
|
||||
try f.run(["image", "pull", image]).check("failed to pull \(image)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user