Files
container/Sources/NativeBuilder/ContainerBuildIR/Graph/GraphBuilder.swift
T
Dmitry Kovba 9a597eb6aa Add the support for ARG in the native builder parser (#516)
This PR adds the support for the ARG instruction in the native builder
parser, implements the support for different kinds of ARGs, and performs
the substitution of ARG variables in the supported instructions.
Resolves https://github.com/apple/container/issues/437.

Some features are currently blocked and not included into this PR:
- [Native builder: add the current target platform we're building use it
to set automatic platform ARGs
#522](https://github.com/apple/container/issues/522)
- [Native builder: add the support for the BuildKit built-in ARGs
#523](https://github.com/apple/container/issues/523)
- [Native builder: ensure pre-defined ARGs are excluded from the output
of the history #524](https://github.com/apple/container/issues/524)
- [Native builder: add the support for stage references and ARG
inheritance #525](https://github.com/apple/container/issues/525)
2025-08-20 14:57:24 -07:00

484 lines
15 KiB
Swift

//===----------------------------------------------------------------------===//
// 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<Platform> = []
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
}
}