//===----------------------------------------------------------------------===// // 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 ContainerBuildReporting import ContainerizationOCI import Foundation /// Errors that can occur during graph building public enum GraphBuilderError: Error, LocalizedError { case noActiveStage case invalidOperation(String) case missingDependency(UUID) public var errorDescription: String? { switch self { case .noActiveStage: return "No active stage. Call stage() or scratch() first." case .invalidOperation(let message): return "Invalid operation: \(message)" case .missingDependency(let id): return "Missing dependency with ID: \(id)" } } } /// Builder for constructing build graphs. /// /// Design rationale: /// - Fluent API for easy graph construction /// - Validates as you build to catch errors early /// - Handles dependency resolution automatically /// - Supports incremental construction public final class GraphBuilder { private var stages: [BuildStage] = [] var currentStage: StageBuilder? var buildArgs: [String: String] = [:] private var targetPlatforms: Set = [] private var metadata = BuildGraphMetadata() private let graphAnalyzers: [any GraphAnalyzer] private let stageAnalyzers: [any StageAnalyzer] private let reporter: Reporter? /// Public initializer with optional reporter public init(reporter: Reporter? = nil) { self.graphAnalyzers = Self.defaultGraphAnalyzers self.stageAnalyzers = Self.defaultStageAnalyzers self.reporter = reporter } /// Internal initializer for custom analyzers internal init( graphAnalyzers: [any GraphAnalyzer], stageAnalyzers: [any StageAnalyzer], reporter: Reporter? = nil ) { self.graphAnalyzers = graphAnalyzers self.stageAnalyzers = stageAnalyzers self.reporter = reporter } /// Default graph analyzers private nonisolated(unsafe) static let defaultGraphAnalyzers: [any GraphAnalyzer] = [ DependencyAnalyzer(), ValidatorAnalyzer(validator: StandardValidator()), SemanticAnalyzer(), ] /// Default stage analyzers (empty for now) private nonisolated(unsafe) static let defaultStageAnalyzers: [any StageAnalyzer] = [] /// Start a new stage @discardableResult public func stage( name: String? = nil, from image: ImageReference, platform: Platform? = nil ) throws -> Self { // Finish current stage if any if let current = currentStage { stages.append(try current.build()) } // Report stage creation if let reporter = reporter { Task { await reporter.report( .irEvent( context: ReportContext( stageId: name, description: "Creating stage \(name ?? "unnamed") from \(image)", sourceMap: nil ), type: .stageAdded )) } } // Create new stage let imageOp = ImageOperation( source: .registry(image), platform: platform ) currentStage = StageBuilder( name: name, base: imageOp, platform: platform, analyzers: stageAnalyzers, reporter: reporter ) return self } /// Start a stage from scratch @discardableResult public func scratch(name: String? = nil) throws -> Self { if let current = currentStage { stages.append(try current.build()) } // Report stage creation if let reporter = reporter { Task { await reporter.report( .irEvent( context: ReportContext( stageId: name, description: "Creating stage \(name ?? "unnamed") from scratch", sourceMap: nil ), type: .stageAdded )) } } let imageOp = ImageOperation(source: .scratch) currentStage = StageBuilder( name: name, base: imageOp, analyzers: stageAnalyzers, reporter: reporter ) return self } /// Add an operation to current stage @discardableResult public func add(_ operation: any Operation, dependsOn: [UUID] = []) throws -> Self { guard let current = currentStage else { throw GraphBuilderError.noActiveStage } current.add(operation, dependsOn: Set(dependsOn)) return self } /// Add a RUN operation @discardableResult public func run( _ command: String, shell: Bool = true, env: [String: String] = [:], workdir: String? = nil, user: User? = nil, mounts: [Mount] = [], network: NetworkMode = .default, ) throws -> Self { let cmd = shell ? Command.shell(command) : Command.exec(command.split(separator: " ").map(String.init)) return try runWithCmd(cmd, shell: shell, env: env, workdir: workdir, user: user, mounts: mounts, network: network) } @discardableResult public func runWithCmd( _ cmd: Command, shell: Bool = true, env: [String: String] = [:], workdir: String? = nil, user: User? = nil, mounts: [Mount] = [], network: NetworkMode = .default, ) throws -> Self { let envVars = env.map { (key: $0.key, value: EnvironmentValue.literal($0.value)) } let operation = ExecOperation( command: cmd, environment: Environment(envVars), mounts: mounts, workingDirectory: workdir, user: user, network: network, ) return try add(operation) } /// Add a COPY operation @discardableResult public func copy( from source: FilesystemSource, to destination: String, chown: Ownership? = nil, chmod: Permissions? = nil ) throws -> Self { let operation = FilesystemOperation( action: .copy, source: source, destination: destination, fileMetadata: FileMetadata( ownership: chown, permissions: chmod ?? .preserve ) ) return try add(operation) } /// Add COPY from context @discardableResult public func copyFromContext( name: String = "default", paths: [String], to destination: String, chown: Ownership? = nil, chmod: Permissions? = nil ) throws -> Self { try copy( from: .context(ContextSource(name: name, paths: paths)), to: destination, chown: chown, chmod: chmod ) } /// Add COPY from stage @discardableResult public func copyFromStage( _ stage: StageReference, paths: [String], to destination: String, chown: Ownership? = nil, chmod: Permissions? = nil ) throws -> Self { try copy( from: .stage(stage, paths: paths), to: destination, chown: chown, chmod: chmod ) } /// Set environment variable @discardableResult public func env(_ key: String, _ value: String) throws -> Self { let operation = MetadataOperation( action: .setEnv(key: key, value: .literal(value)) ) return try add(operation) } /// Set working directory @discardableResult public func workdir(_ path: String) throws -> Self { let operation = MetadataOperation(action: .setWorkdir(path: path)) return try add(operation) } /// Set user @discardableResult public func user(_ user: User) throws -> Self { let operation = MetadataOperation(action: .setUser(user: user)) return try add(operation) } /// Add label @discardableResult public func label(_ key: String, _ value: String) throws -> Self { let operation = MetadataOperation( action: .setLabel(key: key, value: value) ) return try add(operation) } @discardableResult public func labelBatch(labels: [String: String]) throws -> Self { let operation = MetadataOperation( action: .setLabelBatch(labels) ) return try add(operation) } /// Expose port @discardableResult public func expose(_ ports: [PortSpec]) throws -> Self { let operation = MetadataOperation( action: .expose(ports: ports) ) return try add(operation) } /// Set entrypoint @discardableResult public func entrypoint(_ command: Command) throws -> Self { let operation = MetadataOperation(action: .setEntrypoint(command: command)) return try add(operation) } /// Set CMD @discardableResult public func cmd(_ command: Command) throws -> Self { let operation = MetadataOperation(action: .setCmd(command: command)) return try add(operation) } /// Set healthcheck @discardableResult public func healthcheck( test: HealthcheckTest, interval: TimeInterval? = nil, timeout: TimeInterval? = nil, startPeriod: TimeInterval? = nil, retries: Int? = nil ) throws -> Self { let healthcheck = Healthcheck( test: test, interval: interval, timeout: timeout, startPeriod: startPeriod, retries: retries ) let operation = MetadataOperation(action: .setHealthcheck(healthcheck: healthcheck)) return try add(operation) } /// Set target platforms @discardableResult public func platforms(_ platforms: Platform...) -> Self { targetPlatforms = Set(platforms) return self } /// Set metadata @discardableResult public func metadata( sourceFile: String? = nil, contextPath: String? = nil, frontend: String? = nil ) -> Self { if let sourceFile = sourceFile { metadata = BuildGraphMetadata( sourceFile: sourceFile, contextPath: contextPath ?? metadata.contextPath, frontend: frontend ?? metadata.frontend ) } return self } /// Build the final graph public func build() throws -> BuildGraph { // Report build start if let reporter = reporter { let stageCount = stages.count Task { await reporter.report( .irEvent( context: ReportContext( description: "Building graph with \(stageCount) stages", sourceMap: nil ), type: .graphStarted )) } } // Add final stage if any if let current = currentStage { stages.append(try current.build()) currentStage = nil } // Create initial graph var graph = try BuildGraph( stages: stages, buildArgs: buildArgs, targetPlatforms: targetPlatforms, metadata: metadata ) // Create analysis context let analysisContext = AnalysisContext(reporter: reporter) // Run all graph analyzers in sequence for analyzer in graphAnalyzers { let analyzerName = String(describing: type(of: analyzer)) // Report analyzer start if let reporter = reporter { Task { await reporter.report( .irEvent( context: ReportContext( description: "Running \(analyzerName)", sourceMap: nil ), type: .analyzing )) } } graph = try analyzer.analyze(graph, context: analysisContext) } // Report completion if let reporter = reporter { let totalNodes = graph.stages.reduce(0) { $0 + $1.nodes.count } let stageCount = graph.stages.count Task { await reporter.report( .irEvent( context: ReportContext( description: "Graph built: \(stageCount) stages, \(totalNodes) nodes", sourceMap: nil ), type: .graphCompleted )) } } return graph } } // MARK: - Convenience Extensions extension GraphBuilder { /// Create a simple single-stage build public static func singleStage( name: String? = nil, from image: ImageReference, platform: Platform? = nil, reporter: Reporter? = nil, _ configure: (GraphBuilder) throws -> Void ) throws -> BuildGraph { let builder = GraphBuilder(reporter: reporter) if let platform = platform { builder.platforms(platform) } try builder.stage(name: name, from: image, platform: platform) try configure(builder) return try builder.build() } /// Create a multi-stage build public static func multiStage( reporter: Reporter? = nil, _ configure: (GraphBuilder) throws -> Void ) throws -> BuildGraph { let builder = GraphBuilder(reporter: reporter) try configure(builder) return try builder.build() } public func getStage(stageName: String) -> BuildStage? { for s in self.stages { if s.name == stageName { return s } } return nil } }