mirror of
https://github.com/apple/container.git
synced 2026-07-15 05:57:01 +00:00
c21068661f
Closes https://github.com/apple/container/issues/430 Signed-off-by: Kathryn Baldauf <k_baldauf@apple.com>
316 lines
12 KiB
Swift
316 lines
12 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
|
|
|
|
struct BasicTests {
|
|
|
|
@Test func digestCreation() throws {
|
|
// Test creating digest from bytes
|
|
let bytes = Data(repeating: 0xAB, count: 32)
|
|
let digest = try Digest(algorithm: .sha256, bytes: bytes)
|
|
#expect(digest.algorithm == .sha256)
|
|
#expect(digest.bytes == bytes)
|
|
|
|
// Test parsing digest string
|
|
let digestString = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
|
let parsed = try Digest(parsing: digestString)
|
|
#expect(parsed.stringValue == digestString)
|
|
|
|
// Test invalid length
|
|
#expect(throws: Error.self) {
|
|
try Digest(algorithm: .sha256, bytes: Data(count: 16))
|
|
}
|
|
}
|
|
|
|
@Test func imageReference() throws {
|
|
// Test parsing various formats
|
|
let refs = [
|
|
("ubuntu", "ubuntu:latest"),
|
|
("ubuntu:20.04", "ubuntu:20.04"),
|
|
("ghcr.io/owner/repo:tag", "ghcr.io/owner/repo:tag"),
|
|
("localhost:5000/test", "localhost:5000/test:latest"),
|
|
("image@sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", "image@sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"),
|
|
]
|
|
|
|
for (input, expected) in refs {
|
|
guard let ref = ImageReference(parsing: input) else {
|
|
Issue.record("Failed to parse: \(input)")
|
|
continue
|
|
}
|
|
#expect(ref.stringValue == expected, "Failed for input: \(input)")
|
|
}
|
|
|
|
// Test creation
|
|
let ref = try ImageReference(
|
|
registry: "docker.io",
|
|
repository: "library/nginx",
|
|
tag: "alpine"
|
|
)
|
|
#expect(ref.stringValue == "docker.io/library/nginx:alpine")
|
|
}
|
|
|
|
@Test func simpleGraph() throws {
|
|
// Create a simple graph
|
|
guard let imageRef = ImageReference(parsing: "alpine:latest") else {
|
|
Issue.record("Failed to parse image reference")
|
|
return
|
|
}
|
|
let graph = try GraphBuilder.singleStage(
|
|
from: imageRef
|
|
) { builder in
|
|
try builder
|
|
.run("apk add --no-cache curl")
|
|
.workdir("/app")
|
|
.copyFromContext(paths: ["main.go"], to: "/app/")
|
|
.run("go build -o app main.go")
|
|
.entrypoint(.exec(["/app/app"]))
|
|
}
|
|
|
|
#expect(graph.stages.count == 1)
|
|
#expect(graph.stages[0].nodes.count == 5)
|
|
|
|
// Validate the graph
|
|
let validator = StandardValidator()
|
|
let result = validator.validate(graph)
|
|
#expect(result.isValid == true, "Graph validation failed: \(result.errors)")
|
|
}
|
|
|
|
@Test func multiStageGraph() throws {
|
|
guard let golangRef = ImageReference(parsing: "golang:1.21"),
|
|
let alpineRef = ImageReference(parsing: "alpine:latest")
|
|
else {
|
|
Issue.record("Failed to parse image references")
|
|
return
|
|
}
|
|
|
|
let graph = try GraphBuilder.multiStage { builder in
|
|
// Build stage
|
|
try builder
|
|
.stage(name: "builder", from: golangRef)
|
|
.workdir("/src")
|
|
.copyFromContext(paths: ["go.mod", "go.sum", "*.go"], to: "./")
|
|
.run("go build -o /app")
|
|
|
|
// Runtime stage
|
|
try builder
|
|
.stage(from: alpineRef)
|
|
.run("apk add --no-cache ca-certificates")
|
|
.copyFromStage(.named("builder"), paths: ["/app"], to: "/usr/local/bin/app")
|
|
.user(.uid(1000))
|
|
.entrypoint(Command.exec(["/usr/local/bin/app"]))
|
|
}
|
|
|
|
#expect(graph.stages.count == 2)
|
|
#expect(graph.stages[0].name == "builder")
|
|
#expect(graph.stages[1].name == nil)
|
|
|
|
// Check stage dependencies
|
|
let deps = graph.stages[1].stageDependencies()
|
|
#expect(deps.contains(.named("builder")) == true)
|
|
}
|
|
|
|
@Test func operationTypes() throws {
|
|
// Test ExecOperation
|
|
let execOp = ExecOperation(
|
|
command: .shell("echo 'Hello, World!'"),
|
|
environment: Environment([
|
|
(key: "FOO", value: .literal("bar"))
|
|
]),
|
|
workingDirectory: "/tmp"
|
|
)
|
|
#expect(execOp.command.displayString == "echo 'Hello, World!'")
|
|
#expect(execOp.environment.effectiveEnvironment["FOO"] == "bar")
|
|
|
|
// Test FilesystemOperation
|
|
let fsOp = FilesystemOperation(
|
|
action: .copy,
|
|
source: .context(ContextSource(paths: ["file.txt"])),
|
|
destination: "/app/file.txt",
|
|
fileMetadata: FileMetadata(
|
|
ownership: Ownership(user: .numeric(id: 1000), group: .numeric(id: 1000)),
|
|
permissions: .mode(0o644)
|
|
)
|
|
)
|
|
#expect(fsOp.action == .copy)
|
|
#expect(fsOp.destination == "/app/file.txt")
|
|
|
|
// Test MetadataOperation
|
|
let metaOp = MetadataOperation(
|
|
action: .setLabel(key: "version", value: "1.0.0")
|
|
)
|
|
if case .setLabel(let key, let value) = metaOp.action {
|
|
#expect(key == "version")
|
|
#expect(value == "1.0.0")
|
|
} else {
|
|
Issue.record("Wrong metadata action type")
|
|
}
|
|
}
|
|
|
|
@Test func serialization() throws {
|
|
// Create a graph using the builder
|
|
guard let imageRef = ImageReference(parsing: "ubuntu:22.04") else {
|
|
Issue.record("Failed to parse image reference")
|
|
return
|
|
}
|
|
|
|
let originalGraph = try GraphBuilder.singleStage(
|
|
from: imageRef,
|
|
platform: .linuxAMD64
|
|
) { builder in
|
|
try builder
|
|
.run("apt-get update && apt-get install -y curl")
|
|
.workdir("/app")
|
|
.copyFromContext(paths: ["package.json", "src/"], to: "/app/")
|
|
.run("npm install")
|
|
.env("NODE_ENV", "production")
|
|
.expose([PortSpec(port: 3000)])
|
|
.cmd(Command.exec(["node", "src/index.js"]))
|
|
}
|
|
|
|
// Serialize to JSON
|
|
let coder = JSONIRCoder(prettyPrint: true)
|
|
let data = try coder.encode(originalGraph)
|
|
|
|
// Deserialize
|
|
let decodedGraph = try coder.decode(data)
|
|
|
|
// Compare
|
|
#expect(originalGraph.stages.count == decodedGraph.stages.count)
|
|
#expect(originalGraph.buildArgs == decodedGraph.buildArgs)
|
|
#expect(originalGraph.targetPlatforms == decodedGraph.targetPlatforms)
|
|
}
|
|
|
|
@Test func validation() throws {
|
|
// Create a graph with issues
|
|
guard let ubuntuRef = ImageReference(parsing: "ubuntu") else {
|
|
Issue.record("Failed to parse image reference")
|
|
return
|
|
}
|
|
|
|
let builder = GraphBuilder()
|
|
try builder
|
|
.stage(from: ubuntuRef)
|
|
.run("apt-get update") // Warning: update without install
|
|
.copyFromStage(.named("nonexistent"), paths: ["/file"], to: "/") // Error: stage doesn't exist
|
|
|
|
// GraphBuilder should throw validation errors during build
|
|
#expect(throws: ValidationError.self) {
|
|
try builder.build()
|
|
}
|
|
}
|
|
|
|
@Test func semanticAnalysis() throws {
|
|
// Create a Python build graph inline
|
|
let graph = try BuildGraph(
|
|
stages: [
|
|
// Dependencies stage
|
|
BuildStage(
|
|
name: "dependencies",
|
|
base: ImageOperation(
|
|
source: .registry(ImageReference(parsing: "python:3.11-slim")!),
|
|
platform: .linuxAMD64
|
|
),
|
|
nodes: [
|
|
BuildNode(
|
|
operation: MetadataOperation(
|
|
action: .setWorkdir(path: "/app")
|
|
)
|
|
),
|
|
BuildNode(
|
|
operation: FilesystemOperation(
|
|
action: .copy,
|
|
source: .context(ContextSource(paths: ["requirements.txt"])),
|
|
destination: "/app/"
|
|
)
|
|
),
|
|
BuildNode(
|
|
operation: ExecOperation(
|
|
command: .shell("pip install --user --no-cache-dir -r requirements.txt")
|
|
)
|
|
),
|
|
]
|
|
),
|
|
|
|
// Application stage
|
|
BuildStage(
|
|
name: "app",
|
|
base: ImageOperation(
|
|
source: .registry(ImageReference(parsing: "python:3.11-slim")!),
|
|
platform: .linuxAMD64
|
|
),
|
|
nodes: [
|
|
BuildNode(
|
|
operation: FilesystemOperation(
|
|
action: .copy,
|
|
source: .stage(.named("dependencies"), paths: ["/root/.local"]),
|
|
destination: "/root/.local"
|
|
)
|
|
),
|
|
BuildNode(
|
|
operation: MetadataOperation(
|
|
action: .setUser(user: .uidGid(uid: 1000, gid: 1000))
|
|
)
|
|
),
|
|
BuildNode(
|
|
operation: MetadataOperation(
|
|
action: .setCmd(command: .exec(["python", "main.py"]))
|
|
)
|
|
),
|
|
]
|
|
),
|
|
],
|
|
targetPlatforms: [.linuxAMD64, .linuxARM64]
|
|
)
|
|
|
|
let analyzer = SemanticAnalyzer()
|
|
let context = AnalysisContext(reporter: nil, sourceMap: nil)
|
|
let analyzedGraph = try analyzer.analyze(graph, context: context)
|
|
|
|
// SemanticAnalyzer should return the graph unchanged
|
|
#expect(analyzedGraph.stages.count == graph.stages.count)
|
|
#expect(analyzedGraph.buildArgs.count == graph.buildArgs.count)
|
|
}
|
|
|
|
@Test func graphTraversal() throws {
|
|
let stage = BuildStage(
|
|
name: "test",
|
|
base: ImageOperation(source: .scratch),
|
|
nodes: [
|
|
BuildNode(id: UUID(), operation: MetadataOperation(action: .setWorkdir(path: "/app")), dependencies: []),
|
|
BuildNode(id: UUID(), operation: ExecOperation(command: .shell("echo test")), dependencies: []),
|
|
BuildNode(id: UUID(), operation: MetadataOperation(action: .setUser(user: .uid(1000))), dependencies: []),
|
|
]
|
|
)
|
|
|
|
// Test topological sort
|
|
let sorted = try GraphTraversal.topologicalSort(stage)
|
|
#expect(sorted.count == stage.nodes.count)
|
|
|
|
// Test finding roots
|
|
let roots = GraphTraversal.findRoots(in: stage)
|
|
#expect(roots.count == 3) // All nodes are roots in this case
|
|
|
|
// Test finding leaves
|
|
let leaves = GraphTraversal.findLeaves(in: stage)
|
|
#expect(leaves.count == 3) // All nodes are leaves in this case
|
|
}
|
|
}
|