diff --git a/Makefile b/Makefile index 0a1d601b..9b973fee 100644 --- a/Makefile +++ b/Makefile @@ -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 ; \ diff --git a/Package.swift b/Package.swift index 95af0309..cc0465df 100644 --- a/Package.swift +++ b/Package.swift @@ -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", diff --git a/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift b/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift index ac7c6001..bd8879cd 100644 --- a/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift +++ b/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift @@ -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)") } } diff --git a/Tests/IntegrationTests/Build/BuildFixture.swift b/Tests/IntegrationTests/Utilities/BuildFixture.swift similarity index 100% rename from Tests/IntegrationTests/Build/BuildFixture.swift rename to Tests/IntegrationTests/Utilities/BuildFixture.swift diff --git a/Tests/IntegrationTests/Utilities/KernelFixture.swift b/Tests/IntegrationTests/Utilities/KernelFixture.swift new file mode 100644 index 00000000..69eaad8f --- /dev/null +++ b/Tests/IntegrationTests/Utilities/KernelFixture.swift @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import 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:` 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)" + } +} diff --git a/Tests/IntegrationTests/Utilities/LoopbackFileServer.swift b/Tests/IntegrationTests/Utilities/LoopbackFileServer.swift new file mode 100644 index 00000000..67e98b3f --- /dev/null +++ b/Tests/IntegrationTests/Utilities/LoopbackFileServer.swift @@ -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) + } + } +}