Files
Dmitry Kovba d2eb350393 Preserve permissions by default and fix warnings (#496)
Permissions should be preserved when copying by default. This PR also
fixes other warnings while building and testing.
2025-08-13 16:50:19 -07:00

623 lines
24 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
struct SerializationTests {
// MARK: - JSONIRCoder Tests
@Test func jsonCoderBasicRoundTrip() throws {
guard let imageRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
let originalGraph = try GraphBuilder.singleStage(from: imageRef) { builder in
try builder
.run("apk add --no-cache curl")
.workdir("/app")
.copyFromContext(paths: ["src/"], to: "/app/")
.env("NODE_ENV", "production")
.expose([PortSpec(port: 3000)])
.cmd(.exec(["node", "index.js"]))
}
let coder = JSONIRCoder()
let encodedData = try coder.encode(originalGraph)
let decodedGraph = try coder.decode(encodedData)
// Verify structure preservation
#expect(decodedGraph.stages.count == originalGraph.stages.count)
#expect(decodedGraph.buildArgs == originalGraph.buildArgs)
#expect(decodedGraph.targetPlatforms == originalGraph.targetPlatforms)
// Verify stage content
let originalStage = originalGraph.stages[0]
let decodedStage = decodedGraph.stages[0]
#expect(decodedStage.nodes.count == originalStage.nodes.count)
#expect(decodedStage.name == originalStage.name)
}
@Test func jsonCoderPrettyPrint() throws {
guard let imageRef = ImageReference(parsing: "ubuntu:22.04") else {
Issue.record("Failed to parse image reference")
return
}
let graph = try GraphBuilder.singleStage(from: imageRef) { builder in
try builder
.run("apt-get update")
.run("apt-get install -y python3")
}
let prettyPrintCoder = JSONIRCoder(prettyPrint: true)
let compactCoder = JSONIRCoder(prettyPrint: false)
let prettyData = try prettyPrintCoder.encode(graph)
let compactData = try compactCoder.encode(graph)
// Pretty printed should be larger (contains whitespace)
#expect(prettyData.count > compactData.count)
// Both should decode to the same graph
let prettyGraph = try prettyPrintCoder.decode(prettyData)
let compactGraph = try compactCoder.decode(compactData)
#expect(prettyGraph.stages.count == compactGraph.stages.count)
#expect(prettyGraph.buildArgs == compactGraph.buildArgs)
// Verify JSON is actually pretty printed
let prettyJson = String(data: prettyData, encoding: .utf8)!
#expect(prettyJson.contains("\n"), "Pretty printed JSON should contain newlines")
#expect(prettyJson.contains(" "), "Pretty printed JSON should contain indentation")
}
@Test func jsonCoderMultiStageGraph() throws {
guard let nodeRef = ImageReference(parsing: "node:18"),
let nginxRef = ImageReference(parsing: "nginx:alpine")
else {
Issue.record("Failed to parse image references")
return
}
let originalGraph = try GraphBuilder.multiStage { builder in
try builder
.stage(name: "build", from: nodeRef)
.workdir("/app")
.copyFromContext(paths: ["package.json"], to: "./")
.run("npm install")
.copyFromContext(paths: ["src/"], to: "./src/")
.run("npm run build")
try builder
.stage(from: nginxRef)
.copyFromStage(.named("build"), paths: ["/app/dist"], to: "/usr/share/nginx/html")
.expose([PortSpec(port: 80)])
}
let coder = JSONIRCoder()
let encodedData = try coder.encode(originalGraph)
let decodedGraph = try coder.decode(encodedData)
#expect(decodedGraph.stages.count == 2)
#expect(decodedGraph.stages[0].name == "build")
#expect(decodedGraph.stages[1].name == nil)
// Verify stage dependencies are preserved
let runtimeStage = decodedGraph.stages[1]
let stageDeps = runtimeStage.stageDependencies()
#expect(stageDeps.contains(.named("build")))
}
@Test func jsonCoderWithBuildArgs() throws {
guard let nodeRef = ImageReference(parsing: "node:18") else {
Issue.record("Failed to parse image reference")
return
}
let originalGraph = try GraphBuilder.singleStage(from: nodeRef) { builder in
try builder
.arg("NODE_ENV", defaultValue: "development")
.arg("BUILD_VERSION")
.env("NODE_ENV", "production")
.env("BUILD_VERSION", "1.2.3")
.run("npm install")
}
let coder = JSONIRCoder()
let encodedData = try coder.encode(originalGraph)
let decodedGraph = try coder.decode(encodedData)
// Verify build args preservation
#expect(decodedGraph.buildArgs.count >= 0)
// Verify target platforms preservation
#expect(decodedGraph.targetPlatforms.count >= 0)
}
// MARK: - BinaryIRCoder Tests
@Test func binaryCoderBasicRoundTrip() throws {
guard let imageRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
let originalGraph = try GraphBuilder.singleStage(from: imageRef) { builder in
try builder
.run("apk add --no-cache git")
.workdir("/app")
.copyFromContext(paths: ["main.go"], to: "/app/")
.run("go build -o app main.go")
.entrypoint(.exec(["/app/app"]))
}
let coder = BinaryIRCoder()
let encodedData = try coder.encode(originalGraph)
let decodedGraph = try coder.decode(encodedData)
// Verify structure preservation
#expect(decodedGraph.stages.count == originalGraph.stages.count)
#expect(decodedGraph.buildArgs == originalGraph.buildArgs)
#expect(decodedGraph.targetPlatforms == originalGraph.targetPlatforms)
// Verify stage content
let originalStage = originalGraph.stages[0]
let decodedStage = decodedGraph.stages[0]
#expect(decodedStage.nodes.count == originalStage.nodes.count)
}
@Test func binaryCoderCompression() throws {
guard let ubuntuRef = ImageReference(parsing: "ubuntu:22.04") else {
Issue.record("Failed to parse image reference")
return
}
// Create a larger graph to test compression effectiveness
let originalGraph = try GraphBuilder.multiStage { builder in
for i in 0..<3 {
try builder
.stage(name: "stage\(i)", from: ubuntuRef)
.run("apt-get update")
.run("apt-get install -y curl wget git vim")
.workdir("/app")
.copyFromContext(paths: ["file\(i).txt"], to: "/app/")
.env("STAGE_NUMBER", "\(i)")
.label("stage.number", "\(i)")
.label("stage.description", "This is stage number \(i) with detailed information")
}
}
let jsonCoder = JSONIRCoder(prettyPrint: false)
let binaryCoder = BinaryIRCoder()
let jsonData = try jsonCoder.encode(originalGraph)
let binaryData = try binaryCoder.encode(originalGraph)
// Binary format should typically be smaller than JSON
#expect(
binaryData.count <= jsonData.count,
"Binary format should be more compact (JSON: \(jsonData.count), Binary: \(binaryData.count))")
// Verify both decode correctly
let jsonGraph = try jsonCoder.decode(jsonData)
let binaryGraph = try binaryCoder.decode(binaryData)
#expect(jsonGraph.stages.count == binaryGraph.stages.count)
#expect(jsonGraph.buildArgs == binaryGraph.buildArgs)
}
@Test func binaryCoderComplexOperations() throws {
guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
let originalGraph = try GraphBuilder.singleStage(from: alpineRef) { builder in
try builder
.run(
"apk add --no-cache build-tools",
mounts: [
Mount(type: .cache, target: "/var/cache/apk", source: .local("apk-cache")),
Mount(type: .secret, target: "/run/secrets/token", source: .secret("github-token")),
]
)
.copyFromContext(
paths: ["src/**/*.c", "include/**/*.h"],
to: "/app/"
)
.healthcheck(
test: .command(.exec(["./healthcheck.sh"])),
interval: 30,
timeout: 5,
retries: 3
)
.user(.uidGid(uid: 1000, gid: 1000))
}
let coder = BinaryIRCoder()
let encodedData = try coder.encode(originalGraph)
let decodedGraph = try coder.decode(encodedData)
// Verify complex operations are preserved
let stage = decodedGraph.stages[0]
// Check for RUN operation with mounts
let runOps = stage.nodes.compactMap { $0.operation as? ExecOperation }
let runWithMounts = runOps.first { !$0.mounts.isEmpty }
#expect(runWithMounts != nil, "RUN operation with mounts should be preserved")
#expect(runWithMounts!.mounts.count == 2, "Both mounts should be preserved")
// Check for COPY operation with metadata
let copyOps = stage.nodes.compactMap { $0.operation as? FilesystemOperation }
let copyWithMetadata = copyOps.first {
$0.fileMetadata.permissions != nil
}
#expect(copyWithMetadata != nil, "COPY operation should preserve file permissions by default")
// Check for metadata operations
let metaOps = stage.nodes.compactMap { $0.operation as? MetadataOperation }
let hasHealthcheck = metaOps.contains {
if case .setHealthcheck = $0.action { return true }
return false
}
#expect(hasHealthcheck, "Healthcheck metadata should be preserved")
}
// MARK: - Format Comparison Tests
@Test func formatSizeComparison() throws {
guard let golangRef = ImageReference(parsing: "golang:1.21"),
let alpineRef = ImageReference(parsing: "alpine:latest")
else {
Issue.record("Failed to parse image references")
return
}
// Create a realistic build graph
let graph = try GraphBuilder.multiStage { builder in
try builder
.stage(name: "builder", from: golangRef)
.workdir("/src")
.copyFromContext(paths: ["go.mod", "go.sum"], to: "./")
.run("go mod download")
.copyFromContext(paths: ["cmd/", "internal/", "pkg/"], to: "./")
.run("CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o /app cmd/main.go")
try builder
.stage(from: alpineRef)
.run("apk add --no-cache ca-certificates")
.copyFromStage(.named("builder"), paths: ["/app"], to: "/usr/local/bin/")
.user(.uid(65534))
.expose([PortSpec(port: 8080)])
.entrypoint(.exec(["/usr/local/bin/app"]))
}
let jsonCompactCoder = JSONIRCoder(prettyPrint: false)
let jsonPrettyCoder = JSONIRCoder(prettyPrint: true)
let binaryCoder = BinaryIRCoder()
let jsonCompactData = try jsonCompactCoder.encode(graph)
let jsonPrettyData = try jsonPrettyCoder.encode(graph)
let binaryData = try binaryCoder.encode(graph)
print("Format size comparison:")
print("- JSON (compact): \(jsonCompactData.count) bytes")
print("- JSON (pretty): \(jsonPrettyData.count) bytes")
print("- Binary: \(binaryData.count) bytes")
// Expected size ordering
#expect(binaryData.count <= jsonCompactData.count)
#expect(jsonCompactData.count < jsonPrettyData.count)
// All should decode to equivalent graphs
let graphs = [
try jsonCompactCoder.decode(jsonCompactData),
try jsonPrettyCoder.decode(jsonPrettyData),
try binaryCoder.decode(binaryData),
]
for graph in graphs {
#expect(graph.stages.count == 2)
#expect(graph.stages[0].name == "builder")
}
}
// MARK: - Error Handling Tests
@Test func corruptedDataHandling() throws {
let coder = JSONIRCoder()
// Test completely invalid data
let invalidData = "not valid json".data(using: .utf8)!
#expect(throws: Error.self) {
try coder.decode(invalidData)
}
// Test valid JSON but invalid structure
let invalidStructure = """
{
"version": "1.0",
"graph": {
"stages": "this should be an array"
}
}
""".data(using: .utf8)!
#expect(throws: Error.self) {
try coder.decode(invalidStructure)
}
}
@Test func binaryCorruptedDataHandling() throws {
let coder = BinaryIRCoder()
// Test invalid binary data
let invalidData = Data(repeating: 0xFF, count: 100)
#expect(throws: Error.self) {
try coder.decode(invalidData)
}
// Test truncated data
let validGraph: BuildGraph
do {
validGraph = try GraphBuilder.singleStage(
from: ImageReference(parsing: "alpine")!
) { builder in
try builder.run("echo test")
}
} catch {
Issue.record("Failed to create test graph")
return
}
let validData = try coder.encode(validGraph)
let truncatedData = validData.prefix(validData.count / 2)
#expect(throws: Error.self) {
try coder.decode(Data(truncatedData))
}
}
@Test func versionHandling() throws {
// Test that we can detect version information in JSON format
let graph: BuildGraph
do {
graph = try GraphBuilder.singleStage(
from: ImageReference(parsing: "alpine")!
) { builder in
try builder.run("echo version test")
}
} catch {
Issue.record("Failed to create test graph")
return
}
let coder = JSONIRCoder(prettyPrint: true)
let data = try coder.encode(graph)
let jsonString = String(data: data, encoding: .utf8)!
#expect(jsonString.contains("\"version\""), "JSON should contain version information")
#expect(jsonString.contains("\"1.0\""), "Should use version 1.0")
// Should decode correctly
let decodedGraph = try coder.decode(data)
#expect(decodedGraph.stages.count == graph.stages.count)
}
// MARK: - File I/O Tests
@Test func saveAndLoadGraph() throws {
guard let imageRef = ImageReference(parsing: "python:3.11") else {
Issue.record("Failed to parse image reference")
return
}
let originalGraph = try GraphBuilder.singleStage(from: imageRef) { builder in
try builder
.workdir("/app")
.copyFromContext(paths: ["requirements.txt"], to: "./")
.run("pip install -r requirements.txt")
.copyFromContext(paths: ["src/"], to: "./src/")
.cmd(.exec(["python", "src/main.py"]))
}
let tempDir = FileManager.default.temporaryDirectory
let jsonURL = tempDir.appendingPathComponent("test-graph.json")
let binaryURL = tempDir.appendingPathComponent("test-graph.bin")
// Save using different formats
try originalGraph.save(to: jsonURL, using: JSONIRCoder(prettyPrint: true))
try originalGraph.save(to: binaryURL, using: BinaryIRCoder())
// Verify files exist and have content
#expect(FileManager.default.fileExists(atPath: jsonURL.path))
#expect(FileManager.default.fileExists(atPath: binaryURL.path))
let jsonFileSize = try FileManager.default.attributesOfItem(atPath: jsonURL.path)[.size] as! Int
let binaryFileSize = try FileManager.default.attributesOfItem(atPath: binaryURL.path)[.size] as! Int
#expect(jsonFileSize > 0)
#expect(binaryFileSize > 0)
// Load and verify
let jsonGraph = try BuildGraph.load(from: jsonURL, using: JSONIRCoder())
let binaryGraph = try BuildGraph.load(from: binaryURL, using: BinaryIRCoder())
#expect(jsonGraph.stages.count == originalGraph.stages.count)
#expect(binaryGraph.stages.count == originalGraph.stages.count)
// Cleanup
try? FileManager.default.removeItem(at: jsonURL)
try? FileManager.default.removeItem(at: binaryURL)
}
// MARK: - Performance Tests
@Test func serializationPerformance() throws {
// Create a large graph for performance testing
guard let baseRef = ImageReference(parsing: "ubuntu:22.04") else {
Issue.record("Failed to parse image reference")
return
}
var stages: [BuildStage] = []
// Create 5 stages with 10 operations each
for stageIndex in 0..<5 {
var nodes: [BuildNode] = []
for opIndex in 0..<10 {
let operation: any ContainerBuildIR.Operation
switch opIndex % 4 {
case 0:
operation = ExecOperation(command: .shell("echo 'stage \(stageIndex) op \(opIndex)'"))
case 1:
operation = FilesystemOperation(
action: .copy,
source: .context(ContextSource(paths: ["file\(opIndex).txt"])),
destination: "/app/file\(opIndex).txt"
)
case 2:
operation = MetadataOperation(action: .setEnv(key: "VAR\(opIndex)", value: .literal("value\(opIndex)")))
default:
operation = MetadataOperation(action: .setLabel(key: "label\(opIndex)", value: "value\(opIndex)"))
}
nodes.append(BuildNode(operation: operation, dependencies: []))
}
stages.append(
BuildStage(
name: "stage\(stageIndex)",
base: ImageOperation(source: .registry(baseRef)),
nodes: nodes
))
}
let largeGraph = try BuildGraph(stages: stages)
let jsonCoder = JSONIRCoder()
let binaryCoder = BinaryIRCoder()
// Measure JSON encoding time
let jsonStartTime = Date()
let jsonData = try jsonCoder.encode(largeGraph)
let jsonEncodeTime = Date().timeIntervalSince(jsonStartTime)
// Measure binary encoding time
let binaryStartTime = Date()
let binaryData = try binaryCoder.encode(largeGraph)
let binaryEncodeTime = Date().timeIntervalSince(binaryStartTime)
print("Encoding performance for large graph:")
print("- JSON: \(String(format: "%.3f", jsonEncodeTime))s (\(jsonData.count) bytes)")
print("- Binary: \(String(format: "%.3f", binaryEncodeTime))s (\(binaryData.count) bytes)")
// Both should complete in reasonable time (under 1 second for this size)
#expect(jsonEncodeTime < 1.0, "JSON encoding should be fast")
#expect(binaryEncodeTime < 1.0, "Binary encoding should be fast")
// Measure decoding performance
let jsonDecodeStart = Date()
let _ = try jsonCoder.decode(jsonData)
let jsonDecodeTime = Date().timeIntervalSince(jsonDecodeStart)
let binaryDecodeStart = Date()
let _ = try binaryCoder.decode(binaryData)
let binaryDecodeTime = Date().timeIntervalSince(binaryDecodeStart)
print("Decoding performance for large graph:")
print("- JSON: \(String(format: "%.3f", jsonDecodeTime))s")
print("- Binary: \(String(format: "%.3f", binaryDecodeTime))s")
#expect(jsonDecodeTime < 1.0, "JSON decoding should be fast")
#expect(binaryDecodeTime < 1.0, "Binary decoding should be fast")
}
// MARK: - Edge Cases
@Test func emptyGraphSerialization() throws {
// Test edge case of empty graph
let emptyGraph = try BuildGraph(stages: [])
let jsonCoder = JSONIRCoder()
let binaryCoder = BinaryIRCoder()
let jsonData = try jsonCoder.encode(emptyGraph)
let binaryData = try binaryCoder.encode(emptyGraph)
let jsonDecoded = try jsonCoder.decode(jsonData)
let binaryDecoded = try binaryCoder.decode(binaryData)
#expect(jsonDecoded.stages.isEmpty)
#expect(binaryDecoded.stages.isEmpty)
#expect(jsonDecoded.buildArgs.isEmpty)
#expect(binaryDecoded.buildArgs.isEmpty)
}
@Test func graphWithSpecialCharacters() throws {
guard let baseRef = ImageReference(parsing: "alpine") else {
Issue.record("Failed to parse image reference")
return
}
let graph = try GraphBuilder.singleStage(from: baseRef) { builder in
try builder
.run("echo 'Special chars: 中文 émojis 🚀 newlines\n and quotes \"test\"'")
.env("UNICODE", "Contains unicode: ñáéíóú 中文字符 🌍")
.label("description", "Multi-line\ndescription with\ttabs and spaces")
.workdir("/app with spaces/and-symbols!@#$%")
}
let jsonCoder = JSONIRCoder(prettyPrint: true)
let binaryCoder = BinaryIRCoder()
let jsonData = try jsonCoder.encode(graph)
let binaryData = try binaryCoder.encode(graph)
let jsonDecoded = try jsonCoder.decode(jsonData)
let binaryDecoded = try binaryCoder.decode(binaryData)
#expect(jsonDecoded.stages.count == 1)
#expect(binaryDecoded.stages.count == 1)
// Check that the operations contain the expected special characters
let stage = jsonDecoded.stages[0]
let execOp = stage.nodes.compactMap { $0.operation as? ExecOperation }.first!
let envOp = stage.nodes.compactMap { $0.operation as? MetadataOperation }.first { op in
if case .setEnv(let key, _) = op.action, key == "UNICODE" { return true }
return false
}!
// Verify special characters are preserved
if case .shell(let command) = execOp.command {
#expect(command.contains("中文"), "Unicode characters should be preserved in command")
#expect(command.contains("🚀"), "Emoji should be preserved in command")
#expect(command.contains("\n"), "Newlines should be preserved in command")
}
if case .setEnv(_, let value) = envOp.action, case .literal(let envValue) = value {
#expect(envValue.contains("中文字符"), "Unicode characters should be preserved in env")
#expect(envValue.contains("🌍"), "Emoji should be preserved in env")
}
}
}