//===----------------------------------------------------------------------===// // 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 }