Files
2025-08-21 10:08:27 -07:00

656 lines
26 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 ContainerizationOCI
import Foundation
import Testing
@testable import ContainerBuildIR
@testable import ContainerBuildReporting
struct GraphBuilderTests {
// MARK: - Complex Multi-Stage Scenarios
@Test func complexMultiStageNodeJSBuild() throws {
guard let nodeRef = ImageReference(parsing: "node:18-alpine"),
let nginxRef = ImageReference(parsing: "nginx:alpine")
else {
Issue.record("Failed to parse image references")
return
}
let graph = try GraphBuilder.multiStage { builder in
// Dependencies stage - install and cache node_modules
try builder
.stage(name: "deps", from: nodeRef)
.workdir("/app")
.copyFromContext(paths: ["package*.json"], to: "./")
.run("npm ci --only=production && npm cache clean --force")
// Build stage - compile TypeScript and build assets
try builder
.stage(name: "build", from: nodeRef)
.workdir("/app")
.copyFromStage(.named("deps"), paths: ["/app/node_modules"], to: "/app/node_modules")
.copyFromContext(paths: ["tsconfig.json", "webpack.config.js", "src/"], to: "./")
.run("npm run build")
// Runtime stage - serve with nginx
try builder
.stage(name: "runtime", from: nginxRef)
.copyFromStage(.named("build"), paths: ["/app/dist"], to: "/usr/share/nginx/html")
.copyFromContext(paths: ["nginx.conf"], to: "/etc/nginx/nginx.conf")
.expose([PortSpec(port: 80)])
.cmd(.exec(["nginx", "-g", "daemon off;"]))
}
#expect(graph.stages.count == 3)
#expect(graph.stages[0].name == "deps")
#expect(graph.stages[1].name == "build")
#expect(graph.stages[2].name == "runtime")
// Verify stage dependencies
let buildDeps = graph.stages[1].stageDependencies()
#expect(buildDeps.contains(.named("deps")))
let runtimeDeps = graph.stages[2].stageDependencies()
#expect(runtimeDeps.contains(.named("build")))
// Validate the complex graph
let validator = StandardValidator()
let result = validator.validate(graph)
#expect(result.isValid, "Complex multi-stage build should be valid: \(result.errors)")
}
@Test func fourStageGoMicroserviceBuild() throws {
guard let goRef = ImageReference(parsing: "golang:1.21-alpine"),
let scratchRef = ImageReference(parsing: "scratch")
else {
Issue.record("Failed to parse image references")
return
}
let graph = try GraphBuilder.multiStage { builder in
// Base tools stage
try builder
.stage(name: "tools", from: goRef)
.run("apk add --no-cache git ca-certificates")
.workdir("/tools")
.run("go install github.com/swaggo/swag/cmd/swag@latest")
// Dependency stage
try builder
.stage(name: "deps", from: goRef)
.workdir("/src")
.copyFromContext(paths: ["go.mod", "go.sum"], to: "./")
.run("go mod download")
// Build stage
try builder
.stage(name: "build", from: goRef)
.copyFromStage(.named("tools"), paths: ["/go/bin/swag"], to: "/usr/local/bin/")
.workdir("/src")
.copyFromStage(.named("deps"), paths: ["/go/pkg"], to: "/go/pkg")
.copyFromContext(paths: [".", "!**/*_test.go"], to: "./")
.run("swag init -g cmd/server/main.go")
.run("CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o /app cmd/server/main.go")
// Runtime stage
try builder
.stage(from: scratchRef)
.copyFromStage(.named("tools"), paths: ["/etc/ssl/certs/ca-certificates.crt"], to: "/etc/ssl/certs/")
.copyFromStage(.named("build"), paths: ["/app"], to: "/app")
.user(.uid(65534)) // nobody user
.expose([PortSpec(port: 8080)])
.entrypoint(.exec(["/app"]))
}
#expect(graph.stages.count == 4)
// Verify complex dependency chain
let buildStage = graph.stages[2]
let buildDeps = buildStage.stageDependencies()
#expect(buildDeps.contains(.named("tools")))
#expect(buildDeps.contains(.named("deps")))
let runtimeStage = graph.stages[3]
let runtimeDeps = runtimeStage.stageDependencies()
#expect(runtimeDeps.contains(.named("tools")))
#expect(runtimeDeps.contains(.named("build")))
}
// MARK: - Build Arguments and Environment
@Test func buildArgumentPropagation() throws {
guard let nodeRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
let graph = try GraphBuilder.multiStage { builder in
try builder
.fromOnlyArg("BASE_IMAGE", defaultValue: "alpine:latest")
.fromOnlyArg("BUILD_VERSION", defaultValue: "1.0.0")
.stage(name: "build", from: nodeRef)
.arg("NODE_ENV", defaultValue: "development")
.arg("BUILD_VERSION")
.arg("API_URL")
.workdir("/app")
.copyFromContext(paths: ["package.json"], to: "./")
.run("npm install --omit=dev")
.env("NODE_ENV", "production")
.env("BUILD_VERSION", "1.0.0")
.env("API_URL", "https://api.example.com")
.copyFromContext(paths: ["src/"], to: "./src/")
.run("npm run build")
}
// Verify global FROM-only ARG values
#expect(graph.buildArgs.count == 2)
#expect(graph.buildArgs["BASE_IMAGE"] == "alpine:latest")
#expect(graph.buildArgs["BUILD_VERSION"] == "1.0.0")
// Verify stage-local ARG values
#expect(graph.stages.count == 1)
let stage = graph.stages[0]
#expect(getStageArg(stage, name: "BASE_IMAGE") == nil)
#expect(getStageArg(stage, name: "NODE_ENV") == "development")
#expect(getStageArg(stage, name: "BUILD_VERSION") == nil)
#expect(getStageArg(stage, name: "API_URL") == nil)
// Verify ENV instructions reference build args
let envOps = stage.nodes.compactMap { node in
node.operation as? MetadataOperation
}.filter { meta in
if case .setEnv = meta.action { return true }
return false
}
#expect(envOps.count == 3)
}
@Test func testArgSubstitutionWithSimpleVariables() throws {
let graphBuilder = GraphBuilder()
graphBuilder.fromOnlyArg("DEFINED", defaultValue: "value")
graphBuilder.fromOnlyArg("EMPTY", defaultValue: "")
#expect(graphBuilder.substituteArgs("prefix-$DEFINED-$DEFINED-$DEFINED-suffix", inFromContext: true) == "prefix-value-value-value-suffix")
#expect(graphBuilder.substituteArgs("prefix-$EMPTY-$EMPTY-$EMPTY-suffix", inFromContext: true) == "prefix----suffix")
#expect(graphBuilder.substituteArgs("prefix-$UNDEFINED-$UNDEFINED-$UNDEFINED-suffix", inFromContext: true) == "prefix----suffix")
}
@Test func testArgSubstitution() throws {
let graphBuilder = GraphBuilder()
graphBuilder.fromOnlyArg("DEFINED", defaultValue: "value")
graphBuilder.fromOnlyArg("EMPTY", defaultValue: "")
#expect(graphBuilder.substituteArgs("prefix-${DEFINED}-${DEFINED}-${DEFINED}-suffix", inFromContext: true) == "prefix-value-value-value-suffix")
#expect(graphBuilder.substituteArgs("prefix-${EMPTY}-${EMPTY}-${EMPTY}-suffix", inFromContext: true) == "prefix----suffix")
#expect(graphBuilder.substituteArgs("prefix-${UNDEFINED}-${UNDEFINED}-${UNDEFINED}-suffix", inFromContext: true) == "prefix----suffix")
}
@Test func testArgSubstitutionWithDefaults() throws {
let graphBuilder = GraphBuilder()
graphBuilder.fromOnlyArg("DEFINED", defaultValue: "defined")
graphBuilder.fromOnlyArg("EMPTY", defaultValue: "")
#expect(graphBuilder.substituteArgs("${UNDEFINED:-default}", inFromContext: true) == "default")
#expect(graphBuilder.substituteArgs("${UNDEFINED:-default with spaces}", inFromContext: true) == "default with spaces")
#expect(graphBuilder.substituteArgs("${UNDEFINED:-}", inFromContext: true) == "")
#expect(graphBuilder.substituteArgs("${DEFINED:-default}", inFromContext: true) == "defined")
#expect(graphBuilder.substituteArgs("${EMPTY:-default}", inFromContext: true) == "")
#expect(graphBuilder.substituteArgs("a=${UNDEFINED:-default} b=${DEFINED:-default} c=${EMPTY:-default}", inFromContext: true) == "a=default b=defined c=")
}
@Test func testArgSubstitutionWithUTF16Characters() throws {
let graphBuilder = GraphBuilder()
#expect(graphBuilder.substituteArgs("unicorn 🦄 unicorn ${UNDEFINED:-🦄}", inFromContext: true) == "unicorn 🦄 unicorn 🦄")
#expect(graphBuilder.substituteArgs("🦄 ${UNDEFINED:-unicorn 🦄 unicorn}", inFromContext: true) == "🦄 unicorn 🦄 unicorn")
}
@Test func testArgSubstitutionWithSubstituteValues() throws {
let graphBuilder = GraphBuilder()
graphBuilder.fromOnlyArg("DEFINED", defaultValue: "defined")
graphBuilder.fromOnlyArg("EMPTY", defaultValue: "")
#expect(graphBuilder.substituteArgs("${DEFINED:+value}", inFromContext: true) == "value")
#expect(graphBuilder.substituteArgs("${DEFINED:+value with spaces}", inFromContext: true) == "value with spaces")
#expect(graphBuilder.substituteArgs("${DEFINED:+}", inFromContext: true) == "")
#expect(graphBuilder.substituteArgs("${UNDEFINED:+value}", inFromContext: true) == "")
#expect(graphBuilder.substituteArgs("${EMPTY:+value}", inFromContext: true) == "")
#expect(graphBuilder.substituteArgs("a=${UNDEFINED:+value} b=${DEFINED:+value} c=${EMPTY:+value}", inFromContext: true) == "a= b=value c=")
}
@Test func testPredefinedArgVariables() throws {
let graphBuilder = GraphBuilder()
var originalValues: [String: String?] = [:]
for varName in GraphBuilder.predefinedArgs {
originalValues[varName] = ProcessInfo.processInfo.environment[varName]
setenv(varName, "test-value-\(varName.lowercased())", 1)
}
defer {
for (varName, originalValue) in originalValues {
if let originalValue {
setenv(varName, originalValue, 1)
} else {
unsetenv(varName)
}
}
}
for varName in GraphBuilder.predefinedArgs {
let expectedValue = "test-value-\(varName.lowercased())"
#expect(graphBuilder.substituteArgs("${\(varName)}", inFromContext: true) == expectedValue)
}
}
// MARK: - Platform-Specific Builds
@Test func multiPlatformBuild() throws {
guard let baseRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
let graph = try GraphBuilder.singleStage(from: baseRef, platform: Platform.linuxAMD64) { builder in
try builder
.platforms(Platform.linuxAMD64, Platform.linuxARM64)
.run("apk add --no-cache curl")
.workdir("/app")
.copyFromContext(paths: ["app.sh"], to: "/app/")
.run("chmod +x /app/app.sh")
.entrypoint(.exec(["/app/app.sh"]))
}
#expect(graph.targetPlatforms.count == 2)
#expect(graph.targetPlatforms.contains(Platform.linuxAMD64))
#expect(graph.targetPlatforms.contains(Platform.linuxARM64))
// Verify base image has platform constraints
let stage = graph.stages[0]
let imageOp = stage.base
#expect(imageOp.platform != nil)
}
@Test func platformSpecificStages() throws {
guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
let graph = try GraphBuilder.multiStage { builder in
// Linux AMD64 optimized stage
try builder
.stage(name: "linux-amd64", from: alpineRef, platform: Platform.linuxAMD64)
.run("apk add --no-cache glibc-compat")
.copyFromContext(paths: ["bin/app-linux-amd64"], to: "/usr/local/bin/app")
// Linux ARM64 stage
try builder
.stage(name: "linux-arm64", from: alpineRef, platform: Platform.linuxARM64)
.run("apk add --no-cache ca-certificates")
.copyFromContext(paths: ["bin/app-linux-arm64"], to: "/usr/local/bin/app")
// Final stage that copies from platform-specific stage
try builder
.stage(from: alpineRef)
.copyFromStage(.named("linux-amd64"), paths: ["/usr/local/bin/app"], to: "/usr/local/bin/")
.copyFromStage(.named("linux-arm64"), paths: ["/usr/local/bin/app"], to: "/usr/local/bin/")
.entrypoint(.exec(["/usr/local/bin/app"]))
}
#expect(graph.stages.count == 3)
// Verify platform-specific stages
#expect(graph.stages[0].name == "linux-amd64")
#expect(graph.stages[1].name == "linux-arm64")
let amd64ImageOp = graph.stages[0].base
#expect(amd64ImageOp.platform == Platform.linuxAMD64)
let arm64ImageOp = graph.stages[1].base
#expect(arm64ImageOp.platform == Platform.linuxARM64)
}
// MARK: - Error Conditions
@Test func invalidStageReference() throws {
guard let alpineRef = ImageReference(parsing: "alpine") else {
Issue.record("Failed to parse image reference")
return
}
#expect(throws: Error.self) {
try GraphBuilder.multiStage { builder in
try builder
.stage(name: "base", from: alpineRef)
.run("echo hello")
try builder
.stage(name: "app", from: alpineRef)
.copyFromStage(.named("nonexistent"), paths: ["/file"], to: "/")
}
}
}
@Test func circularStageDependency() throws {
guard let alpineRef = ImageReference(parsing: "alpine") else {
Issue.record("Failed to parse image reference")
return
}
// This should create a circular dependency through stage references
// GraphBuilder should throw BuildGraphError for cyclic dependencies
#expect(throws: BuildGraphError.self) {
try GraphBuilder.multiStage { builder in
try builder
.stage(name: "stage1", from: alpineRef)
.copyFromStage(.named("stage2"), paths: ["/file1"], to: "/file1")
try builder
.stage(name: "stage2", from: alpineRef)
.copyFromStage(.named("stage1"), paths: ["/file2"], to: "/file2")
}
}
}
@Test func validEntrypointAndCmdSequence() throws {
guard let alpineRef = ImageReference(parsing: "alpine") else {
Issue.record("Failed to parse image reference")
return
}
// CMD after ENTRYPOINT is a valid and common Docker pattern
// ENTRYPOINT defines the executable, CMD provides default arguments
let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
try builder
.entrypoint(.exec(["/app"])) // Set entrypoint first
.cmd(.shell("echo override")) // CMD after ENTRYPOINT is valid
}
#expect(graph.stages.count == 1)
#expect(graph.stages[0].nodes.count == 2)
// Verify the operations exist and are in the correct order
let nodes = graph.stages[0].nodes
let entrypointNode = nodes.first { node in
if let metaOp = node.operation as? MetadataOperation,
case .setEntrypoint = metaOp.action
{
return true
}
return false
}
#expect(entrypointNode != nil, "Should have ENTRYPOINT operation")
let cmdNode = nodes.first { node in
if let metaOp = node.operation as? MetadataOperation,
case .setCmd = metaOp.action
{
return true
}
return false
}
#expect(cmdNode != nil, "Should have CMD operation")
// Validate the graph structure
let validator = StandardValidator()
let result = validator.validate(graph)
#expect(result.isValid, "ENTRYPOINT + CMD sequence should be valid: \(result.errors)")
}
@Test func emptyStage() throws {
guard let alpineRef = ImageReference(parsing: "alpine") else {
Issue.record("Failed to parse image reference")
return
}
// Empty stage should be valid but might generate warnings
let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
// Intentionally empty - just the base image
}
#expect(graph.stages.count == 1)
#expect(graph.stages[0].nodes.isEmpty, "Empty stage should have no nodes")
let validator = StandardValidator()
let result = validator.validate(graph)
#expect(result.isValid, "Empty stage should be structurally valid")
}
// MARK: - Advanced GraphBuilder Features
@Test func conditionalInstructions() throws {
guard let nodeRef = ImageReference(parsing: "node:18") else {
Issue.record("Failed to parse image reference")
return
}
let isDevelopment = false
let graph = try GraphBuilder.singleStage(from: nodeRef) { builder in
try builder
.workdir("/app")
.copyFromContext(paths: ["package.json"], to: "./")
.run("npm install" + (isDevelopment ? "" : " --omit=dev"))
.copyFromContext(paths: ["src/"], to: "./src/")
if isDevelopment {
try builder
.env("NODE_ENV", "development")
.run("npm run test")
.cmd(.shell("npm run dev"))
} else {
try builder
.env("NODE_ENV", "production")
.run("npm run build")
.cmd(.exec(["node", "dist/index.js"]))
}
}
// Verify production build path was taken
let envOps = graph.stages[0].nodes.compactMap { node in
node.operation as? MetadataOperation
}.compactMap { meta in
if case .setEnv(let key, let value) = meta.action,
key == "NODE_ENV"
{
return value
}
return nil
}
#expect(envOps.contains(.literal("production")))
#expect(!envOps.contains(.literal("development")))
}
@Test func stageWithCustomMetadata() throws {
guard let alpineRef = ImageReference(parsing: "alpine") else {
Issue.record("Failed to parse image reference")
return
}
let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
try builder
.label("version", "1.0.0")
.label("maintainer", "team@example.com")
.label("description", "Sample application")
.workdir("/app")
.user(.named("appuser"))
.expose([PortSpec(port: 8080)])
}
// Verify all metadata operations were added
let metadataOps = graph.stages[0].nodes.compactMap { $0.operation as? MetadataOperation }
let labelOps = metadataOps.filter {
if case .setLabel = $0.action { return true }
return false
}
#expect(labelOps.count == 3)
let userOps = metadataOps.filter {
if case .setUser = $0.action { return true }
return false
}
#expect(userOps.count == 1)
let exposeOps = metadataOps.filter {
if case .expose = $0.action { return true }
return false
}
#expect(exposeOps.count == 1)
}
@Test func complexMountOperations() throws {
guard let alpineRef = ImageReference(parsing: "alpine") else {
Issue.record("Failed to parse image reference")
return
}
let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
try builder
.workdir("/app")
.run(
"apk add --no-cache make gcc",
mounts: [
Mount(type: .cache, target: "/var/cache/apk"),
Mount(type: .secret, target: "/run/secrets/github-token", source: .secret("github-token"), options: MountOptions(mode: 0o400)),
Mount(type: .bind, target: "/app/.cache", source: .local("build-cache")),
]
)
.copyFromContext(paths: ["Makefile", "src/"], to: "./")
.run(
"make build",
mounts: [
Mount(type: .cache, target: "/app/.cache", options: MountOptions(sharing: .shared)),
Mount(type: .tmpfs, target: "/tmp", options: MountOptions(size: 100_000_000)),
]
)
}
// Verify mount operations exist
let execOps = graph.stages[0].nodes.compactMap { $0.operation as? ExecOperation }
let firstRunOp = execOps.first { op in
op.command.displayString.contains("apk add")
}
#expect(firstRunOp?.mounts.count == 3)
let secondRunOp = execOps.first { op in
op.command.displayString.contains("make build")
}
#expect(secondRunOp?.mounts.count == 2)
}
// MARK: - Graph Modification and Builder State
@Test func builderStatePreservation() throws {
guard let alpineRef = ImageReference(parsing: "alpine") else {
Issue.record("Failed to parse image reference")
return
}
let graph1 = try GraphBuilder.singleStage(from: alpineRef) { stageBuilder in
try stageBuilder
.arg("VERSION", defaultValue: "1.0.0")
.platforms(Platform.linuxAMD64)
.run("echo 'Build 1'")
}
// Verify first graph has our settings
#expect(graph1.targetPlatforms == [Platform.linuxAMD64])
#expect(graph1.stages.count == 1)
#expect(getStageArg(graph1.stages[0], name: "VERSION") == "1.0.0")
// Create second graph with different settings
let graph2 = try GraphBuilder.singleStage(from: alpineRef) { stageBuilder in
try stageBuilder
.arg("VERSION", defaultValue: "2.0.0")
.platforms(Platform.linuxARM64)
.run("echo 'Build 2'")
}
// Verify second graph has updated settings
#expect(graph2.targetPlatforms == [Platform.linuxARM64])
#expect(graph2.stages.count == 1)
#expect(getStageArg(graph2.stages[0], name: "VERSION") == "2.0.0")
// Verify first graph unchanged
#expect(graph1.targetPlatforms == [Platform.linuxAMD64])
#expect(graph1.stages.count == 1)
#expect(getStageArg(graph1.stages[0], name: "VERSION") == "1.0.0")
}
@Test func incrementalGraphBuilding() throws {
guard let alpineRef = ImageReference(parsing: "alpine") else {
Issue.record("Failed to parse image reference")
return
}
let graph = try GraphBuilder.multiStage { builder in
// Build base stage
try builder
.stage(name: "base", from: alpineRef)
.run("apk add --no-cache ca-certificates")
.workdir("/app")
// Add dependency stage
try builder
.stage(name: "deps", from: alpineRef)
.copyFromStage(.named("base"), paths: ["/etc/ssl"], to: "/etc/ssl")
.run("apk add --no-cache curl")
// Add final stage
try builder
.stage(from: alpineRef)
.copyFromStage(.named("base"), paths: ["/app"], to: "/app")
.copyFromStage(.named("deps"), paths: ["/usr/bin/curl"], to: "/usr/local/bin/")
.entrypoint(.exec(["/app/start.sh"]))
}
#expect(graph.stages.count == 3)
#expect(graph.stages[0].name == "base")
#expect(graph.stages[1].name == "deps")
#expect(graph.stages[2].name == nil) // Final stage
// Verify dependencies are correct
let finalStageDeps = graph.stages[2].stageDependencies()
#expect(finalStageDeps.contains(.named("base")))
#expect(finalStageDeps.contains(.named("deps")))
}
}
func getStageArg(_ stage: BuildStage, name: String, globalBuildArgs: [String: String] = [:]) -> String? {
for node in stage.nodes {
if let metadataOp = node.operation as? MetadataOperation,
case .declareArg(let argName, let defaultValue) = metadataOp.action,
argName == name
{
return defaultValue ?? globalBuildArgs[name]
}
}
return nil
}