From 6b28e270f46d2bbf14fb5faefccb9a69e676e139 Mon Sep 17 00:00:00 2001 From: Kathryn Baldauf Date: Wed, 1 Jul 2026 14:20:11 -0700 Subject: [PATCH 1/2] Export ContainerFixture as a test support package Signed-off-by: Kathryn Baldauf --- Package.swift | 10 ++- .../ContainerTestSupport}/CommandResult.swift | 16 ++-- .../ContainerFixture+ContainerHelpers.swift | 38 +++++----- .../ContainerFixture+ImageHelpers.swift | 34 ++++----- .../ContainerFixture+MachineHelpers.swift | 74 +++++++++---------- .../ContainerFixture+NetworkHelpers.swift | 26 +++---- .../ContainerFixture+SSHTestHelpers.swift | 2 +- .../ContainerFixture+SystemHelpers.swift | 4 +- .../ContainerFixture+VolumeHelpers.swift | 16 ++-- .../ContainerFixture.swift | 22 +++--- .../IntegrationTests/Build/BuildFixture.swift | 1 + .../Build/TestCLIBuilderEnvOnlySerial.swift | 1 + .../Build/TestCLIBuilderLifecycleSerial.swift | 1 + .../TestCLIBuilderLocalOutputSerial.swift | 1 + .../Build/TestCLIBuilderSerial.swift | 1 + .../Build/TestCLIBuilderTarExportSerial.swift | 1 + .../Containers/TestCLICopyCommand.swift | 1 + .../Containers/TestCLICreateCommand.swift | 1 + .../Containers/TestCLIExecCommand.swift | 1 + .../Containers/TestCLIExportCommand.swift | 1 + .../Containers/TestCLINotFound.swift | 1 + .../TestCLIPruneCommandSerial.swift | 1 + .../Containers/TestCLIRemove.swift | 1 + .../Containers/TestCLIRmRaceCondition.swift | 1 + .../Containers/TestCLIStatsCommand.swift | 1 + .../Containers/TestCLIStop.swift | 1 + .../Images/TestCLIImagesCommand.swift | 1 + .../Machine/TestCLIMachineCommand.swift | 1 + .../Machine/TestCLIMachineRuntimeSerial.swift | 1 + .../Network/TestCLINetwork.swift | 1 + .../Run/TestCLIRunCapabilities.swift | 1 + .../Run/TestCLIRunCommand.swift | 1 + .../Run/TestCLIRunInitImage.swift | 1 + .../Run/TestCLIRunLifecycle.swift | 1 + .../Run/TestCLIRunLifecycleSerial.swift | 1 + .../IntegrationTests/System/TestCLIHelp.swift | 1 + .../System/TestCLIKernelSetSerial.swift | 1 + .../System/TestCLIStatus.swift | 1 + .../System/TestCLISystemDFSerial.swift | 1 + .../System/TestCLIVersion.swift | 1 + .../Volumes/TestCLIAnonymousVolumes.swift | 1 + .../Volumes/TestCLIVolumes.swift | 1 + .../Volumes/TestCLIVolumesSerial.swift | 1 + .../IntegrationTests/Warmup/ImageWarmup.swift | 1 + 44 files changed, 159 insertions(+), 117 deletions(-) rename {Tests/IntegrationTests/Utilities => Sources/ContainerTestSupport}/CommandResult.swift (80%) rename {Tests/IntegrationTests/Utilities => Sources/ContainerTestSupport}/ContainerFixture+ContainerHelpers.swift (83%) rename {Tests/IntegrationTests/Utilities => Sources/ContainerTestSupport}/ContainerFixture+ImageHelpers.swift (74%) rename {Tests/IntegrationTests/Utilities => Sources/ContainerTestSupport}/ContainerFixture+MachineHelpers.swift (68%) rename {Tests/IntegrationTests/Utilities => Sources/ContainerTestSupport}/ContainerFixture+NetworkHelpers.swift (76%) rename {Tests/IntegrationTests/Utilities => Sources/ContainerTestSupport}/ContainerFixture+SSHTestHelpers.swift (98%) rename {Tests/IntegrationTests/Utilities => Sources/ContainerTestSupport}/ContainerFixture+SystemHelpers.swift (91%) rename {Tests/IntegrationTests/Utilities => Sources/ContainerTestSupport}/ContainerFixture+VolumeHelpers.swift (85%) rename {Tests/IntegrationTests/Utilities => Sources/ContainerTestSupport}/ContainerFixture.swift (96%) diff --git a/Package.swift b/Package.swift index bd46c8510..921adcac4 100644 --- a/Package.swift +++ b/Package.swift @@ -38,6 +38,7 @@ let package = Package( .library(name: "ContainerNetworkServer", targets: ["ContainerNetworkServer"]), .library(name: "ContainerNetworkVmnetServer", targets: ["ContainerNetworkVmnetServer"]), .library(name: "ContainerResource", targets: ["ContainerResource"]), + .library(name: "ContainerTestSupport", targets: ["ContainerTestSupport"]), .library(name: "ContainerLog", targets: ["ContainerLog"]), .library(name: "ContainerPersistence", targets: ["ContainerPersistence"]), .library(name: "ContainerPlugin", targets: ["ContainerPlugin"]), @@ -96,6 +97,7 @@ let package = Package( "ContainerLog", "ContainerPersistence", "ContainerResource", + "ContainerTestSupport", "MachineAPIClient", "Yams", ], @@ -566,7 +568,13 @@ let package = Package( .target( name: "ContainerTestSupport", dependencies: [ - .product(name: "SystemPackage", package: "swift-system") + .product(name: "AsyncHTTPClient", package: "async-http-client"), + .product(name: "Logging", package: "swift-log"), + .product(name: "SystemPackage", package: "swift-system"), + .product(name: "TOML", package: "swift-toml"), + "ContainerLog", + "ContainerPersistence", + "ContainerResource", ] ), .target( diff --git a/Tests/IntegrationTests/Utilities/CommandResult.swift b/Sources/ContainerTestSupport/CommandResult.swift similarity index 80% rename from Tests/IntegrationTests/Utilities/CommandResult.swift rename to Sources/ContainerTestSupport/CommandResult.swift index 0ac083f8e..036b16cec 100644 --- a/Tests/IntegrationTests/Utilities/CommandResult.swift +++ b/Sources/ContainerTestSupport/CommandResult.swift @@ -16,21 +16,21 @@ import Foundation -struct CommandResult: Sendable { - let outputData: Data - let errorData: Data - let status: Int32 +public struct CommandResult: Sendable { + public let outputData: Data + public let errorData: Data + public let status: Int32 - var output: String { + public var output: String { String(data: outputData, encoding: .utf8) ?? "" } - var error: String { + public var error: String { String(data: errorData, encoding: .utf8) ?? "" } @discardableResult - func check(_ message: String? = nil) throws -> CommandResult { + public func check(_ message: String? = nil) throws -> CommandResult { guard status == 0 else { let detail = message ?? error.trimmingCharacters(in: .whitespacesAndNewlines) throw CommandError.nonZeroExit(status, detail) @@ -39,7 +39,7 @@ struct CommandResult: Sendable { } } -enum CommandError: Error { +public enum CommandError: Error { case binaryNotFound case executionFailed(String) case nonZeroExit(Int32, String) diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+ContainerHelpers.swift b/Sources/ContainerTestSupport/ContainerFixture+ContainerHelpers.swift similarity index 83% rename from Tests/IntegrationTests/Utilities/ContainerFixture+ContainerHelpers.swift rename to Sources/ContainerTestSupport/ContainerFixture+ContainerHelpers.swift index e628cd4c1..a7c5f560d 100644 --- a/Tests/IntegrationTests/Utilities/ContainerFixture+ContainerHelpers.swift +++ b/Sources/ContainerTestSupport/ContainerFixture+ContainerHelpers.swift @@ -22,14 +22,14 @@ import SystemPackage extension ContainerFixture { /// Decoded output of `container inspect `. - struct InspectOutput: Codable { - struct Status: Codable { - let state: String - let networks: [ContainerResource.Attachment] + public struct InspectOutput: Codable { + public struct Status: Codable { + public let state: String + public let networks: [ContainerResource.Attachment] } - let configuration: ContainerConfiguration - let status: Status - var networks: [ContainerResource.Attachment] { status.networks } + public let configuration: ContainerConfiguration + public let status: Status + public var networks: [ContainerResource.Attachment] { status.networks } } } @@ -38,7 +38,7 @@ extension ContainerFixture { extension ContainerFixture { /// `-e` flags forwarding proxy env vars into container commands. - var proxyEnvironmentArgs: [String] { + public 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) } @@ -49,7 +49,7 @@ extension ContainerFixture { /// /// `containerEnv` injects environment variables into the container via `-e` flags. /// To set the CLI subprocess environment (e.g. for `--ssh`), use ``run(_:env:)`` directly. - func doLongRun( + public func doLongRun( name: String, image: String? = nil, args: [String] = [], @@ -70,7 +70,7 @@ extension ContainerFixture { } /// Creates a stopped container (`container create`). - func doCreate( + public func doCreate( name: String, image: String? = nil, args: [String] = ["sleep", "infinity"], @@ -90,12 +90,12 @@ extension ContainerFixture { } /// Starts a stopped container. - func doStart(_ name: String) throws { + public 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 { + public func doStop(_ name: String, signal: String? = "SIGKILL") throws { var args = ["stop"] if let signal { args += ["-s", signal] } args.append(name) @@ -103,7 +103,7 @@ extension ContainerFixture { } /// Deletes a container. - func doRemove(_ name: String, force: Bool = false) throws { + public func doRemove(_ name: String, force: Bool = false) throws { var args = ["delete"] if force { args.append("--force") } args.append(name) @@ -116,7 +116,7 @@ extension ContainerFixture { /// 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 { + public func doRemoveIfExists(_ name: String, force: Bool = false, ignoreFailure: Bool = false) throws { do { try doRemove(name, force: force) } catch { @@ -126,7 +126,7 @@ extension ContainerFixture { /// Runs a command inside a container, returns stdout. Throws on non-zero exit. @discardableResult - func doExec( + public func doExec( _ name: String, cmd: [String], detach: Bool = false, @@ -142,7 +142,7 @@ extension ContainerFixture { } /// Exports a container filesystem to a tar archive at `path`. - func doExport(_ name: String, to path: FilePath) throws { + public func doExport(_ name: String, to path: FilePath) throws { try run(["export", name, "-o", path.string]).check() } } @@ -152,7 +152,7 @@ extension ContainerFixture { extension ContainerFixture { /// Returns the parsed inspect output for a container. - func inspectContainer(_ name: String) throws -> InspectOutput { + public func inspectContainer(_ name: String) throws -> InspectOutput { let result = try run(["inspect", name]).check() let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 @@ -164,12 +164,12 @@ extension ContainerFixture { } /// Returns the `status.state` string for a container (e.g. `"running"`, `"stopped"`). - func getContainerStatus(_ name: String) throws -> String { + public func getContainerStatus(_ name: String) throws -> String { try inspectContainer(name).status.state } /// Returns the `configuration.id` for a container. - func getContainerId(_ name: String) throws -> String { + public func getContainerId(_ name: String) throws -> String { try inspectContainer(name).configuration.id } } diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+ImageHelpers.swift b/Sources/ContainerTestSupport/ContainerFixture+ImageHelpers.swift similarity index 74% rename from Tests/IntegrationTests/Utilities/ContainerFixture+ImageHelpers.swift rename to Sources/ContainerTestSupport/ContainerFixture+ImageHelpers.swift index f8c04b152..4dc3a1999 100644 --- a/Tests/IntegrationTests/Utilities/ContainerFixture+ImageHelpers.swift +++ b/Sources/ContainerTestSupport/ContainerFixture+ImageHelpers.swift @@ -21,17 +21,17 @@ import Testing extension ContainerFixture { /// Decoded output of `container image inspect` or `container image list --format json`. - struct ImageInspectOutput: Codable { - struct Configuration: Codable { let name: String } - struct Variant: Codable { - struct Platform: Codable { - let os: String - let architecture: String + public struct ImageInspectOutput: Codable { + public struct Configuration: Codable { public let name: String } + public struct Variant: Codable { + public struct Platform: Codable { + public let os: String + public let architecture: String } - let platform: Platform + public let platform: Platform } - let configuration: Configuration - let variants: [Variant] + public let configuration: Configuration + public let variants: [Variant] } } @@ -40,43 +40,43 @@ extension ContainerFixture { extension ContainerFixture { /// Pulls an image. Passes optional extra args (e.g. `["--platform", "linux/amd64"]`). - func doPull(_ imageName: String, args: [String] = []) throws { + public func doPull(_ imageName: String, args: [String] = []) throws { var pullArgs = ["image", "pull"] + args pullArgs.append(imageName) try run(pullArgs).check() } /// Returns all images currently in the local store. - func doListImages() throws -> [ImageInspectOutput] { + public func doListImages() throws -> [ImageInspectOutput] { let result = try run(["image", "list", "--format", "json"]).check() return try JSONDecoder().decode([ImageInspectOutput].self, from: result.outputData) } /// Returns true if an image with the given exact reference is present. - func isImagePresent(_ targetImage: String) throws -> Bool { + public func isImagePresent(_ targetImage: String) throws -> Bool { try doListImages().contains { $0.configuration.name == targetImage } } /// Tags `image` with `newName`. - func doImageTag(_ image: String, newName: String) throws { + public func doImageTag(_ image: String, newName: String) throws { try run(["image", "tag", image, newName]).check() } /// Removes the given images, or all images when `images` is `nil`. - func doRemoveImages(_ images: [String]? = nil) throws { + public func doRemoveImages(_ images: [String]? = nil) throws { var args = ["image", "rm"] if let images { args.append(contentsOf: images) } else { args.append("--all") } try run(args).check() } /// Returns the full inspect output for an image, including variant information. - func doInspectImages(_ name: String) throws -> [ImageInspectOutput] { + public func doInspectImages(_ name: String) throws -> [ImageInspectOutput] { let result = try run(["image", "inspect", name]).check() return try JSONDecoder().decode([ImageInspectOutput].self, from: result.outputData) } /// Returns the `configuration.name` of an image. - func inspectImage(_ name: String) throws -> String { + public func inspectImage(_ name: String) throws -> String { let outputs = try doInspectImages(name) guard let first = outputs.first else { throw CommandError.executionFailed("image '\(name)' not found in inspect output") @@ -85,7 +85,7 @@ extension ContainerFixture { } /// Asserts that the image was successfully built and is present in the image store. - func assertImageBuilt(_ image: String) throws { + public func assertImageBuilt(_ image: String) throws { let name = try inspectImage(image) #expect(name == image, "expected image \(image) to be present") } diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+MachineHelpers.swift b/Sources/ContainerTestSupport/ContainerFixture+MachineHelpers.swift similarity index 68% rename from Tests/IntegrationTests/Utilities/ContainerFixture+MachineHelpers.swift rename to Sources/ContainerTestSupport/ContainerFixture+MachineHelpers.swift index 2f807bc8f..13e3ca9c7 100644 --- a/Tests/IntegrationTests/Utilities/ContainerFixture+MachineHelpers.swift +++ b/Sources/ContainerTestSupport/ContainerFixture+MachineHelpers.swift @@ -19,38 +19,38 @@ import Testing // MARK: - Machine output types -struct MachineListItem: Codable { - let id: String - let status: String - let `default`: Bool - let ipAddress: String? - let cpus: Int - let memory: UInt64 - let diskSize: UInt64? - let createdDate: Date? +public struct MachineListItem: Codable { + public let id: String + public let status: String + public let `default`: Bool + public let ipAddress: String? + public let cpus: Int + public let memory: UInt64 + public let diskSize: UInt64? + public let createdDate: Date? } -struct MachineInspectOutput: Codable { - let id: String - let image: ImageDescription - let platform: Platform - let status: String - let startedDate: Date? - let createdDate: Date? - let containerId: String? - let cpus: Int - let memory: UInt64 - let homeMount: String? - let diskSize: UInt64? - let ipAddress: String? - - struct ImageDescription: Codable { - let reference: String +public struct MachineInspectOutput: Codable { + public let id: String + public let image: ImageDescription + public let platform: Platform + public let status: String + public let startedDate: Date? + public let createdDate: Date? + public let containerId: String? + public let cpus: Int + public let memory: UInt64 + public let homeMount: String? + public let diskSize: UInt64? + public let ipAddress: String? + + public struct ImageDescription: Codable { + public let reference: String } - struct Platform: Codable { - let os: String - let architecture: String + public struct Platform: Codable { + public let os: String + public let architecture: String } } @@ -59,12 +59,12 @@ struct MachineInspectOutput: Codable { extension ContainerFixture { /// Runs `container machine ` and returns the result. - func runMachine(_ arguments: [String], env: [String: String] = [:]) throws -> CommandResult { + public func runMachine(_ arguments: [String], env: [String: String] = [:]) throws -> CommandResult { try run(["machine"] + arguments, env: env, pty: true) } /// Creates a machine without booting it. - func doMachineCreate(name: String, image: String, extraArgs: [String] = []) throws { + public func doMachineCreate(name: String, image: String, extraArgs: [String] = []) throws { var args = ["create", "--no-boot", "--name", name] args += extraArgs args.append(image) @@ -72,30 +72,30 @@ extension ContainerFixture { } /// Boots a machine by running a trivial command (which auto-boots). - func doMachineBoot(name: String) throws { + public func doMachineBoot(name: String) throws { try runMachine(["run", "--root", "-n", name, "true"]).check() } /// Stops a machine and returns the output (the machine name). @discardableResult - func doMachineStop(name: String) throws -> String { + public func doMachineStop(name: String) throws -> String { let result = try runMachine(["stop", name]).check() return result.output.trimmingCharacters(in: .whitespacesAndNewlines) } /// Removes a machine. - func doMachineRemove(name: String) throws { + public func doMachineRemove(name: String) throws { try runMachine(["rm", name]).check() } /// Silently stops and removes a machine, ignoring errors. - func cleanupMachine(_ name: String) { + public func cleanupMachine(_ name: String) { _ = try? runMachine(["stop", name]) _ = try? runMachine(["rm", name]) } /// Inspects a machine and decodes the JSON output. - func doMachineInspect(name: String? = nil) throws -> MachineInspectOutput { + public func doMachineInspect(name: String? = nil) throws -> MachineInspectOutput { var args = ["inspect"] if let name { args.append(name) } let result = try runMachine(args).check() @@ -110,7 +110,7 @@ extension ContainerFixture { /// Runs a command inside a machine and returns its stdout. @discardableResult - func doMachineRun( + public func doMachineRun( name: String, root: Bool = false, env: [String] = [], @@ -127,7 +127,7 @@ extension ContainerFixture { } /// Polls machine inspect until the status matches or the attempt limit is reached. - func waitForMachineStatus(_ name: String, status: String, maxAttempts: Int = 30) async throws { + public func waitForMachineStatus(_ name: String, status: String, maxAttempts: Int = 30) async throws { for _ in 0.. NetworkInspectOutput { + public func inspectNetwork(_ name: String) throws -> NetworkInspectOutput { let result = try run(["network", "inspect", name]).check() let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 @@ -66,7 +66,7 @@ extension ContainerFixture { } /// Returns an `HTTPClient` for use in network connectivity tests. - func makeHTTPClient() -> HTTPClient { + public func makeHTTPClient() -> HTTPClient { HTTPClient(eventLoopGroupProvider: .singleton) } } diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+SSHTestHelpers.swift b/Sources/ContainerTestSupport/ContainerFixture+SSHTestHelpers.swift similarity index 98% rename from Tests/IntegrationTests/Utilities/ContainerFixture+SSHTestHelpers.swift rename to Sources/ContainerTestSupport/ContainerFixture+SSHTestHelpers.swift index 2786a2dee..477ea60cf 100644 --- a/Tests/IntegrationTests/Utilities/ContainerFixture+SSHTestHelpers.swift +++ b/Sources/ContainerTestSupport/ContainerFixture+SSHTestHelpers.swift @@ -35,7 +35,7 @@ extension ContainerFixture { /// closing the listening fd is what causes the accept loop to exit. /// /// Returns the socket path. Pass it as `SSH_AUTH_SOCK` in the CLI process env. - func makeFakeSSHAgentSocket() throws -> String { + public func makeFakeSSHAgentSocket() throws -> String { let socketDir = "/tmp/\(testID)-ssh" try FileManager.default.createDirectory( atPath: socketDir, withIntermediateDirectories: true, attributes: nil) diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+SystemHelpers.swift b/Sources/ContainerTestSupport/ContainerFixture+SystemHelpers.swift similarity index 91% rename from Tests/IntegrationTests/Utilities/ContainerFixture+SystemHelpers.swift rename to Sources/ContainerTestSupport/ContainerFixture+SystemHelpers.swift index 8657d7630..ea86f59b4 100644 --- a/Tests/IntegrationTests/Utilities/ContainerFixture+SystemHelpers.swift +++ b/Sources/ContainerTestSupport/ContainerFixture+SystemHelpers.swift @@ -24,14 +24,14 @@ import TOML extension ContainerFixture { /// Returns the decoded system configuration from `container system property list`. - func getSystemConfig() throws -> ContainerSystemConfig { + public func getSystemConfig() throws -> ContainerSystemConfig { let result = try run(["system", "property", "list", "--format", "toml"]).check() return try TOMLDecoder().decode(ContainerSystemConfig.self, from: Data(result.output.utf8)) } /// Creates a temporary directory, calls `body` with its URL, then removes it /// regardless of whether `body` throws. - func withTempDir(_ body: (URL) async throws -> T) async throws -> T { + public func withTempDir(_ body: (URL) async throws -> T) async throws -> T { let dir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+VolumeHelpers.swift b/Sources/ContainerTestSupport/ContainerFixture+VolumeHelpers.swift similarity index 85% rename from Tests/IntegrationTests/Utilities/ContainerFixture+VolumeHelpers.swift rename to Sources/ContainerTestSupport/ContainerFixture+VolumeHelpers.swift index 6bf96be59..d70aa4bfe 100644 --- a/Tests/IntegrationTests/Utilities/ContainerFixture+VolumeHelpers.swift +++ b/Sources/ContainerTestSupport/ContainerFixture+VolumeHelpers.swift @@ -21,7 +21,7 @@ import Foundation extension ContainerFixture { /// Creates a named volume, optionally with extra `--opt` arguments. - func doVolumeCreate(_ name: String, opts: [String] = []) throws { + public func doVolumeCreate(_ name: String, opts: [String] = []) throws { var args = ["volume", "create"] for opt in opts { args += ["--opt", opt] } args.append(name) @@ -29,23 +29,23 @@ extension ContainerFixture { } /// Deletes a volume, throwing on failure. - func doVolumeDelete(_ name: String) throws { + public func doVolumeDelete(_ name: String) throws { try run(["volume", "rm", name]).check() } /// Deletes a volume, silently ignoring errors. - func doVolumeDeleteIfExists(_ name: String) { + public func doVolumeDeleteIfExists(_ name: String) { _ = try? run(["volume", "rm", name]) } /// Returns `true` if `volume rm` exits non-zero (i.e. the delete was blocked). - func doesVolumeDeleteFail(_ name: String) throws -> Bool { + public func doesVolumeDeleteFail(_ name: String) throws -> Bool { try run(["volume", "rm", name]).status != 0 } /// Returns the names of all volume attachments on a container /// (the UUID name for anonymous volumes, the explicit name for named volumes). - func getContainerMountedVolumeNames(_ containerName: String) throws -> [String] { + public func getContainerMountedVolumeNames(_ containerName: String) throws -> [String] { let inspect = try inspectContainer(containerName) return inspect.configuration.mounts.compactMap { mount in if case .volume(let name, _, _, _) = mount.type { return name } @@ -54,7 +54,7 @@ extension ContainerFixture { } /// Returns the names of all anonymous volumes (UUID-format names) in the local store. - func getAnonymousVolumeNames() throws -> [String] { + public func getAnonymousVolumeNames() throws -> [String] { let result = try run(["volume", "list", "--quiet"]).check() return result.output .components(separatedBy: .newlines) @@ -63,14 +63,14 @@ extension ContainerFixture { } /// Deletes all currently known anonymous volumes. Useful before count-based assertions. - func doCleanupAnonymousVolumes() { + public func doCleanupAnonymousVolumes() { for vol in (try? getAnonymousVolumeNames()) ?? [] { doVolumeDeleteIfExists(vol) } } /// Returns `true` if a volume with the given name appears in `volume list`. - func volumeExists(_ name: String) throws -> Bool { + public func volumeExists(_ name: String) throws -> Bool { let result = try run(["volume", "list", "--quiet"]).check() return result.output .components(separatedBy: .newlines) diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture.swift b/Sources/ContainerTestSupport/ContainerFixture.swift similarity index 96% rename from Tests/IntegrationTests/Utilities/ContainerFixture.swift rename to Sources/ContainerTestSupport/ContainerFixture.swift index 9b7de8376..0656bff20 100644 --- a/Tests/IntegrationTests/Utilities/ContainerFixture.swift +++ b/Sources/ContainerTestSupport/ContainerFixture.swift @@ -57,13 +57,13 @@ import Testing /// 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 { +public final class ContainerFixture: Sendable { // MARK: - Configuration /// 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] = [ + public static let warmupImages: [String] = [ "ghcr.io/linuxcontainers/alpine:3.20", "ghcr.io/linuxcontainers/alpine:3.18", "ghcr.io/containerd/busybox:1.36", @@ -72,14 +72,14 @@ final class ContainerFixture: Sendable { // MARK: - State /// Short random identifier prefixed to every resource this test creates. - let testID: String + public let testID: String /// Scratch directory for build inputs, test data, and command output. /// Created at fixture init; removed on cleanup unless `CLITEST_PRESERVE_SCRATCH=true`. - let testDir: FilePath + public let testDir: FilePath /// Logger for this fixture scope. Tests may emit diagnostic messages via this logger. - let log: Logger + public let log: Logger // MARK: - Unstructured API @@ -87,7 +87,7 @@ final class ContainerFixture: Sendable { /// /// Cleanup runs in LIFO order regardless of whether `body` throws. @discardableResult - static func with(_ body: (ContainerFixture) async throws -> T) async throws -> T { + public static func with(_ body: (ContainerFixture) async throws -> T) async throws -> T { let testID = String(UUID().uuidString.prefix(8)).lowercased() let scratchRoot = @@ -143,7 +143,7 @@ final class ContainerFixture: Sendable { /// Registers a cleanup closure to run when the fixture scope exits. /// Closures execute in LIFO order. - func addCleanup(_ task: @escaping @Sendable () async throws -> Void) { + public func addCleanup(_ task: @escaping @Sendable () async throws -> Void) { cleanupTasks.withLock { $0.append(task) } } @@ -153,7 +153,7 @@ final class ContainerFixture: Sendable { /// 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( + public func run( _ arguments: [String], stdin: Data? = nil, currentDirectory: FilePath? = nil, @@ -249,7 +249,7 @@ final class ContainerFixture: Sendable { /// 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 { + public 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]) @@ -268,7 +268,7 @@ final class ContainerFixture: Sendable { /// 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) async throws { + public func waitForContainerRunning(_ name: String, attempts: Int = 30) async throws { for _ in 0.. Date: Thu, 2 Jul 2026 16:22:23 -0700 Subject: [PATCH 2/2] Add BuildFixtures to ContainerTestSupport package Signed-off-by: Kathryn Baldauf --- Package.swift | 1 + .../ContainerTestSupport}/BuildFixture.swift | 62 +++++++++---------- 2 files changed, 32 insertions(+), 31 deletions(-) rename {Tests/IntegrationTests/Build => Sources/ContainerTestSupport}/BuildFixture.swift (87%) diff --git a/Package.swift b/Package.swift index 921adcac4..d8c21f26f 100644 --- a/Package.swift +++ b/Package.swift @@ -569,6 +569,7 @@ let package = Package( name: "ContainerTestSupport", dependencies: [ .product(name: "AsyncHTTPClient", package: "async-http-client"), + .product(name: "ContainerizationExtras", package: "containerization"), .product(name: "Logging", package: "swift-log"), .product(name: "SystemPackage", package: "swift-system"), .product(name: "TOML", package: "swift-toml"), diff --git a/Tests/IntegrationTests/Build/BuildFixture.swift b/Sources/ContainerTestSupport/BuildFixture.swift similarity index 87% rename from Tests/IntegrationTests/Build/BuildFixture.swift rename to Sources/ContainerTestSupport/BuildFixture.swift index 664a9b8db..9beca8008 100644 --- a/Tests/IntegrationTests/Build/BuildFixture.swift +++ b/Sources/ContainerTestSupport/BuildFixture.swift @@ -14,7 +14,6 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerTestSupport import ContainerizationExtras import Darwin import Foundation @@ -25,7 +24,7 @@ import Testing extension ContainerFixture { /// A file-system entry to materialize inside a build context directory. - enum FileSystemEntry { + public enum FileSystemEntry { case file( _ path: String, content: FileEntryContent, @@ -42,22 +41,23 @@ extension ContainerFixture { case symbolicLink(_ path: String, target: String, uid: uid_t = 0, gid: gid_t = 0) } - enum FileEntryContent { + public enum FileEntryContent { case zeroFilled(size: Int64) case data(Data) } - struct FilePermissions: OptionSet { - let rawValue: UInt16 - static let r = FilePermissions(rawValue: 0o400) - static let w = FilePermissions(rawValue: 0o200) - static let x = FilePermissions(rawValue: 0o100) - static let gr = FilePermissions(rawValue: 0o040) - static let gw = FilePermissions(rawValue: 0o020) - static let gx = FilePermissions(rawValue: 0o010) - static let or = FilePermissions(rawValue: 0o004) - static let ow = FilePermissions(rawValue: 0o002) - static let ox = FilePermissions(rawValue: 0o001) + public struct FilePermissions: OptionSet, Sendable { + public let rawValue: UInt16 + public init(rawValue: UInt16) { self.rawValue = rawValue } + public static let r = FilePermissions(rawValue: 0o400) + public static let w = FilePermissions(rawValue: 0o200) + public static let x = FilePermissions(rawValue: 0o100) + public static let gr = FilePermissions(rawValue: 0o040) + public static let gw = FilePermissions(rawValue: 0o020) + public static let gx = FilePermissions(rawValue: 0o010) + public static let or = FilePermissions(rawValue: 0o004) + public static let ow = FilePermissions(rawValue: 0o002) + public static let ox = FilePermissions(rawValue: 0o001) } } @@ -66,24 +66,24 @@ extension ContainerFixture { extension ContainerFixture { /// Starts the buildkit builder container. - func builderStart(cpus: Int64 = 2, memoryInGBs: Int64 = 2) throws { + public func builderStart(cpus: Int64 = 2, memoryInGBs: Int64 = 2) throws { try run(["builder", "start", "-c", "\(cpus)", "-m", "\(memoryInGBs)GB"]).check() } /// Stops the buildkit builder container. - func builderStop() throws { + public func builderStop() throws { try run(["builder", "stop"]).check() } /// Deletes the buildkit builder container. - func builderDelete(force: Bool = false) throws { + public func builderDelete(force: Bool = false) throws { var args = ["builder", "delete"] if force { args.append("--force") } try run(args).check() } /// Polls until the buildkit container is running and the builder shim is ready. - func waitForBuilderRunning() async throws { + public func waitForBuilderRunning() async throws { try await waitForContainerRunning("buildkit", attempts: 10) for _ in 0..<3 { let response = try? doExec("buildkit", cmd: ["pidof", "-s", "container-builder-shim"]) @@ -100,7 +100,7 @@ extension ContainerFixture { /// Each build test gets an isolated builder to avoid inter-test contamination. /// Acquires a process-wide lock so only one test holds the buildkit singleton at a time, /// regardless of how many suites run concurrently in the global pass. - func withBuilder( + public func withBuilder( cpus: Int64 = 2, memoryInGBs: Int64 = 2, _ body: @Sendable (ContainerFixture) async throws -> Void @@ -121,7 +121,7 @@ extension ContainerFixture { /// Use this in tests that manually manage the builder lifecycle (e.g. lifecycle /// tests that call ``builderStart()``/``builderStop()`` directly) so they /// serialise correctly with tests that use ``withBuilder(_:)``. - func withBuilderLock(_ body: @Sendable () async throws -> T) async throws -> T { + public func withBuilderLock(_ body: @Sendable () async throws -> T) async throws -> T { try await withoutActuallyEscaping(body) { escapingBody in try await Self.builderLock.withLock { _ in try await escapingBody() @@ -139,7 +139,7 @@ extension ContainerFixture { /// Creates a new scratch directory under ``testDir`` and returns its path. /// /// The directory is removed automatically when the fixture scope exits. - func createTempDir() throws -> FilePath { + public func createTempDir() throws -> FilePath { let dir = testDir.appending(UUID().uuidString) try FileManager.default.createDirectory( atPath: dir.string, withIntermediateDirectories: true, attributes: nil) @@ -147,7 +147,7 @@ extension ContainerFixture { } /// Writes `contents` to a new file under ``testDir`` with the given suffix. - func createTempFile(suffix: String, contents: Data) throws -> FilePath { + public func createTempFile(suffix: String, contents: Data) throws -> FilePath { let file = testDir.appending(UUID().uuidString + suffix) try contents.write(to: URL(filePath: file.string), options: .atomic) return file @@ -157,7 +157,7 @@ extension ContainerFixture { /// /// Creates `dir/Dockerfile` (if `dockerfile` is non-empty) and /// `dir/context/` populated with `context` entries. - func createContext(dir: FilePath, dockerfile: String, context: [FileSystemEntry]? = nil) throws { + public func createContext(dir: FilePath, dockerfile: String, context: [FileSystemEntry]? = nil) throws { if !dockerfile.isEmpty { try Data(dockerfile.utf8).write(to: URL(filePath: dir.appending("Dockerfile").string), options: .atomic) } @@ -170,7 +170,7 @@ extension ContainerFixture { } /// Materializes a ``FileSystemEntry`` inside `contextDir`. - func createEntry(_ entry: FileSystemEntry, contextDir: FilePath) throws { + public func createEntry(_ entry: FileSystemEntry, contextDir: FilePath) throws { switch entry { case .file(let path, let content, let permissions, let uid, let gid): let fullPath = appendingRelative(contextDir, path) @@ -243,7 +243,7 @@ extension ContainerFixture { /// Builds an image from `contextDir/Dockerfile` with context `contextDir/context/`. @discardableResult - func build( + public func build( tag: String, contextDir: FilePath = FilePath("."), buildArgs: [String] = [], @@ -261,7 +261,7 @@ extension ContainerFixture { /// - `contextDir` defaults to the current directory (`.`) /// - `dockerfilePath` defaults to `nil`, resolved to `contextDir/Dockerfile` at call time @discardableResult - func buildWithPaths( + public func buildWithPaths( tags: [String] = [], contextDir: FilePath = FilePath("."), dockerfilePath: FilePath? = nil, @@ -285,7 +285,7 @@ extension ContainerFixture { /// Builds with a Dockerfile read from stdin. @discardableResult - func buildWithStdin( + public func buildWithStdin( tags: [String], contextDir: FilePath, dockerfileContents: String, @@ -308,7 +308,7 @@ extension ContainerFixture { /// Builds with `--output type=local,dest=`. @discardableResult - func buildWithPathsAndLocalOutput( + public func buildWithPathsAndLocalOutput( tag: String, contextDir: FilePath = FilePath("."), dockerfilePath: FilePath? = nil, @@ -338,18 +338,18 @@ extension ContainerFixture { extension ContainerFixture { /// Returns true if `path` exists as a regular file inside `container`. - func containerHasFile(_ container: String, at path: String) throws -> Bool { + public func containerHasFile(_ container: String, at path: String) throws -> Bool { try run(["exec", container, "test", "-f", path]).status == 0 } /// Asserts that `path` exists as a regular file inside `container`. - func assertContainerHasFile(_ container: String, at path: String, _ comment: String? = nil) throws { + public func assertContainerHasFile(_ container: String, at path: String, _ comment: String? = nil) throws { let exists = try containerHasFile(container, at: path) #expect(exists, "\(comment ?? path) should exist in container") } /// Asserts that `path` does NOT exist inside `container`. - func assertContainerMissingFile(_ container: String, at path: String, _ comment: String? = nil) throws { + public func assertContainerMissingFile(_ container: String, at path: String, _ comment: String? = nil) throws { let exists = try containerHasFile(container, at: path) #expect(!exists, "\(comment ?? path) should NOT exist in container") }