//===----------------------------------------------------------------------===// // Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. // // 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 ContainerBuildIR import ContainerBuildReporting import ContainerBuildSnapshotter import Foundation import Testing @testable import ContainerBuildExecutor struct ExecutionDispatcherTests { @Test func dispatcherRouting() async throws { // Create test executors with different capabilities let execExecutor = ExecOperationExecutor() let fsExecutor = FilesystemOperationExecutor() let metadataExecutor = MetadataOperationExecutor() let dispatcher = ExecutionDispatcher(executors: [ execExecutor, fsExecutor, metadataExecutor, ]) // Create test context let context = try createTestContext() // Test exec operation routing let execOp = ExecOperation( command: .shell("echo test"), environment: .empty, mounts: [], workingDirectory: nil, user: nil, network: .default, security: .default, metadata: OperationMetadata() ) let execResult = try await dispatcher.dispatch(execOp, context: context) #expect(execResult.duration > 0) // Test filesystem operation routing let fsOp = FilesystemOperation( action: .copy, source: .context(ContextSource(paths: ["test.txt"])), destination: "/app/test.txt", fileMetadata: FileMetadata(), options: FilesystemOptions(), metadata: OperationMetadata() ) let _ = try await dispatcher.dispatch(fsOp, context: context) // Test metadata operation routing let metadataOp = MetadataOperation( action: .setEnv(key: "TEST", value: .literal("value")), metadata: OperationMetadata() ) let metadataResult = try await dispatcher.dispatch(metadataOp, context: context) #expect(metadataResult.environmentChanges["TEST"] == EnvironmentValue.literal("value")) } @Test func capabilityMatching() async throws { // Create a custom executor with specific capabilities struct PrivilegedExecutor: OperationExecutor { let capabilities = ExecutorCapabilities( supportedOperations: [.exec], requiresPrivileged: true, maxConcurrency: 1 ) func execute(_ operation: ContainerBuildIR.Operation, context: ExecutionContext) async throws -> ExecutionResult { let digest = try! Digest(algorithm: .sha256, bytes: Data(count: 32)) let mountPoint = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let snapshot = Snapshot( digest: digest, size: 0, state: .prepared(mountpoint: mountPoint) ) return ExecutionResult( environmentChanges: [:], metadataChanges: [:], snapshot: snapshot, duration: 0.001 ) } func canExecute(_ operation: ContainerBuildIR.Operation) -> Bool { operation is ExecOperation } } let regularExecutor = ExecOperationExecutor() let privilegedExecutor = PrivilegedExecutor() let dispatcher = ExecutionDispatcher(executors: [ regularExecutor, privilegedExecutor, ]) let context = try createTestContext() let execOp = ExecOperation( command: .shell("privileged command"), environment: .empty, mounts: [], workingDirectory: nil, user: nil, network: .default, security: .default, metadata: OperationMetadata() ) // Without constraints, should use regular executor _ = try await dispatcher.dispatch(execOp, context: context) // With privileged constraint, should use privileged executor let constraints = NodeConstraints(requiresPrivileged: true) _ = try await dispatcher.dispatch( execOp, context: context, constraints: constraints ) } @Test func unsupportedOperation() async throws { // Create dispatcher with limited executors let dispatcher = ExecutionDispatcher(executors: [ ExecOperationExecutor() ]) let context = try createTestContext() // Try to dispatch an unsupported operation struct CustomOperation: ContainerBuildIR.Operation { static let operationKind = OperationKind(rawValue: "custom") var operationKind: OperationKind { Self.operationKind } let metadata: OperationMetadata = OperationMetadata() func accept(_ visitor: V) throws -> V.Result { try visitor.visitUnknown(self) } } let customOp = CustomOperation() await #expect(throws: (any Error).self) { try await dispatcher.dispatch(customOp, context: context) } } @Test func concurrencyLimiting() async throws { // Create executor with limited concurrency struct SlowExecutor: OperationExecutor { let capabilities = ExecutorCapabilities( supportedOperations: [.exec], maxConcurrency: 2 ) func execute(_ operation: ContainerBuildIR.Operation, context: ExecutionContext) async throws -> ExecutionResult { // Simulate slow operation try await Task.sleep(nanoseconds: 100_000_000) // 100ms let digest = try! Digest(algorithm: .sha256, bytes: Data(count: 32)) let mountPoint = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let snapshot = Snapshot( digest: digest, size: 0, state: .prepared(mountpoint: mountPoint) ) return ExecutionResult( environmentChanges: [:], metadataChanges: [:], snapshot: snapshot, duration: 0.1 ) } func canExecute(_ operation: ContainerBuildIR.Operation) -> Bool { operation is ExecOperation } } let dispatcher = ExecutionDispatcher(executors: [SlowExecutor()]) let context = try createTestContext() // Create multiple operations let operations = (0..<5).map { i in ExecOperation( command: .shell("echo \(i)"), environment: .empty, mounts: [], workingDirectory: nil, user: nil, network: .default, security: .default, metadata: OperationMetadata() ) } // Dispatch all operations concurrently let startTime = Date() try await withThrowingTaskGroup(of: Void.self) { group in for op in operations { group.addTask { _ = try await dispatcher.dispatch(op, context: context) } } try await group.waitForAll() } let duration = Date().timeIntervalSince(startTime) // With max concurrency 2 and 5 operations at 100ms each, // should take at least 300ms (3 batches) #expect(duration > 0.25) } // MARK: - Helpers private func createTestContext() throws -> ExecutionContext { let stage = BuildStage( id: UUID(), name: "test", base: ImageOperation( source: .scratch, platform: nil, pullPolicy: .ifNotPresent, verification: nil, metadata: OperationMetadata() ), nodes: [], platform: nil ) let graph = try BuildGraph( stages: [stage], buildArgs: [:], targetPlatforms: [.linuxAMD64], metadata: BuildGraphMetadata() ) let mockSnapshotter = MockSnapshotter() return ExecutionContext( stage: stage, graph: graph, platform: .linuxAMD64, reporter: Reporter(), snapshotter: mockSnapshotter ) } }