mirror of
https://github.com/apple/container.git
synced 2026-08-24 02:24:19 -05:00
Address flaky TestCLIKernelSetSerial suite. (#1976)
This commit is contained in:
@@ -288,6 +288,7 @@ define RUN_INTEGRATION
|
||||
@echo Running the integration tests...
|
||||
@$(INTEGRATION_PROFILE_ENV) bin/container --debug system start --timeout 60 --enable-kernel-install $(SYSTEM_START_OPTS) && \
|
||||
{ \
|
||||
if [ -n "$(APP_ROOT)" ]; then CONTAINER_APP_ROOT=$(APP_ROOT) && export CONTAINER_APP_ROOT ; fi ; \
|
||||
CLITEST_LOG_ROOT=$(LOG_ROOT) && export CLITEST_LOG_ROOT ; \
|
||||
CLITEST_SCRATCH_ROOT=$(SCRATCH_ROOT) && export CLITEST_SCRATCH_ROOT ; \
|
||||
CONTAINER_CLI_PATH=$(ROOT_DIR)/bin/container && export CONTAINER_CLI_PATH ; \
|
||||
|
||||
@@ -85,6 +85,9 @@ let package = Package(
|
||||
dependencies: [
|
||||
.product(name: "AsyncHTTPClient", package: "async-http-client"),
|
||||
.product(name: "Logging", package: "swift-log"),
|
||||
.product(name: "NIOCore", package: "swift-nio"),
|
||||
.product(name: "NIOHTTP1", package: "swift-nio"),
|
||||
.product(name: "NIOPosix", package: "swift-nio"),
|
||||
.product(name: "SystemPackage", package: "swift-system"),
|
||||
.product(name: "Containerization", package: "containerization"),
|
||||
.product(name: "ContainerizationArchive", package: "containerization"),
|
||||
@@ -95,6 +98,7 @@ let package = Package(
|
||||
"ContainerAPIClient",
|
||||
"ContainerLog",
|
||||
"ContainerPersistence",
|
||||
"ContainerPlugin",
|
||||
"ContainerResource",
|
||||
"MachineAPIClient",
|
||||
"Yams",
|
||||
|
||||
@@ -14,19 +14,22 @@
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerizationArchive
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
/// Tests for `container system kernel set`. Each test modifies the global default
|
||||
/// kernel binary, so the suite must run fully serialised.
|
||||
///
|
||||
/// None of these tests touch the network: they capture the bytes of whatever
|
||||
/// kernel is already installed (from the previous test, or from the initial
|
||||
/// `system start --enable-kernel-install`) and repackage them into a fixture
|
||||
/// tar via ``KernelFixture``, so the real install/extract/digest-verify/
|
||||
/// guest-boot code paths are still exercised end to end.
|
||||
@Suite(.serialized)
|
||||
struct TestCLIKernelSetSerial {
|
||||
private let remoteTar = ContainerSystemConfig().kernel.url
|
||||
private let fixture = KernelFixture()
|
||||
private let defaultBinaryPath = ContainerSystemConfig().kernel.binaryPath
|
||||
private let defaultDigest = KernelConfig.defaultDigest
|
||||
|
||||
/// Kernel release string parsed from the binary filename.
|
||||
///
|
||||
@@ -75,41 +78,37 @@ struct TestCLIKernelSetSerial {
|
||||
}
|
||||
|
||||
@Test func fromLocalTar() async throws {
|
||||
let symlinkBinaryPath = URL(filePath: defaultBinaryPath)
|
||||
.deletingLastPathComponent()
|
||||
.appending(path: "vmlinux.container")
|
||||
.relativePath
|
||||
|
||||
try await ContainerFixture.with { f in
|
||||
f.addCleanup { resetKernelToRecommended(f) }
|
||||
let tempDir = URL(filePath: f.testDir.string)
|
||||
let localTarPath = tempDir.appending(path: remoteTar.lastPathComponent)
|
||||
try await ContainerAPIClient.FileDownloader.downloadFile(url: remoteTar, to: localTarPath)
|
||||
let capturedBinary = try prepareFixture(f)
|
||||
let tarPath = URL(filePath: f.testDir.string).appending(path: "kernel.tar")
|
||||
let digest = try fixture.writeTar(binary: capturedBinary, binaryArchivePath: defaultBinaryPath, to: tarPath)
|
||||
|
||||
try f.run([
|
||||
"system", "kernel", "set",
|
||||
"--force",
|
||||
"--tar", localTarPath.path,
|
||||
"--tar", tarPath.path,
|
||||
"--binary", symlinkBinaryPath,
|
||||
"--digest", defaultDigest,
|
||||
"--digest", digest,
|
||||
]).check()
|
||||
try await validateGuestKernel(f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func fromRemoteTarSymlink() async throws {
|
||||
let symlinkBinaryPath = URL(filePath: defaultBinaryPath)
|
||||
.deletingLastPathComponent()
|
||||
.appending(path: "vmlinux.container")
|
||||
.relativePath
|
||||
|
||||
try await ContainerFixture.with { f in
|
||||
f.addCleanup { resetKernelToRecommended(f) }
|
||||
let capturedBinary = try prepareFixture(f)
|
||||
let tarPath = URL(filePath: f.testDir.string).appending(path: "kernel.tar")
|
||||
let digest = try fixture.writeTar(binary: capturedBinary, binaryArchivePath: defaultBinaryPath, to: tarPath)
|
||||
|
||||
let server = try LoopbackFileServer(serving: try Data(contentsOf: tarPath))
|
||||
defer { server.shutdown() }
|
||||
|
||||
try f.run([
|
||||
"system", "kernel", "set",
|
||||
"--force",
|
||||
"--tar", remoteTar.absoluteString,
|
||||
"--tar", server.url.absoluteString,
|
||||
"--binary", symlinkBinaryPath,
|
||||
"--digest", defaultDigest,
|
||||
"--digest", digest,
|
||||
]).check()
|
||||
try await validateGuestKernel(f)
|
||||
}
|
||||
@@ -117,36 +116,48 @@ struct TestCLIKernelSetSerial {
|
||||
|
||||
@Test func fromLocalDisk() async throws {
|
||||
try await ContainerFixture.with { f in
|
||||
f.addCleanup { resetKernelToRecommended(f) }
|
||||
let tempDir = URL(filePath: f.testDir.string)
|
||||
let localTarPath = tempDir.appending(path: remoteTar.lastPathComponent)
|
||||
try await ContainerAPIClient.FileDownloader.downloadFile(url: remoteTar, to: localTarPath)
|
||||
|
||||
let targetPath = tempDir.appending(path: URL(string: defaultBinaryPath)!.lastPathComponent)
|
||||
let archiveReader = try ArchiveReader(file: localTarPath)
|
||||
let (_, data) = try archiveReader.extractFile(path: defaultBinaryPath)
|
||||
try data.write(to: targetPath, options: .atomic)
|
||||
|
||||
try f.run(["system", "kernel", "set", "--force", "--binary", targetPath.path]).check()
|
||||
let capturedBinary = try prepareFixture(f)
|
||||
try f.run(["system", "kernel", "set", "--force", "--binary", capturedBinary.path]).check()
|
||||
try await validateGuestKernel(f)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
/// Resets the kernel back to the recommended default. Used as a cleanup at the
|
||||
/// The archive path `fromLocalTar`/`fromRemoteTarSymlink` request — a symlink
|
||||
/// alongside the real binary, deliberately exercising `KernelService.extractFile`'s
|
||||
/// symlink-following branch.
|
||||
private var symlinkBinaryPath: String {
|
||||
URL(filePath: defaultBinaryPath)
|
||||
.deletingLastPathComponent()
|
||||
.appending(path: "vmlinux.container")
|
||||
.relativePath
|
||||
}
|
||||
|
||||
/// Captures the currently-installed kernel binary and registers cleanup to
|
||||
/// restore it regardless of test outcome. The upcoming `kernel set --force`
|
||||
/// command overwrites whatever is currently installed, so there's no need
|
||||
/// to clear it out first.
|
||||
private func prepareFixture(_ f: ContainerFixture) throws -> URL {
|
||||
let capturedBinary = URL(filePath: f.testDir.string).appending(path: "captured-kernel")
|
||||
try fixture.captureInstalledBinary(to: capturedBinary)
|
||||
f.addCleanup { restoreCapturedKernel(f, from: capturedBinary) }
|
||||
return capturedBinary
|
||||
}
|
||||
|
||||
/// Restores the kernel captured at the start of a test. Used as cleanup at the
|
||||
/// end of every test so a failure here doesn't silently affect the next test —
|
||||
/// the suite is serialised and the kernel is global state. The fixture's
|
||||
/// cleanup runner swallows throws with `try?`, so we record an issue against
|
||||
/// the current test rather than rely on error propagation.
|
||||
private func resetKernelToRecommended(_ f: ContainerFixture) {
|
||||
private func restoreCapturedKernel(_ f: ContainerFixture, from capturedBinary: URL) {
|
||||
do {
|
||||
let result = try f.run(["system", "kernel", "set", "--recommended", "--force"])
|
||||
let result = try f.run(["system", "kernel", "set", "--force", "--binary", capturedBinary.path])
|
||||
if result.status != 0 {
|
||||
Issue.record("kernel reset to --recommended failed (status \(result.status)): \(result.error)")
|
||||
Issue.record("kernel restore from captured binary failed (status \(result.status)): \(result.error)")
|
||||
}
|
||||
} catch {
|
||||
Issue.record("kernel reset to --recommended could not run: \(error)")
|
||||
Issue.record("kernel restore from captured binary could not run: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ContainerPlugin
|
||||
import Containerization
|
||||
import ContainerizationArchive
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// Fixture helpers for `TestCLIKernelSetSerial`.
|
||||
///
|
||||
/// `container system kernel set` always requires a kernel to already be
|
||||
/// installed for the integration suite to run at all (`system start
|
||||
/// --enable-kernel-install` guarantees this). Rather than downloading the real
|
||||
/// ~570MB kata-static release tarball in every test, these helpers capture the
|
||||
/// bytes of whatever kernel is already installed and repackage them into a
|
||||
/// tar with the same internal layout (a real binary plus the `vmlinux.container`
|
||||
/// symlink member kata's release tarballs ship) so the real install/extract/
|
||||
/// digest-verify code paths still get exercised, without any network access.
|
||||
struct KernelFixture {
|
||||
private let kernelsDirectory = URL(fileURLWithPath: ApplicationRoot.pathname).appendingPathComponent("kernels")
|
||||
|
||||
private var defaultKernelSymlink: URL {
|
||||
kernelsDirectory.appendingPathComponent("default.kernel-\(SystemPlatform.linuxArm.architecture.rawValue)")
|
||||
}
|
||||
|
||||
/// Copies the bytes of the currently-installed default kernel to `destination`.
|
||||
func captureInstalledBinary(to destination: URL) throws {
|
||||
let resolved = defaultKernelSymlink.resolvingSymlinksInPath()
|
||||
guard FileManager.default.fileExists(atPath: resolved.path) else {
|
||||
throw CommandError.executionFailed("no default kernel installed at \(resolved.path)")
|
||||
}
|
||||
try FileManager.default.copyItem(at: resolved, to: destination)
|
||||
}
|
||||
|
||||
/// Writes a tar at `tarPath` containing `binary`'s bytes at `binaryArchivePath`,
|
||||
/// plus a `vmlinux.container` symlink alongside it pointing at the binary's
|
||||
/// filename — mirroring the layout `KernelService.extractFile`'s
|
||||
/// symlink-following branch expects.
|
||||
///
|
||||
/// Returns the tar's own `sha256:<hex>` digest, since `container system
|
||||
/// kernel set --digest` verifies the archive's digest, not the extracted
|
||||
/// binary's.
|
||||
@discardableResult
|
||||
func writeTar(binary: URL, binaryArchivePath: String, to tarPath: URL) throws -> String {
|
||||
let binaryData = try Data(contentsOf: binary)
|
||||
|
||||
let writer = try ArchiveWriter(format: .ustar, filter: .none, file: tarPath)
|
||||
let fileEntry = WriteEntry()
|
||||
fileEntry.path = binaryArchivePath
|
||||
fileEntry.fileType = .regular
|
||||
fileEntry.permissions = 0o644
|
||||
fileEntry.size = Int64(binaryData.count)
|
||||
try writer.writeEntry(entry: fileEntry, data: binaryData)
|
||||
|
||||
let symlinkEntry = WriteEntry()
|
||||
symlinkEntry.path =
|
||||
URL(filePath: binaryArchivePath)
|
||||
.deletingLastPathComponent()
|
||||
.appending(path: "vmlinux.container")
|
||||
.relativePath
|
||||
symlinkEntry.fileType = .symbolicLink
|
||||
symlinkEntry.symlinkTarget = URL(filePath: binaryArchivePath).lastPathComponent
|
||||
symlinkEntry.permissions = 0o644
|
||||
try writer.writeEntry(entry: symlinkEntry, data: nil)
|
||||
|
||||
try writer.finishEncoding()
|
||||
|
||||
var hasher = SHA256()
|
||||
let handle = try FileHandle(forReadingFrom: tarPath)
|
||||
defer { try? handle.close() }
|
||||
while let chunk = try handle.read(upToCount: 4 * 1024 * 1024), !chunk.isEmpty {
|
||||
hasher.update(data: chunk)
|
||||
}
|
||||
let hex = hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
||||
return "sha256:\(hex)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 NIOCore
|
||||
import NIOHTTP1
|
||||
import NIOPosix
|
||||
|
||||
/// Minimal loopback-only HTTP/1.1 server that serves a single fixed byte
|
||||
/// payload for any GET request. Used by integration tests that need to
|
||||
/// exercise a "fetch this over a URL" code path without depending on a real
|
||||
/// network peer.
|
||||
final class LoopbackFileServer: Sendable {
|
||||
/// URL clients should fetch to receive the served payload.
|
||||
let url: URL
|
||||
|
||||
private let group: MultiThreadedEventLoopGroup
|
||||
private let channel: any Channel
|
||||
|
||||
init(serving data: Data) throws {
|
||||
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||||
let bootstrap = ServerBootstrap(group: group)
|
||||
.childChannelInitializer { channel in
|
||||
channel.pipeline.configureHTTPServerPipeline().flatMap {
|
||||
channel.pipeline.addHandler(StaticPayloadHandler(data: data))
|
||||
}
|
||||
}
|
||||
|
||||
let channel: any Channel
|
||||
do {
|
||||
channel = try bootstrap.bind(host: "127.0.0.1", port: 0).wait()
|
||||
} catch {
|
||||
try? group.syncShutdownGracefully()
|
||||
throw error
|
||||
}
|
||||
guard let port = channel.localAddress?.port else {
|
||||
try? channel.close().wait()
|
||||
try? group.syncShutdownGracefully()
|
||||
throw CommandError.executionFailed("loopback file server has no bound port")
|
||||
}
|
||||
|
||||
self.group = group
|
||||
self.channel = channel
|
||||
self.url = URL(string: "http://127.0.0.1:\(port)/payload")!
|
||||
}
|
||||
|
||||
/// Stops accepting connections and shuts down the server's event loop.
|
||||
func shutdown() {
|
||||
try? channel.close().wait()
|
||||
try? group.syncShutdownGracefully()
|
||||
}
|
||||
}
|
||||
|
||||
/// Responds to any request with the fixed payload, then closes the connection.
|
||||
private final class StaticPayloadHandler: ChannelInboundHandler, Sendable {
|
||||
typealias InboundIn = HTTPServerRequestPart
|
||||
typealias OutboundOut = HTTPServerResponsePart
|
||||
|
||||
private let data: Data
|
||||
|
||||
init(data: Data) {
|
||||
self.data = data
|
||||
}
|
||||
|
||||
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
||||
guard case .end = self.unwrapInboundIn(data) else { return }
|
||||
|
||||
var headers = HTTPHeaders()
|
||||
headers.add(name: "Content-Length", value: "\(self.data.count)")
|
||||
headers.add(name: "Connection", value: "close")
|
||||
context.write(self.wrapOutboundOut(.head(HTTPResponseHead(version: .http1_1, status: .ok, headers: headers))), promise: nil)
|
||||
|
||||
var buffer = context.channel.allocator.buffer(capacity: self.data.count)
|
||||
buffer.writeBytes(self.data)
|
||||
context.write(self.wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil)
|
||||
|
||||
let loopBoundContext = NIOLoopBound(context, eventLoop: context.eventLoop)
|
||||
context.writeAndFlush(self.wrapOutboundOut(.end(nil))).whenComplete { _ in
|
||||
loopBoundContext.value.close(promise: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user