Files
container/Tests/NativeBuilderTests/ContainerBuildIRTests/DependencyAnalysisTests.swift
J Logan 4ac18b54f3 Test fixes for 0.4.1 release. (#550)
- Disabled tests that failed with release config.
- Changed so merge builds build with release config so this doesn't burn
us next release.
- Added `swift --version` to Makefile so we can see what we're running
up in CI.
2025-08-27 18:04:00 -07:00

734 lines
27 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 Foundation
import Testing
@testable import ContainerBuildIR
@testable import ContainerBuildReporting
struct DependencyAnalysisTests {
// MARK: - DependencyAnalyzer Tests
@Test func sequentialDependenciesInStage() throws {
// Create a stage with multiple operations that should depend on each other
let baseOp = ImageOperation(source: .registry(ImageReference(parsing: "alpine")!))
let stage = BuildStage(
name: "test",
base: baseOp,
nodes: [
BuildNode(
operation: ExecOperation(command: .shell("echo 'step 1'")),
dependencies: []
),
BuildNode(
operation: ExecOperation(command: .shell("echo 'step 2'")),
dependencies: []
),
BuildNode(
operation: ExecOperation(command: .shell("echo 'step 3'")),
dependencies: []
),
]
)
let graph = try BuildGraph(stages: [stage])
let analyzer = DependencyAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let analyzedGraph = try analyzer.analyze(graph, context: context)
let analyzedStage = analyzedGraph.stages[0]
// First node should have no dependencies
#expect(analyzedStage.nodes[0].dependencies.isEmpty)
// Second node should depend on first
#expect(analyzedStage.nodes[1].dependencies.contains(analyzedStage.nodes[0].id))
// Third node should depend on second
#expect(analyzedStage.nodes[2].dependencies.contains(analyzedStage.nodes[1].id))
// Verify chain: node[0] -> node[1] -> node[2]
#expect(analyzedStage.nodes[1].dependencies.count == 1)
#expect(analyzedStage.nodes[2].dependencies.count == 1)
}
@Test(.disabled()) func crossStageDependenciesWithCopyFrom() throws {
guard let alpineRef = ImageReference(parsing: "alpine"),
let ubuntuRef = ImageReference(parsing: "ubuntu")
else {
Issue.record("Failed to parse image references")
return
}
// Create multi-stage build with COPY --from dependencies
let buildStage = BuildStage(
name: "builder",
base: ImageOperation(source: .registry(alpineRef)),
nodes: [
BuildNode(
operation: ExecOperation(command: .shell("echo 'building...'")),
dependencies: []
),
BuildNode(
operation: ExecOperation(command: .shell("echo 'app built' > /app/binary")),
dependencies: []
),
]
)
let runtimeStage = BuildStage(
name: "runtime",
base: ImageOperation(source: .registry(ubuntuRef)),
nodes: [
BuildNode(
operation: FilesystemOperation(
action: .copy,
source: .stage(.named("builder"), paths: ["/app/binary"]),
destination: "/usr/local/bin/app"
),
dependencies: []
),
BuildNode(
operation: MetadataOperation(action: .setEntrypoint(command: .exec(["/usr/local/bin/app"]))),
dependencies: []
),
]
)
let graph = try BuildGraph(stages: [buildStage, runtimeStage])
let analyzer = DependencyAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let analyzedGraph = try analyzer.analyze(graph, context: context)
// Verify cross-stage dependency was established
let analyzedRuntime = analyzedGraph.stages[1]
let copyNode = analyzedRuntime.nodes[0]
let lastBuildNode = analyzedGraph.stages[0].nodes.last!
#expect(
copyNode.dependencies.contains(lastBuildNode.id),
"COPY --from should depend on last operation in source stage")
// Verify intra-stage dependency in runtime stage
let entrypointNode = analyzedRuntime.nodes[1]
#expect(
entrypointNode.dependencies.contains(copyNode.id),
"Entrypoint should depend on copy operation")
}
@Test(.disabled()) func stageReferenceResolution() throws {
guard let alpineRef = ImageReference(parsing: "alpine") else {
Issue.record("Failed to parse image reference")
return
}
let stage1 = BuildStage(
name: "stage1",
base: ImageOperation(source: .registry(alpineRef)),
nodes: [
BuildNode(
operation: ExecOperation(command: .shell("echo 'stage1' > /file1")),
dependencies: []
)
]
)
let stage2 = BuildStage(
name: "stage2",
base: ImageOperation(source: .registry(alpineRef)),
nodes: [
BuildNode(
operation: ExecOperation(command: .shell("echo 'stage2' > /file2")),
dependencies: []
)
]
)
let finalStage = BuildStage(
base: ImageOperation(source: .registry(alpineRef)),
nodes: [
// Test named reference
BuildNode(
operation: FilesystemOperation(
action: .copy,
source: .stage(.named("stage1"), paths: ["/file1"]),
destination: "/final/file1"
),
dependencies: []
),
// Test index reference
BuildNode(
operation: FilesystemOperation(
action: .copy,
source: .stage(.index(1), paths: ["/file2"]),
destination: "/final/file2"
),
dependencies: []
),
// Test previous reference
BuildNode(
operation: FilesystemOperation(
action: .copy,
source: .stage(.previous, paths: ["/file2"]),
destination: "/final/file2-prev"
),
dependencies: []
),
]
)
let graph = try BuildGraph(stages: [stage1, stage2, finalStage])
let analyzer = DependencyAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let analyzedGraph = try analyzer.analyze(graph, context: context)
let analyzedFinal = analyzedGraph.stages[2]
// Verify named reference dependency
let namedCopyNode = analyzedFinal.nodes[0]
let stage1LastNode = analyzedGraph.stages[0].nodes.last!
#expect(namedCopyNode.dependencies.contains(stage1LastNode.id))
// Verify index reference dependency
let indexCopyNode = analyzedFinal.nodes[1]
let stage2LastNode = analyzedGraph.stages[1].nodes.last!
#expect(indexCopyNode.dependencies.contains(stage2LastNode.id))
// Verify previous reference dependency
let previousCopyNode = analyzedFinal.nodes[2]
#expect(previousCopyNode.dependencies.contains(stage2LastNode.id))
}
@Test func preserveExistingDependencies() throws {
guard let alpineRef = ImageReference(parsing: "alpine") else {
Issue.record("Failed to parse image reference")
return
}
// Create nodes with existing dependencies
let node1 = BuildNode(
operation: ExecOperation(command: .shell("echo 'node1'")),
dependencies: []
)
let node2 = BuildNode(
operation: ExecOperation(command: .shell("echo 'node2'")),
dependencies: []
)
let node3 = BuildNode(
operation: ExecOperation(command: .shell("echo 'node3'")),
dependencies: Set([node1.id]) // Explicitly depends on node1, not node2
)
let stage = BuildStage(
name: "test",
base: ImageOperation(source: .registry(alpineRef)),
nodes: [node1, node2, node3]
)
let graph = try BuildGraph(stages: [stage])
let analyzer = DependencyAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let analyzedGraph = try analyzer.analyze(graph, context: context)
let analyzedStage = analyzedGraph.stages[0]
// Node1 should have no dependencies
#expect(analyzedStage.nodes[0].dependencies.isEmpty)
// Node2 should depend on node1 (sequential)
#expect(analyzedStage.nodes[1].dependencies.contains(analyzedStage.nodes[0].id))
// Node3 should preserve its explicit dependency on node1
// and NOT have sequential dependency on node2
let node3Analyzed = analyzedStage.nodes[2]
#expect(node3Analyzed.dependencies.contains(node1.id))
#expect(!node3Analyzed.dependencies.contains(node2.id))
#expect(node3Analyzed.dependencies.count == 1)
}
// MARK: - GraphTraversal Tests
@Test func topologicalSort() throws {
// Create a stage with dependencies: A -> B -> C, A -> D -> C
let nodeA = BuildNode(
operation: ExecOperation(command: .shell("echo 'A'")),
dependencies: []
)
let nodeD = BuildNode(
operation: ExecOperation(command: .shell("echo 'D'")),
dependencies: Set([nodeA.id])
)
let nodeB = BuildNode(
operation: ExecOperation(command: .shell("echo 'B'")),
dependencies: Set([nodeA.id])
)
let nodeC = BuildNode(
operation: ExecOperation(command: .shell("echo 'C'")),
dependencies: Set([nodeB.id, nodeD.id])
)
let stage = BuildStage(
name: "test",
base: ImageOperation(source: .scratch),
nodes: [nodeC, nodeB, nodeA, nodeD] // Intentionally out of order
)
let sorted = try GraphTraversal.topologicalSort(stage)
// Find positions in sorted array
let posA = sorted.firstIndex { $0.id == nodeA.id }!
let posB = sorted.firstIndex { $0.id == nodeB.id }!
let posC = sorted.firstIndex { $0.id == nodeC.id }!
let posD = sorted.firstIndex { $0.id == nodeD.id }!
// Verify ordering constraints
#expect(posA < posB, "A should come before B")
#expect(posA < posD, "A should come before D")
#expect(posB < posC, "B should come before C")
#expect(posD < posC, "D should come before C")
#expect(sorted.count == 4, "All nodes should be included")
}
@Test func cycleDetection() throws {
// Create a cycle: A -> B -> C -> A
let nodeA = BuildNode(
operation: ExecOperation(command: .shell("echo 'A'")),
dependencies: []
)
let nodeB = BuildNode(
operation: ExecOperation(command: .shell("echo 'B'")),
dependencies: Set([nodeA.id])
)
let nodeC = BuildNode(
operation: ExecOperation(command: .shell("echo 'C'")),
dependencies: Set([nodeB.id])
)
// Create cycle by making A depend on C
let nodeACyclic = BuildNode(
id: nodeA.id,
operation: nodeA.operation,
dependencies: Set([nodeC.id])
)
let stage = BuildStage(
name: "cyclic",
base: ImageOperation(source: .scratch),
nodes: [nodeACyclic, nodeB, nodeC]
)
#expect(throws: BuildGraphError.self) {
try GraphTraversal.topologicalSort(stage)
}
}
@Test func findDependentsAndDependencies() throws {
// Create dependency chain: A -> B -> C, A -> D
let nodeA = BuildNode(
operation: ExecOperation(command: .shell("echo 'A'")),
dependencies: []
)
let nodeB = BuildNode(
operation: ExecOperation(command: .shell("echo 'B'")),
dependencies: Set([nodeA.id])
)
let nodeC = BuildNode(
operation: ExecOperation(command: .shell("echo 'C'")),
dependencies: Set([nodeB.id])
)
let nodeD = BuildNode(
operation: ExecOperation(command: .shell("echo 'D'")),
dependencies: Set([nodeA.id])
)
let stage = BuildStage(
name: "test",
base: ImageOperation(source: .scratch),
nodes: [nodeA, nodeB, nodeC, nodeD]
)
// Test finding dependents
let aDependents = GraphTraversal.findDependents(of: nodeA.id, in: stage)
#expect(aDependents.contains(nodeB.id))
#expect(aDependents.contains(nodeC.id)) // Transitive
#expect(aDependents.contains(nodeD.id))
#expect(aDependents.count == 3)
let bDependents = GraphTraversal.findDependents(of: nodeB.id, in: stage)
#expect(bDependents.contains(nodeC.id))
#expect(!bDependents.contains(nodeA.id))
#expect(bDependents.count == 1)
// Test finding dependencies
let cDependencies = GraphTraversal.findDependencies(of: nodeC.id, in: stage)
#expect(cDependencies.contains(nodeB.id))
#expect(cDependencies.contains(nodeA.id)) // Transitive
#expect(!cDependencies.contains(nodeD.id))
#expect(cDependencies.count == 2)
let aDependencies = GraphTraversal.findDependencies(of: nodeA.id, in: stage)
#expect(aDependencies.isEmpty, "Root node should have no dependencies")
}
@Test func findRootsAndLeaves() throws {
// Create graph: A -> B -> C, D -> E
let nodeA = BuildNode(
operation: ExecOperation(command: .shell("echo 'A'")),
dependencies: []
)
let nodeB = BuildNode(
operation: ExecOperation(command: .shell("echo 'B'")),
dependencies: Set([nodeA.id])
)
let nodeC = BuildNode(
operation: ExecOperation(command: .shell("echo 'C'")),
dependencies: Set([nodeB.id])
)
let nodeD = BuildNode(
operation: ExecOperation(command: .shell("echo 'D'")),
dependencies: []
)
let nodeE = BuildNode(
operation: ExecOperation(command: .shell("echo 'E'")),
dependencies: Set([nodeD.id])
)
let stage = BuildStage(
name: "test",
base: ImageOperation(source: .scratch),
nodes: [nodeA, nodeB, nodeC, nodeD, nodeE]
)
// Test finding roots
let roots = GraphTraversal.findRoots(in: stage)
let rootIds = Set(roots.map { $0.id })
#expect(rootIds.contains(nodeA.id))
#expect(rootIds.contains(nodeD.id))
#expect(rootIds.count == 2)
// Test finding leaves
let leaves = GraphTraversal.findLeaves(in: stage)
let leafIds = Set(leaves.map { $0.id })
#expect(leafIds.contains(nodeC.id))
#expect(leafIds.contains(nodeE.id))
#expect(leafIds.count == 2)
}
@Test func criticalPath() throws {
// Create a diamond dependency: A -> B -> D, A -> C -> D
let nodeA = BuildNode(
operation: ExecOperation(command: .shell("echo 'A'")),
dependencies: []
)
let nodeB = BuildNode(
operation: ExecOperation(command: .shell("echo 'B'")),
dependencies: Set([nodeA.id])
)
let nodeC = BuildNode(
operation: ExecOperation(command: .shell("echo 'C'")),
dependencies: Set([nodeA.id])
)
let nodeD = BuildNode(
operation: ExecOperation(command: .shell("echo 'D'")),
dependencies: Set([nodeB.id, nodeC.id])
)
let stage = BuildStage(
name: "test",
base: ImageOperation(source: .scratch),
nodes: [nodeA, nodeB, nodeC, nodeD]
)
let criticalPath = GraphTraversal.criticalPath(in: stage)
#expect(criticalPath.count >= 3, "Critical path should have at least 3 nodes")
// Verify path contains A and D (start and end)
let pathIds = Set(criticalPath.map { $0.id })
#expect(pathIds.contains(nodeA.id))
#expect(pathIds.contains(nodeD.id))
}
@Test func depthFirstTraversal() throws {
// Create dependency chain: A -> B -> C
let nodeA = BuildNode(
operation: ExecOperation(command: .shell("echo 'A'")),
dependencies: []
)
let nodeB = BuildNode(
operation: ExecOperation(command: .shell("echo 'B'")),
dependencies: Set([nodeA.id])
)
let nodeC = BuildNode(
operation: ExecOperation(command: .shell("echo 'C'")),
dependencies: Set([nodeB.id])
)
let stage = BuildStage(
name: "test",
base: ImageOperation(source: .scratch),
nodes: [nodeC, nodeB, nodeA] // Out of order
)
var visitOrder: [UUID] = []
try GraphTraversal.depthFirst(stage: stage) { node in
visitOrder.append(node.id)
}
#expect(visitOrder.count == 3)
// Find positions in visit order
let posA = visitOrder.firstIndex(of: nodeA.id)!
let posB = visitOrder.firstIndex(of: nodeB.id)!
let posC = visitOrder.firstIndex(of: nodeC.id)!
// DFS should visit dependencies before dependents
#expect(posA < posB, "A should be visited before B")
#expect(posB < posC, "B should be visited before C")
}
// MARK: - Stage Dependencies Tests
@Test func findStageDependencies() throws {
guard let alpineRef = ImageReference(parsing: "alpine") else {
Issue.record("Failed to parse image reference")
return
}
let stage1 = BuildStage(
name: "build",
base: ImageOperation(source: .registry(alpineRef)),
nodes: [
BuildNode(
operation: ExecOperation(command: .shell("echo 'building...'")),
dependencies: []
)
]
)
let stage2 = BuildStage(
name: "test",
base: ImageOperation(source: .registry(alpineRef)),
nodes: [
BuildNode(
operation: FilesystemOperation(
action: .copy,
source: .stage(.named("build"), paths: ["/app"]),
destination: "/test/app"
),
dependencies: []
)
]
)
let stage3 = BuildStage(
name: "final",
base: ImageOperation(source: .registry(alpineRef)),
nodes: [
BuildNode(
operation: FilesystemOperation(
action: .copy,
source: .stage(.index(0), paths: ["/app"]),
destination: "/final/app"
),
dependencies: []
),
BuildNode(
operation: FilesystemOperation(
action: .copy,
source: .stage(.previous, paths: ["/test"]),
destination: "/final/test"
),
dependencies: []
),
]
)
let graph = try BuildGraph(stages: [stage1, stage2, stage3])
// Test stage 2 dependencies
let stage2Deps = GraphTraversal.findStageDependencies(of: stage2, in: graph)
#expect(stage2Deps.contains("build"))
#expect(stage2Deps.count == 1)
// Test stage 3 dependencies
let stage3Deps = GraphTraversal.findStageDependencies(of: stage3, in: graph)
#expect(stage3Deps.contains("build")) // From index(0)
#expect(stage3Deps.contains("test")) // From .previous
#expect(stage3Deps.count == 2)
// Test stage 1 dependencies (should be empty)
let stage1Deps = GraphTraversal.findStageDependencies(of: stage1, in: graph)
#expect(stage1Deps.isEmpty)
}
// MARK: - Build Graph Analysis Tests
@Test func buildGraphAnalysis() throws {
guard let alpineRef = ImageReference(parsing: "alpine") else {
Issue.record("Failed to parse image reference")
return
}
let graph = try GraphBuilder.multiStage { builder in
try builder
.stage(name: "build", from: alpineRef)
.run("apk add --no-cache build-tools")
.workdir("/src")
.copyFromContext(paths: ["src/"], to: "./")
.run("make build")
try builder
.stage(name: "test", from: alpineRef)
.copyFromStage(.named("build"), paths: ["/src/app"], to: "/test/")
.run("./test/app --test")
try builder
.stage(from: alpineRef)
.copyFromStage(.named("build"), paths: ["/src/app"], to: "/usr/local/bin/")
.entrypoint(.exec(["/usr/local/bin/app"]))
}
let analysis = graph.analyze()
#expect(analysis.stageCount == 3)
#expect(analysis.operationCount > 0)
// Verify operation types are counted
#expect(analysis.operationsByType[OperationKind.exec] != nil)
#expect(analysis.operationsByType[OperationKind.filesystem] != nil)
#expect(analysis.operationsByType[OperationKind.metadata] != nil)
// Verify stage dependencies
#expect(analysis.stageDependencies["test"]?.contains("build") == true)
#expect(analysis.stageDependencies.count >= 1)
#expect(analysis.maxDepth > 0)
#expect(analysis.criticalPathLength > 0)
}
// MARK: - Error Handling Tests
@Test func invalidStageReferenceHandling() throws {
guard let alpineRef = ImageReference(parsing: "alpine") else {
Issue.record("Failed to parse image reference")
return
}
// Create stage with invalid reference
let stage = BuildStage(
name: "test",
base: ImageOperation(source: .registry(alpineRef)),
nodes: [
BuildNode(
operation: FilesystemOperation(
action: .copy,
source: .stage(.named("nonexistent"), paths: ["/file"]),
destination: "/test/file"
),
dependencies: []
)
]
)
let graph = try BuildGraph(stages: [stage])
let analyzer = DependencyAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
// Should not throw - dependency analyzer handles missing references gracefully
let analyzedGraph = try analyzer.analyze(graph, context: context)
// The copy operation should not have gained any cross-stage dependencies
let copyNode = analyzedGraph.stages[0].nodes[0]
#expect(
copyNode.dependencies.isEmpty,
"Copy from nonexistent stage should not create dependencies")
}
@Test func complexDependencyChain() throws {
// Test a complex multi-stage build with intricate dependencies
guard let nodeRef = ImageReference(parsing: "node:18"),
let nginxRef = ImageReference(parsing: "nginx:alpine")
else {
Issue.record("Failed to parse image references")
return
}
let graph = try GraphBuilder.multiStage { builder in
// Dependencies stage
try builder
.stage(name: "deps", from: nodeRef)
.workdir("/app")
.copyFromContext(paths: ["package*.json"], to: "./")
.run("npm ci --only=production")
// Build stage
try builder
.stage(name: "build", from: nodeRef)
.workdir("/app")
.copyFromStage(.named("deps"), paths: ["/app/node_modules"], to: "/app/node_modules")
.copyFromContext(paths: ["src/", "tsconfig.json"], to: "./")
.run("npm run build")
// Assets stage
try builder
.stage(name: "assets", from: nodeRef)
.workdir("/app")
.copyFromStage(.named("build"), paths: ["/app/dist"], to: "/app/dist")
.run("npm run optimize-assets")
// Final stage
try builder
.stage(from: nginxRef)
.copyFromStage(.named("assets"), paths: ["/app/dist"], to: "/usr/share/nginx/html")
.copyFromContext(paths: ["nginx.conf"], to: "/etc/nginx/nginx.conf")
.expose([PortSpec(port: 80)])
}
let analyzer = DependencyAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let analyzedGraph = try analyzer.analyze(graph, context: context)
// Verify complex dependency chain
let finalStage = analyzedGraph.stages[3]
let copyFromAssetsNode = finalStage.nodes[0]
let assetsStageLastNode = analyzedGraph.stages[2].nodes.last!
#expect(copyFromAssetsNode.dependencies.contains(assetsStageLastNode.id))
// Verify transitive dependencies exist through the chain
let assetsStage = analyzedGraph.stages[2]
let copyFromBuildNode = assetsStage.nodes[1] // Node 1 is the copyFromStage operation
let buildStageLastNode = analyzedGraph.stages[1].nodes.last!
#expect(copyFromBuildNode.dependencies.contains(buildStageLastNode.id))
// Verify build stage dependencies
let buildStage = analyzedGraph.stages[1]
let copyFromDepsNode = buildStage.nodes[1] // Node 1 is the copyFromStage operation
let depsStageLastNode = analyzedGraph.stages[0].nodes.last!
#expect(copyFromDepsNode.dependencies.contains(depsStageLastNode.id))
}
}