mirror of
https://github.com/apple/container.git
synced 2026-09-19 14:15:53 +00:00
We're working on making a pure swift container image build system that leverages containerization. This PR represents our initial design and initial work towards this goal. The native builder is still in active development and most of the implementation has not been started or completed. We will be opening a series of issues that represent various (but not necessarily all) pieces of work that need to be done here. There are docs included in this PR that describe the overall design of each component and outline some of our goals. The easiest way to view the docs by themselves (since this is a massive PR) is to look at the docs commit in the `Commits` tab. We'd love any feedback! @wlan0 --------- Signed-off-by: Kathryn Baldauf <k_baldauf@apple.com>
420 lines
17 KiB
Swift
420 lines
17 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 ContainerBuildIR
|
|
import ContainerBuildSnapshotter
|
|
import Foundation
|
|
import Testing
|
|
|
|
@testable import ContainerBuildCache
|
|
|
|
struct ContentAddressableCacheTests {
|
|
|
|
// MARK: - Basic Operations Tests
|
|
|
|
@Test func contentAddressableCacheBasicGetPut() async throws {
|
|
try await withCacheTestEnvironment { environment in
|
|
let mockContentStore = MockContentStore(baseDir: environment.tempDir)
|
|
let configuration = TestDataFactory.createCacheConfiguration(
|
|
maxSize: 1024 * 1024, // 1MB for tests
|
|
maxAge: 3600, // 1 hour for tests
|
|
indexPath: environment.tempDir.appendingPathComponent("cache-index")
|
|
)
|
|
|
|
// Note: This test assumes ContentAddressableCache can accept our protocol
|
|
// In a real implementation, we might need to create an adapter
|
|
let cache = try await ContentAddressableCache(
|
|
contentStore: mockContentStore,
|
|
configuration: configuration
|
|
)
|
|
defer {
|
|
// Cleanup handled by withCacheTestEnvironment
|
|
}
|
|
let operation = TestDataFactory.createOperation()
|
|
let key = TestDataFactory.createCacheKey(operation: operation)
|
|
let result = TestDataFactory.createCachedResult()
|
|
|
|
// Test cache miss
|
|
let initialResult = await cache.get(key, for: operation)
|
|
#expect(initialResult == nil)
|
|
|
|
// Test put
|
|
await cache.put(result, key: key, for: operation)
|
|
|
|
// Test cache hit
|
|
let cachedResult = await cache.get(key, for: operation)
|
|
#expect(cachedResult != nil)
|
|
#expect(cachedResult?.snapshot.id == result.snapshot.id)
|
|
#expect(cachedResult?.environmentChanges.count == result.environmentChanges.count)
|
|
#expect(cachedResult?.metadataChanges == result.metadataChanges)
|
|
}
|
|
}
|
|
|
|
@Test func contentAddressableCacheMiss() async throws {
|
|
try await withCacheTestEnvironment { environment in
|
|
let mockContentStore = MockContentStore(baseDir: environment.tempDir)
|
|
let configuration = TestDataFactory.createCacheConfiguration(
|
|
maxSize: 1024 * 1024, // 1MB for tests
|
|
maxAge: 3600, // 1 hour for tests
|
|
indexPath: environment.tempDir.appendingPathComponent("cache-index")
|
|
)
|
|
|
|
let cache = try await ContentAddressableCache(
|
|
contentStore: mockContentStore,
|
|
configuration: configuration
|
|
)
|
|
|
|
let key = TestDataFactory.createCacheKey()
|
|
let operation = TestDataFactory.createOperation()
|
|
|
|
let result = await cache.get(key, for: operation)
|
|
#expect(result == nil)
|
|
}
|
|
}
|
|
|
|
@Test func contentAddressableCacheHit() async throws {
|
|
try await withCacheTestEnvironment { environment in
|
|
let mockContentStore = MockContentStore(baseDir: environment.tempDir)
|
|
let configuration = TestDataFactory.createCacheConfiguration(
|
|
maxSize: 1024 * 1024, // 1MB for tests
|
|
maxAge: 3600, // 1 hour for tests
|
|
indexPath: environment.tempDir.appendingPathComponent("cache-index")
|
|
)
|
|
|
|
let cache = try await ContentAddressableCache(
|
|
contentStore: mockContentStore,
|
|
configuration: configuration
|
|
)
|
|
|
|
let operation = TestDataFactory.createOperation()
|
|
let key = TestDataFactory.createCacheKey(operation: operation)
|
|
let result = TestDataFactory.createCachedResult()
|
|
|
|
// Store the result
|
|
await cache.put(result, key: key, for: operation)
|
|
|
|
// Retrieve it
|
|
let cachedResult = await cache.get(key, for: operation)
|
|
#expect(cachedResult != nil)
|
|
#expect(cachedResult?.snapshot.digest == result.snapshot.digest)
|
|
}
|
|
}
|
|
|
|
@Test func contentAddressableCacheDuplicatePut() async throws {
|
|
try await withCacheTestEnvironment { environment in
|
|
let mockContentStore = MockContentStore(baseDir: environment.tempDir)
|
|
let configuration = TestDataFactory.createCacheConfiguration(
|
|
maxSize: 1024 * 1024, // 1MB for tests
|
|
maxAge: 3600, // 1 hour for tests
|
|
indexPath: environment.tempDir.appendingPathComponent("cache-index")
|
|
)
|
|
|
|
let cache = try await ContentAddressableCache(
|
|
contentStore: mockContentStore,
|
|
configuration: configuration
|
|
)
|
|
|
|
let key = TestDataFactory.createCacheKey()
|
|
let result1 = TestDataFactory.createCachedResult(snapshotContent: "content1")
|
|
let result2 = TestDataFactory.createCachedResult(snapshotContent: "content2")
|
|
let operation = TestDataFactory.createOperation()
|
|
|
|
// Store first result
|
|
await cache.put(result1, key: key, for: operation)
|
|
|
|
// Store second result with same key (should be ignored)
|
|
await cache.put(result2, key: key, for: operation)
|
|
|
|
// Should still get the first result
|
|
let cachedResult = await cache.get(key, for: operation)
|
|
#expect(cachedResult != nil)
|
|
#expect(cachedResult?.snapshot.digest == result1.snapshot.digest)
|
|
}
|
|
}
|
|
|
|
@Test func contentAddressableCacheStatistics() async throws {
|
|
try await withCacheTestEnvironment { environment in
|
|
let mockContentStore = MockContentStore(baseDir: environment.tempDir)
|
|
let configuration = TestDataFactory.createCacheConfiguration(
|
|
maxSize: 1024 * 1024, // 1MB for tests
|
|
maxAge: 3600, // 1 hour for tests
|
|
indexPath: environment.tempDir.appendingPathComponent("cache-index")
|
|
)
|
|
|
|
let cache = try await ContentAddressableCache(
|
|
contentStore: mockContentStore,
|
|
configuration: configuration
|
|
)
|
|
|
|
let stats = await cache.statistics()
|
|
#expect(stats.entryCount == 0)
|
|
#expect(stats.totalSize == 0)
|
|
|
|
// Add some entries
|
|
let key1 = TestDataFactory.createCacheKey(operationContent: "op1")
|
|
let key2 = TestDataFactory.createCacheKey(operationContent: "op2")
|
|
let result = TestDataFactory.createCachedResult()
|
|
let operation = TestDataFactory.createOperation()
|
|
|
|
await cache.put(result, key: key1, for: operation)
|
|
await cache.put(result, key: key2, for: operation)
|
|
|
|
let updatedStats = await cache.statistics()
|
|
#expect(updatedStats.entryCount == 2)
|
|
#expect(updatedStats.totalSize > 0)
|
|
}
|
|
}
|
|
|
|
// MARK: - Eviction Tests
|
|
|
|
@Test func contentAddressableCacheEvictionBySize() async throws {
|
|
try await withCacheTestEnvironment { environment in
|
|
let mockContentStore = MockContentStore(baseDir: environment.tempDir)
|
|
|
|
// Create a small cache
|
|
let smallConfig = TestDataFactory.createCacheConfiguration(
|
|
maxSize: 2048, // Very small cache
|
|
indexPath: environment.tempDir.appendingPathComponent("small-cache-index")
|
|
)
|
|
|
|
let smallCache = try await ContentAddressableCache(
|
|
contentStore: mockContentStore,
|
|
configuration: smallConfig
|
|
)
|
|
|
|
// Fill the cache beyond capacity
|
|
let operation = TestDataFactory.createOperation()
|
|
for i in 0..<10 {
|
|
let key = TestDataFactory.createCacheKey(operationContent: "operation-\(i)")
|
|
let result = TestDataFactory.createCachedResult(snapshotContent: "large-content-\(i)")
|
|
await smallCache.put(result, key: key, for: operation)
|
|
}
|
|
|
|
// Give eviction time to run
|
|
try await Task.sleep(nanoseconds: 100_000_000 * 5) // 0.1 seconds
|
|
|
|
let stats = await smallCache.statistics()
|
|
#expect(stats.totalSize < 2048 * 2) // Should have evicted some entries
|
|
}
|
|
}
|
|
|
|
@Test func contentAddressableCacheEvictionByAge() async throws {
|
|
try await withCacheTestEnvironment { environment in
|
|
let mockContentStore = MockContentStore(baseDir: environment.tempDir)
|
|
let configuration = TestDataFactory.createCacheConfiguration(
|
|
maxSize: 1024 * 1024, // 1MB for tests
|
|
maxAge: 3600, // 1 hour for tests
|
|
indexPath: environment.tempDir.appendingPathComponent("cache-index")
|
|
)
|
|
|
|
let cache = try await ContentAddressableCache(
|
|
contentStore: mockContentStore,
|
|
configuration: configuration
|
|
)
|
|
|
|
// This test would require manipulating time or using a mock clock
|
|
// For now, we'll test the TTL-based eviction logic
|
|
|
|
let key = TestDataFactory.createCacheKey()
|
|
let result = TestDataFactory.createCachedResult()
|
|
let operation = TestDataFactory.createOperation()
|
|
|
|
await cache.put(result, key: key, for: operation)
|
|
|
|
// Verify it's there
|
|
let cachedResult = await cache.get(key, for: operation)
|
|
#expect(cachedResult != nil)
|
|
|
|
// In a real test, we'd advance time and check eviction
|
|
// For now, just verify the entry exists
|
|
let stats = await cache.statistics()
|
|
#expect(stats.entryCount == 1)
|
|
}
|
|
}
|
|
|
|
@Test func contentAddressableCacheEvictionByTTL() async throws {
|
|
try await withCacheTestEnvironment { environment in
|
|
let mockContentStore = MockContentStore(baseDir: environment.tempDir)
|
|
|
|
// Create configuration with short TTL
|
|
let shortTTLConfig = TestDataFactory.createCacheConfiguration(
|
|
maxSize: 1024 * 1024,
|
|
maxAge: 1, // 1 second TTL
|
|
indexPath: environment.tempDir.appendingPathComponent("ttl-cache-index")
|
|
)
|
|
|
|
let ttlCache = try await ContentAddressableCache(
|
|
contentStore: mockContentStore,
|
|
configuration: shortTTLConfig
|
|
)
|
|
|
|
let key = TestDataFactory.createCacheKey()
|
|
let result = TestDataFactory.createCachedResult()
|
|
let operation = TestDataFactory.createOperation()
|
|
|
|
await ttlCache.put(result, key: key, for: operation)
|
|
|
|
// Verify it's there initially
|
|
let initialResult = await ttlCache.get(key, for: operation)
|
|
#expect(initialResult != nil)
|
|
|
|
// Wait for TTL to expire
|
|
try await Task.sleep(nanoseconds: 1_500_000_000) // 1.5 seconds
|
|
|
|
// Entry should be evicted (this depends on the implementation running periodic cleanup)
|
|
// Note: This test might be flaky depending on the eviction implementation
|
|
}
|
|
}
|
|
|
|
// MARK: - Concurrency Tests
|
|
|
|
@Test func contentAddressableCacheConcurrentOperations() async throws {
|
|
try await withCacheTestEnvironment { environment in
|
|
let mockContentStore = MockContentStore(baseDir: environment.tempDir)
|
|
let configuration = TestDataFactory.createCacheConfiguration(
|
|
maxSize: 1024 * 1024, // 1MB for tests
|
|
maxAge: 3600, // 1 hour for tests
|
|
indexPath: environment.tempDir.appendingPathComponent("cache-index")
|
|
)
|
|
|
|
let cache = try await ContentAddressableCache(
|
|
contentStore: mockContentStore,
|
|
configuration: configuration
|
|
)
|
|
|
|
let operation = TestDataFactory.createOperation()
|
|
|
|
// Test concurrent puts and gets
|
|
await withTaskGroup(of: Void.self) { group in
|
|
// Concurrent puts
|
|
for i in 0..<5 {
|
|
group.addTask {
|
|
let key = TestDataFactory.createCacheKey(operationContent: "concurrent-op-\(i)")
|
|
let result = TestDataFactory.createCachedResult(snapshotContent: "content-\(i)")
|
|
await cache.put(result, key: key, for: operation)
|
|
}
|
|
}
|
|
|
|
// Concurrent gets
|
|
for i in 0..<5 {
|
|
group.addTask {
|
|
let key = TestDataFactory.createCacheKey(operationContent: "concurrent-op-\(i)")
|
|
_ = await cache.get(key, for: operation)
|
|
}
|
|
}
|
|
}
|
|
|
|
let stats = await cache.statistics()
|
|
#expect(stats.entryCount > 0)
|
|
}
|
|
}
|
|
|
|
// MARK: - Error Handling Tests
|
|
|
|
@Test func contentAddressableCacheCorruptedData() async throws {
|
|
try await withCacheTestEnvironment { environment in
|
|
let mockContentStore = MockContentStore(baseDir: environment.tempDir)
|
|
let configuration = TestDataFactory.createCacheConfiguration(
|
|
maxSize: 1024 * 1024, // 1MB for tests
|
|
maxAge: 3600, // 1 hour for tests
|
|
indexPath: environment.tempDir.appendingPathComponent("cache-index")
|
|
)
|
|
|
|
let cache = try await ContentAddressableCache(
|
|
contentStore: mockContentStore,
|
|
configuration: configuration
|
|
)
|
|
|
|
// This test would require injecting corrupted data into the content store
|
|
// For now, we'll test basic error resilience
|
|
|
|
let key = TestDataFactory.createCacheKey()
|
|
let operation = TestDataFactory.createOperation()
|
|
|
|
// Try to get from empty cache (should handle gracefully)
|
|
let result = await cache.get(key, for: operation)
|
|
#expect(result == nil)
|
|
|
|
// Cache should still be functional
|
|
let stats = await cache.statistics()
|
|
#expect(stats.entryCount == 0)
|
|
}
|
|
}
|
|
|
|
@Test func contentAddressableCacheMissingContentStore() async throws {
|
|
try await withCacheTestEnvironment { environment in
|
|
let mockContentStore = MockContentStore(baseDir: environment.tempDir)
|
|
let configuration = TestDataFactory.createCacheConfiguration(
|
|
maxSize: 1024 * 1024, // 1MB for tests
|
|
maxAge: 3600, // 1 hour for tests
|
|
indexPath: environment.tempDir.appendingPathComponent("cache-index")
|
|
)
|
|
|
|
let cache = try await ContentAddressableCache(
|
|
contentStore: mockContentStore,
|
|
configuration: configuration
|
|
)
|
|
|
|
// Test behavior when content store operations fail
|
|
// This would require a mock that can simulate failures
|
|
|
|
let key = TestDataFactory.createCacheKey()
|
|
let result = TestDataFactory.createCachedResult()
|
|
let operation = TestDataFactory.createOperation()
|
|
|
|
// Put should not crash even if content store has issues
|
|
await cache.put(result, key: key, for: operation)
|
|
|
|
// Get should handle missing content gracefully
|
|
let _ = await cache.get(key, for: operation)
|
|
// Result depends on whether the put succeeded despite content store issues
|
|
}
|
|
}
|
|
|
|
// MARK: - Performance Tests
|
|
|
|
@Test func contentAddressableCachePerformance() async throws {
|
|
try await withCacheTestEnvironment { environment in
|
|
let mockContentStore = MockContentStore(baseDir: environment.tempDir)
|
|
let configuration = TestDataFactory.createCacheConfiguration(
|
|
maxSize: 1024 * 1024, // 1MB for tests
|
|
maxAge: 3600, // 1 hour for tests
|
|
indexPath: environment.tempDir.appendingPathComponent("cache-index")
|
|
)
|
|
|
|
let cache = try await ContentAddressableCache(
|
|
contentStore: mockContentStore,
|
|
configuration: configuration
|
|
)
|
|
|
|
let operation = TestDataFactory.createOperation()
|
|
|
|
let (_, duration) = await PerformanceMeasurement.measure {
|
|
// Perform multiple cache operations
|
|
for i in 0..<100 {
|
|
let key = TestDataFactory.createCacheKey(operationContent: "perf-test-\(i)")
|
|
let result = TestDataFactory.createCachedResult(snapshotContent: "content-\(i)")
|
|
await cache.put(result, key: key, for: operation)
|
|
_ = await cache.get(key, for: operation)
|
|
}
|
|
}
|
|
|
|
// Assert reasonable performance (adjust threshold as needed)
|
|
#expect(duration < 5.0, "Cache operations took too long: \(duration)s")
|
|
}
|
|
}
|
|
}
|