Files
Dmitry Kovba c571546f60 Use a lock consistently in the ExecutionContext class (#619)
The changes in this PR improve the consistency of using a lock in the
`ExecutionContext` class.
2025-09-17 09:49:30 -07:00

484 lines
16 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 ContainerBuildReporting
import ContainerBuildSnapshotter
import ContainerizationOCI
import Foundation
/// Carries execution state through operation execution.
///
/// The context maintains the current state of the build, including filesystem
/// snapshots, environment variables, and other mutable state that operations
/// may read or modify.
public final class ExecutionContext: @unchecked Sendable {
/// The current build stage being executed.
public let stage: BuildStage
/// The complete build graph.
public let graph: BuildGraph
/// The target platform for this execution.
public let platform: Platform
/// Progress reporter for build events.
public let reporter: Reporter
/// The snapshotter for managing filesystem snapshots.
public let snapshotter: any Snapshotter
/// Current environment variables.
private var _environment: Environment
/// Current working directory.
private var _workingDirectory: String
/// Current user.
private var _user: ContainerBuildIR.User?
/// Image configuration being built.
private var _imageConfig: OCIImageConfig
/// Snapshots for each executed node.
private var _snapshots: [UUID: Snapshot]
/// Currently active (prepared but not committed) snapshots for cleanup.
private var _activeSnapshots: [UUID: Snapshot]
/// Head (most recent committed) snapshot for this context.
private var _headSnapshot: Snapshot?
/// Lock for thread-safe access.
private let lock = NSLock()
/// Serialize filesystem mutations within this context.
/// Ensures prepare → body → commit happens one-at-a-time per context to avoid
/// divergent snapshot branches when multiple FS-mutating operations run in parallel.
private let fsSemaphore = AsyncSemaphore(value: 1)
public init(
stage: BuildStage,
graph: BuildGraph,
platform: Platform,
reporter: Reporter,
snapshotter: any Snapshotter,
baseEnvironment: Environment = .init(),
baseConfig: OCIImageConfig? = nil
) {
self.stage = stage
self.graph = graph
self.platform = platform
self.reporter = reporter
self.snapshotter = snapshotter
self._environment = baseEnvironment
self._workingDirectory = "/"
self._user = nil
self._imageConfig = baseConfig ?? OCIImageConfig(platform: platform)
self._snapshots = [:]
self._activeSnapshots = [:]
self._headSnapshot = nil
}
deinit {
// Clean up any remaining active snapshots
// Note: This is a best-effort cleanup since deinit can't be async
// The proper cleanup should happen in the executor error handling
if !_activeSnapshots.isEmpty {
// Log warning about unclean shutdown
print("Warning: ExecutionContext deallocated with \(_activeSnapshots.count) active snapshots")
}
}
/// Get the current environment.
public var environment: Environment {
lock.withLock { _environment }
}
/// Update the environment.
public func updateEnvironment(_ updates: [String: EnvironmentValue]) {
lock.withLock {
// Create new environment with updates
var newVars = _environment.variables
for (key, value) in updates {
// Remove existing entries for this key
newVars.removeAll { $0.key == key }
// Add new entry
newVars.append((key: key, value: value))
}
_environment = Environment(newVars)
}
}
/// Get the current working directory.
public var workingDirectory: String {
lock.withLock { _workingDirectory }
}
/// Set the working directory.
public func setWorkingDirectory(_ path: String) {
lock.withLock { _workingDirectory = path }
}
/// Get the current user.
public var user: ContainerBuildIR.User? {
lock.withLock { _user }
}
/// Set the current user.
public func setUser(_ user: ContainerBuildIR.User?) {
lock.withLock { _user = user }
}
/// Get the current image configuration.
public var imageConfig: OCIImageConfig {
lock.withLock { _imageConfig }
}
/// Update the image configuration.
public func updateImageConfig(_ updates: (inout OCIImageConfig) -> Void) {
lock.withLock {
updates(&_imageConfig)
}
}
/// Get the snapshot for a node.
public func snapshot(for nodeId: UUID) -> Snapshot? {
lock.withLock { _snapshots[nodeId] }
}
/// Set the snapshot for a node.
public func setSnapshot(_ snapshot: Snapshot, for nodeId: UUID) {
lock.withLock {
_snapshots[nodeId] = snapshot
_headSnapshot = snapshot
}
}
/// The current head snapshot (last committed snapshot in this context).
public var headSnapshot: Snapshot? {
lock.withLock { _headSnapshot }
}
/// Create a child context for a nested execution.
public func childContext(for stage: BuildStage) -> ExecutionContext {
let (environmentVars, imageConfig) = lock.withLock {
(_environment.variables, _imageConfig)
}
return ExecutionContext(
stage: stage,
graph: graph,
platform: platform,
reporter: reporter,
snapshotter: snapshotter,
baseEnvironment: Environment(environmentVars),
baseConfig: imageConfig
)
}
// MARK: - Snapshotter Integration
/// Prepare a snapshot for modification by an operation.
///
/// This method handles the snapshotter lifecycle:
/// 1. Takes a parent snapshot (or creates a base snapshot if none)
/// 2. Calls snapshotter.prepare() to make it ready for modification
/// 3. Tracks the prepared snapshot for cleanup if needed
///
/// - Parameter operationId: The UUID of the operation that will modify this snapshot
/// - Returns: A prepared snapshot ready for modification
/// - Throws: Any errors from the snapshotter
public func prepareSnapshot(for operationId: UUID) async throws -> Snapshot {
let parentCommitted = headSnapshot
// Always create a new child snapshot that points to the latest committed snapshot (if any).
// Prepare is responsible for ensuring both the child mountpoint and parent materialization (if needed).
let tempMountPoint = FileManager.default.temporaryDirectory
.appendingPathComponent("child-snapshot", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let snapshotToPrepare: Snapshot
if let parent = parentCommitted {
snapshotToPrepare = Snapshot(
digest: parent.digest, // initial digest equals base; final digest set at commit
size: parent.size,
parent: parent,
state: .prepared(mountpoint: tempMountPoint)
)
} else {
snapshotToPrepare = Snapshot(
digest: try Digest(algorithm: .sha256, bytes: Data(count: 32)), // Empty digest for scratch
size: 0,
parent: nil,
state: .prepared(mountpoint: tempMountPoint)
)
}
// Prepare the snapshot via snapshotter (materializes parent if needed).
let preparedSnapshot = try await snapshotter.prepare(snapshotToPrepare)
// Track active snapshot for cleanup
lock.withLock {
_activeSnapshots[operationId] = preparedSnapshot
}
return preparedSnapshot
}
/// Commit a prepared snapshot after an operation completes successfully.
///
/// This method:
/// 1. Calls snapshotter.commit() to finalize the snapshot
/// 2. Stores the committed snapshot for the operation
/// 3. Removes it from active snapshots tracking
///
/// - Parameters:
/// - snapshot: The prepared snapshot to commit
/// - operationId: The UUID of the operation that modified this snapshot
/// - Returns: The committed snapshot with final digest and state
/// - Throws: Any errors from the snapshotter
public func commitSnapshot(_ snapshot: Snapshot, for operationId: UUID) async throws -> Snapshot {
let committedSnapshot = try await snapshotter.commit(snapshot)
lock.withLock {
// Store committed snapshot
_snapshots[operationId] = committedSnapshot
// Update head pointer
_headSnapshot = committedSnapshot
// Remove from active tracking
_activeSnapshots.removeValue(forKey: operationId)
}
return committedSnapshot
}
/// Clean up a prepared snapshot if an operation fails.
///
/// This method:
/// 1. Calls snapshotter.remove() to clean up the snapshot
/// 2. Removes it from active snapshots tracking
///
/// - Parameter operationId: The UUID of the operation that failed
public func cleanupSnapshot(for operationId: UUID) async {
let snapshotToCleanup = lock.withLock {
_activeSnapshots.removeValue(forKey: operationId)
}
if let snapshot = snapshotToCleanup {
do {
try await snapshotter.remove(snapshot)
} catch {
// Log error but don't throw - this is cleanup
let context = ReportContext(description: "Snapshot cleanup warning")
await reporter.report(.operationLog(context: context, message: "Failed to cleanup snapshot \(snapshot.id): \(error)"))
}
}
}
/// Clean up all active snapshots (for context cleanup).
public func cleanupAllActiveSnapshots() async {
let activeSnapshots = lock.withLock {
let snapshots = Array(_activeSnapshots.values)
_activeSnapshots.removeAll()
return snapshots
}
for snapshot in activeSnapshots {
do {
try await snapshotter.remove(snapshot)
} catch {
// Log error but continue cleanup
let context = ReportContext(description: "Snapshot cleanup warning")
await reporter.report(.operationLog(context: context, message: "Failed to cleanup snapshot \(snapshot.id): \(error)"))
}
}
}
/// Get the count of active snapshots (for monitoring/debugging).
public var activeSnapshotCount: Int {
lock.withLock { _activeSnapshots.count }
}
/// Get the count of committed snapshots (for monitoring/debugging).
public var committedSnapshotCount: Int {
lock.withLock { _snapshots.count }
}
// MARK: - Snapshot helper
/// Convenience wrapper to prepare, use, and commit a snapshot around a body of work.
/// - Parameters:
/// - base: Optional starting snapshot to prepare. If nil, a new child of the latest committed snapshot is created.
/// - body: Async body that performs work against the prepared snapshot.
/// - Returns: Tuple of (result returned by body, final committed snapshot)
@discardableResult
public func withSnapshot<T: Sendable>(startingFrom base: Snapshot? = nil, _ body: @Sendable (Snapshot) async throws -> T) async throws -> (T, Snapshot) {
try await fsSemaphore.withPermit {
let operationId = UUID()
// Prepare snapshot
let workingSnapshot: Snapshot
if let base = base {
// Prepare provided base snapshot and track it as active for cleanup
let prepared: Snapshot
switch base.state {
case .prepared:
prepared = base
default:
prepared = try await snapshotter.prepare(base)
}
lock.withLock {
_activeSnapshots[operationId] = prepared
}
workingSnapshot = prepared
} else {
workingSnapshot = try await prepareSnapshot(for: operationId)
}
do {
// Execute body work
let result = try await body(workingSnapshot)
// Commit and persist
let finalSnapshot = try await commitSnapshot(workingSnapshot, for: operationId)
return (result, finalSnapshot)
} catch {
// Cleanup on failure then rethrow
await cleanupSnapshot(for: operationId)
throw error
}
}
}
/// Prepare and commit a snapshot from a provided base without performing any body work.
/// If the base is not already prepared, it will be prepared first.
/// - Parameter base: The base snapshot to prepare and commit.
/// - Returns: The committed snapshot.
public func prepareAndCommit(from base: Snapshot) async throws -> Snapshot {
try await fsSemaphore.withPermit {
let operationId = UUID()
// Prepare if needed and track as active for cleanup
let working: Snapshot
switch base.state {
case .prepared:
working = base
lock.withLock { _activeSnapshots[operationId] = working }
default:
let prepared = try await snapshotter.prepare(base)
lock.withLock { _activeSnapshots[operationId] = prepared }
working = prepared
}
do {
let committed = try await commitSnapshot(working, for: operationId)
return committed
} catch {
await cleanupSnapshot(for: operationId)
throw error
}
}
}
}
/// OCI image configuration.
///
/// Represents the configuration for an OCI container image.
public struct OCIImageConfig: Sendable {
/// The platform this image is for.
public let platform: Platform
/// Environment variables.
public var env: [String]
/// Default command.
public var cmd: [String]?
/// Entry point.
public var entrypoint: [String]?
/// Working directory.
public var workingDir: String?
/// User.
public var user: String?
/// Exposed ports.
public var exposedPorts: Set<String>
/// Volumes.
public var volumes: Set<String>
/// Labels.
public var labels: [String: String]
/// Stop signal.
public var stopSignal: String?
/// Health check.
public var healthcheck: Healthcheck?
public init(
platform: Platform,
env: [String] = [],
cmd: [String]? = nil,
entrypoint: [String]? = nil,
workingDir: String? = nil,
user: String? = nil,
exposedPorts: Set<String> = [],
volumes: Set<String> = [],
labels: [String: String] = [:],
stopSignal: String? = nil,
healthcheck: Healthcheck? = nil
) {
self.platform = platform
self.env = env
self.cmd = cmd
self.entrypoint = entrypoint
self.workingDir = workingDir
self.user = user
self.exposedPorts = exposedPorts
self.volumes = volumes
self.labels = labels
self.stopSignal = stopSignal
self.healthcheck = healthcheck
}
}
// Helper extension for thread-safe lock usage
extension NSLock {
func withLock<T>(_ body: () throws -> T) rethrows -> T {
lock()
defer { unlock() }
return try body()
}
}
// Helper extension for Environment
extension Environment {
/// Get the value for a key.
public func get(_ key: String) -> EnvironmentValue? {
for (k, v) in variables.reversed() {
if k == key {
return v
}
}
return nil
}
}