Files
Kathryn Baldauf 16f2630126 Add initial native builder code (#399)
We're working on making a pure swift container image build system that
leverages containerization. This PR represents our initial design and
initial work towards this goal.

The native builder is still in active development and most of the
implementation has not been started or completed. We will be opening a
series of issues that represent various (but not necessarily all) pieces
of work that need to be done here.

There are docs included in this PR that describe the overall design of
each component and outline some of our goals. The easiest way to view
the docs by themselves (since this is a massive PR) is to look at the
docs commit in the `Commits` tab.

We'd love any feedback! 

@wlan0

---------

Signed-off-by: Kathryn Baldauf <k_baldauf@apple.com>
2025-07-31 13:13:20 -07:00

483 lines
18 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 Foundation
/// Performs semantic analysis on build graphs.
///
/// Design rationale:
/// - Goes beyond structural validation to understand intent
/// - Provides optimization suggestions
/// - Detects common patterns and anti-patterns
public struct SemanticAnalyzer: GraphAnalyzer {
/// Initialize a new semantic analyzer.
public init() {}
/// Analyze a build graph and report issues via the reporter
public func analyze(_ graph: BuildGraph, context: AnalysisContext) throws -> BuildGraph {
// Perform the analysis
let analysis = performAnalysis(graph)
// Report findings via the reporter
reportFindings(analysis, context: context)
// Return the graph unchanged (semantic analyzer doesn't transform)
return graph
}
/// Perform semantic analysis on the graph
private func performAnalysis(_ graph: BuildGraph) -> SemanticAnalysis {
let layerAnalysis = analyzeLayerEfficiency(graph)
let cacheAnalysis = analyzeCacheability(graph)
let securityAnalysis = analyzeSecurityPosture(graph)
let sizeAnalysis = analyzeSizeOptimizations(graph)
return SemanticAnalysis(
layerEfficiency: layerAnalysis,
cacheability: cacheAnalysis,
security: securityAnalysis,
sizeOptimizations: sizeAnalysis
)
}
// MARK: - Layer Efficiency
private func analyzeLayerEfficiency(_ graph: BuildGraph) -> LayerEfficiencyAnalysis {
var issues: [LayerIssue] = []
for stage in graph.stages {
// Check for operations that could be combined
let _ = stage.nodes.compactMap { $0.operation as? ExecOperation }
// Detect multiple package manager invocations
var packageManagerCalls: [(command: String, node: BuildNode)] = []
for (_, node) in stage.nodes.enumerated() {
if let execOp = node.operation as? ExecOperation,
case .shell(let cmd) = execOp.command
{
if cmd.contains("apt-get") || cmd.contains("yum") || cmd.contains("apk") || cmd.contains("dnf") {
packageManagerCalls.append((cmd, node))
}
}
}
if packageManagerCalls.count > 1 {
issues.append(
LayerIssue(
type: .multipleLayers,
description: "Multiple package manager invocations create separate layers",
suggestion: "Combine package installations into a single RUN command",
estimatedImpact: .high
))
}
// Check for file operations followed by deletions
for (index, node) in stage.nodes.enumerated() {
if let fsOp = node.operation as? FilesystemOperation,
fsOp.action == .copy || fsOp.action == .add
{
// Look for subsequent removal
for nextNode in stage.nodes[(index + 1)...] {
if let nextFs = nextNode.operation as? FilesystemOperation,
nextFs.action == .remove
{
issues.append(
LayerIssue(
type: .unnecessaryFiles,
description: "Files added then removed still consume layer space",
suggestion: "Avoid adding files that will be deleted",
estimatedImpact: .medium
))
}
}
}
}
}
return LayerEfficiencyAnalysis(issues: issues)
}
// MARK: - Cache Analysis
private func analyzeCacheability(_ graph: BuildGraph) -> CacheabilityAnalysis {
var invalidators: [CacheInvalidator] = []
for stage in graph.stages {
// Check for operations that frequently invalidate cache
for (index, node) in stage.nodes.enumerated() {
if let fsOp = node.operation as? FilesystemOperation {
switch fsOp.source {
case .context(let source):
// Copying entire context early invalidates cache
if source.paths.contains(".") && index < stage.nodes.count / 2 {
invalidators.append(
CacheInvalidator(
operation: "COPY . .",
reason: "Copying entire context early in build",
suggestion: "Copy only necessary files or move COPY . . later"
))
}
default:
break
}
}
// Dynamic commands that change frequently
if let execOp = node.operation as? ExecOperation,
case .shell(let cmd) = execOp.command
{
if cmd.contains("date") || cmd.contains("timestamp") || cmd.contains("git rev-parse") {
invalidators.append(
CacheInvalidator(
operation: cmd,
reason: "Command output changes frequently",
suggestion: "Use build args for dynamic values"
))
}
}
}
}
return CacheabilityAnalysis(
cacheInvalidators: invalidators,
estimatedCacheHitRate: invalidators.isEmpty ? 0.8 : 0.3
)
}
// MARK: - Security Analysis
private func analyzeSecurityPosture(_ graph: BuildGraph) -> SecurityAnalysis {
var findings: [SecurityFinding] = []
for stage in graph.stages {
var currentUser: User?
var hasUserSwitch = false
for node in stage.nodes {
// Track user context
if let metaOp = node.operation as? MetadataOperation,
case .setUser(let user) = metaOp.action
{
currentUser = user
hasUserSwitch = true
}
// Check for security issues in exec operations
if let execOp = node.operation as? ExecOperation {
// Running privileged without user switch
if execOp.security.privileged && !hasUserSwitch {
findings.append(
SecurityFinding(
severity: .high,
type: .privilegedExecution,
description: "Privileged execution as root",
remediation: "Switch to non-root user after privileged operations"
))
}
// Downloading without verification
if case .shell(let cmd) = execOp.command {
if (cmd.contains("curl") || cmd.contains("wget")) && !cmd.contains("--verify") && !cmd.contains("sha256") {
findings.append(
SecurityFinding(
severity: .medium,
type: .unverifiedDownload,
description: "Downloading files without verification",
remediation: "Add checksum verification for downloaded files"
))
}
// Installing packages without pinning versions
if cmd.contains("install") && !cmd.contains("=") && (cmd.contains("apt-get") || cmd.contains("pip")) {
findings.append(
SecurityFinding(
severity: .low,
type: .unpinnedDependencies,
description: "Installing packages without version pinning",
remediation: "Pin package versions for reproducible builds"
))
}
}
}
}
// Final user check
if currentUser == nil && stage == graph.targetStage {
findings.append(
SecurityFinding(
severity: .high,
type: .rootUser,
description: "Container runs as root by default",
remediation: "Add USER instruction to run as non-root"
))
}
}
return SecurityAnalysis(findings: findings)
}
// MARK: - Size Optimization
private func analyzeSizeOptimizations(_ graph: BuildGraph) -> SizeOptimizationAnalysis {
var opportunities: [SizeOptimization] = []
for stage in graph.stages {
var hasCleanup = false
for node in stage.nodes {
if let execOp = node.operation as? ExecOperation,
case .shell(let cmd) = execOp.command
{
// Check for package manager cleanup
if cmd.contains("apt-get install") && !cmd.contains("rm -rf /var/lib/apt/lists/*") {
opportunities.append(
SizeOptimization(
type: .packageManagerCache,
description: "Package manager cache not cleaned",
estimatedSavingMB: 50,
suggestion: "Add && rm -rf /var/lib/apt/lists/* after apt-get install"
))
}
// Check for build dependencies
if cmd.contains("build-essential") || cmd.contains("-dev") {
let isMultiStage = graph.stages.count > 1
if !isMultiStage {
opportunities.append(
SizeOptimization(
type: .buildDependencies,
description: "Build dependencies included in final image",
estimatedSavingMB: 200,
suggestion: "Use multi-stage build to exclude build dependencies"
))
}
}
if cmd.contains("rm") || cmd.contains("clean") {
hasCleanup = true
}
}
}
// Check for cleanup in separate layers
if hasCleanup {
opportunities.append(
SizeOptimization(
type: .separateCleanupLayer,
description: "Cleanup in separate RUN creates new layer",
estimatedSavingMB: 0,
suggestion: "Combine cleanup with installation in same RUN"
))
}
}
return SizeOptimizationAnalysis(opportunities: opportunities)
}
}
// MARK: - Analysis Results
/// Complete semantic analysis results.
public struct SemanticAnalysis {
public let layerEfficiency: LayerEfficiencyAnalysis
public let cacheability: CacheabilityAnalysis
public let security: SecurityAnalysis
public let sizeOptimizations: SizeOptimizationAnalysis
/// Overall health score (0-100)
public var healthScore: Int {
var score = 100
// Deduct for layer issues
score -= layerEfficiency.issues.count * 5
// Deduct for cache invalidators
score -= cacheability.cacheInvalidators.count * 10
// Deduct for security findings
for finding in security.findings {
switch finding.severity {
case .high: score -= 20
case .medium: score -= 10
case .low: score -= 5
}
}
// Deduct for size opportunities
score -= min(sizeOptimizations.totalPotentialSavingMB / 50, 20)
return max(0, score)
}
}
/// Layer efficiency analysis.
public struct LayerEfficiencyAnalysis {
public let issues: [LayerIssue]
}
public struct LayerIssue {
public enum IssueType {
case multipleLayers
case unnecessaryFiles
case largeLayer
case inefficientOrdering
}
public let type: IssueType
public let description: String
public let suggestion: String
public let estimatedImpact: Impact
}
public enum Impact {
case low, medium, high
}
/// Cache analysis results.
public struct CacheabilityAnalysis {
public let cacheInvalidators: [CacheInvalidator]
public let estimatedCacheHitRate: Double
}
public struct CacheInvalidator {
public let operation: String
public let reason: String
public let suggestion: String
}
/// Security analysis results.
public struct SecurityAnalysis {
public let findings: [SecurityFinding]
}
public struct SecurityFinding {
public enum Severity {
case low, medium, high
}
public enum FindingType {
case rootUser
case privilegedExecution
case unverifiedDownload
case unpinnedDependencies
case exposedSecrets
}
public let severity: Severity
public let type: FindingType
public let description: String
public let remediation: String
}
/// Size optimization analysis.
public struct SizeOptimizationAnalysis {
public let opportunities: [SizeOptimization]
public var totalPotentialSavingMB: Int {
opportunities.reduce(0) { $0 + $1.estimatedSavingMB }
}
}
public struct SizeOptimization {
public enum OptimizationType {
case packageManagerCache
case buildDependencies
case unnecessaryFiles
case separateCleanupLayer
case duplicateFiles
}
public let type: OptimizationType
public let description: String
public let estimatedSavingMB: Int
public let suggestion: String
}
// MARK: - Reporting
extension SemanticAnalyzer {
private func reportFindings(_ analysis: SemanticAnalysis, context: AnalysisContext) {
guard let reporter = context.reporter else { return }
// Report layer efficiency issues
for issue in analysis.layerEfficiency.issues {
let description = "\(issue.description). \(issue.suggestion)"
let eventType: IREventType = issue.estimatedImpact == .high ? .error : .warning
Task {
await reporter.report(
.irEvent(
context: ReportContext(
description: description,
sourceMap: nil
),
type: eventType
))
}
}
// Report security findings
for finding in analysis.security.findings {
let description = "\(finding.description). \(finding.remediation)"
let eventType: IREventType = finding.severity == .high ? .error : .warning
Task {
await reporter.report(
.irEvent(
context: ReportContext(
description: description,
sourceMap: nil
),
type: eventType
))
}
}
// Report cache invalidators
for invalidator in analysis.cacheability.cacheInvalidators {
let description = "Cache invalidator: \(invalidator.reason). \(invalidator.suggestion)"
Task {
await reporter.report(
.irEvent(
context: ReportContext(
description: description,
sourceMap: nil
),
type: .warning
))
}
}
// Report size optimizations
for optimization in analysis.sizeOptimizations.opportunities {
if optimization.estimatedSavingMB > 100 {
let description = "\(optimization.description). \(optimization.suggestion) (potential saving: \(optimization.estimatedSavingMB)MB)"
Task {
await reporter.report(
.irEvent(
context: ReportContext(
description: description,
sourceMap: nil
),
type: .warning
))
}
}
}
}
}