Merge nucleic/fuzzy-yarn-otter-2pji into dev
This commit is contained in:
@@ -26,11 +26,26 @@ import SystemPackage
|
||||
|
||||
import struct ContainerizationOS.Terminal
|
||||
|
||||
/// Errors produced while committing generation-scoped container operations.
|
||||
public enum LinuxContainerOperationError: Error, Sendable, Equatable {
|
||||
/// External work completed after the container generation which reserved it was invalidated.
|
||||
case staleGeneration(expected: UInt64, actual: UInt64)
|
||||
}
|
||||
|
||||
/// `LinuxContainer` is an easy to use type for launching and managing the
|
||||
/// full lifecycle of a Linux container ran inside of a virtual machine.
|
||||
public final class LinuxContainer: Container, Sendable {
|
||||
public static let maxIDLength = 64
|
||||
|
||||
/// Process-wide source for generation identities. Allocating from one sequence, rather than
|
||||
/// restarting at zero for each `LinuxContainer` object, keeps late lifecycle evidence from an
|
||||
/// old object from aliasing a replacement object's first generation.
|
||||
private static let generationSequence = Atomic<UInt64>(0)
|
||||
|
||||
private static func allocateGeneration() -> UInt64 {
|
||||
generationSequence.wrappingAdd(1, ordering: .relaxed).oldValue
|
||||
}
|
||||
|
||||
/// The identifier of the container.
|
||||
public let id: String
|
||||
|
||||
@@ -135,6 +150,25 @@ public final class LinuxContainer: Container, Sendable {
|
||||
|
||||
private let state: AsyncMutex<State>
|
||||
|
||||
private let _generation: Atomic<UInt64>
|
||||
|
||||
/// Process-wide unique identifier for the current container generation. It changes when a
|
||||
/// running generation is retired, allowing callers to reject results and evidence produced by
|
||||
/// an older VM instance even when its replacement uses a newly allocated container object.
|
||||
public var generation: UInt64 {
|
||||
_generation.load(ordering: .acquiring)
|
||||
}
|
||||
|
||||
/// Atomically invalidate every reservation made against the current generation. The returned
|
||||
/// identity is the newly installed dead-generation sentinel; no container object can later use
|
||||
/// the same value for a live generation.
|
||||
@discardableResult
|
||||
public func markGenerationDead() -> UInt64 {
|
||||
let deadGeneration = Self.allocateGeneration()
|
||||
_generation.store(deadGeneration, ordering: .releasing)
|
||||
return deadGeneration
|
||||
}
|
||||
|
||||
// Ports to be allocated from for stdio and for
|
||||
// unix socket relays that are sharing a guest
|
||||
// uds to the host.
|
||||
@@ -147,6 +181,11 @@ public final class LinuxContainer: Container, Sendable {
|
||||
private let copyQueue = DispatchQueue(label: "com.apple.containerization.copy")
|
||||
|
||||
private enum State: Sendable {
|
||||
struct ExecReservation: Sendable, Equatable {
|
||||
let operationID: UUID
|
||||
let generation: UInt64
|
||||
}
|
||||
|
||||
/// The container class has been created but no live resources are running.
|
||||
case initialized
|
||||
/// The container's virtual machine has been setup and the runtime environment has been configured.
|
||||
@@ -171,6 +210,7 @@ public final class LinuxContainer: Container, Sendable {
|
||||
let process: LinuxProcess
|
||||
let relayManager: UnixSocketRelayManager
|
||||
var vendedProcesses: [String: LinuxProcess]
|
||||
var execReservations: [String: ExecReservation]
|
||||
let fileMountContext: FileMountContext
|
||||
|
||||
init(_ state: CreatedState, process: LinuxProcess) {
|
||||
@@ -178,6 +218,7 @@ public final class LinuxContainer: Container, Sendable {
|
||||
self.relayManager = state.relayManager
|
||||
self.process = process
|
||||
self.vendedProcesses = [:]
|
||||
self.execReservations = [:]
|
||||
self.fileMountContext = state.fileMountContext
|
||||
}
|
||||
|
||||
@@ -186,6 +227,7 @@ public final class LinuxContainer: Container, Sendable {
|
||||
self.relayManager = state.relayManager
|
||||
self.process = state.process
|
||||
self.vendedProcesses = state.vendedProcesses
|
||||
self.execReservations = state.execReservations
|
||||
self.fileMountContext = state.fileMountContext
|
||||
}
|
||||
}
|
||||
@@ -195,6 +237,7 @@ public final class LinuxContainer: Container, Sendable {
|
||||
let relayManager: UnixSocketRelayManager
|
||||
let process: LinuxProcess
|
||||
var vendedProcesses: [String: LinuxProcess]
|
||||
var execReservations: [String: ExecReservation]
|
||||
let fileMountContext: FileMountContext
|
||||
|
||||
init(_ state: StartedState) {
|
||||
@@ -202,6 +245,7 @@ public final class LinuxContainer: Container, Sendable {
|
||||
self.relayManager = state.relayManager
|
||||
self.process = state.process
|
||||
self.vendedProcesses = state.vendedProcesses
|
||||
self.execReservations = state.execReservations
|
||||
self.fileMountContext = state.fileMountContext
|
||||
}
|
||||
}
|
||||
@@ -361,6 +405,7 @@ public final class LinuxContainer: Container, Sendable {
|
||||
self.logger = logger
|
||||
self.config = configuration
|
||||
self.state = AsyncMutex(.initialized)
|
||||
self._generation = Atomic(Self.allocateGeneration())
|
||||
self.rootfs = rootfs
|
||||
self.writableLayer = writableLayer
|
||||
}
|
||||
@@ -779,6 +824,10 @@ extension LinuxContainer {
|
||||
relayManager = createdState.relayManager
|
||||
}
|
||||
|
||||
// Invalidate unlocked operations as soon as stop has validated a live state. A failed
|
||||
// stop is still the end of this usable generation: late dial completions must not commit.
|
||||
self.markGenerationDead()
|
||||
|
||||
var firstError: Error?
|
||||
do {
|
||||
try await relayManager.stopAll()
|
||||
@@ -904,85 +953,101 @@ extension LinuxContainer {
|
||||
/// Execute a new process in the container. The process is not started after this call, and must be manually started
|
||||
/// via the `start` method.
|
||||
public func exec(_ id: String, configuration: @Sendable @escaping (inout LinuxProcessConfiguration) throws -> Void) async throws -> LinuxProcess {
|
||||
try await self.state.withLock { state in
|
||||
var startedState = try state.startedState("exec")
|
||||
|
||||
var spec = self.generateRuntimeSpec()
|
||||
var config = LinuxProcessConfiguration()
|
||||
try configuration(&config)
|
||||
spec.process = config.toOCI()
|
||||
// [Nucleic vendored patch] Per-exec memory ceiling → the exec's OCI resources, which the
|
||||
// guest applies as memory.max on this exec's own cgroup (patch #9).
|
||||
if let limit = config.memoryLimitInBytes {
|
||||
spec.linux?.resources?.memory?.limit = Int64(limit)
|
||||
}
|
||||
|
||||
let stdio = IOUtil.setup(
|
||||
portAllocator: self.hostVsockPorts,
|
||||
stdin: config.stdin,
|
||||
stdout: config.stdout,
|
||||
stderr: config.stderr
|
||||
)
|
||||
let agent = try await startedState.vm.dialAgent()
|
||||
let process = LinuxProcess(
|
||||
id,
|
||||
containerID: self.id,
|
||||
spec: spec,
|
||||
io: stdio,
|
||||
ociRuntimePath: self.config.ociRuntimePath,
|
||||
agent: agent,
|
||||
vm: startedState.vm,
|
||||
logger: self.logger,
|
||||
onDelete: { [weak self = self] in
|
||||
await self?.removeProcess(id: id)
|
||||
}
|
||||
)
|
||||
|
||||
startedState.vendedProcesses[id] = process
|
||||
state = .started(startedState)
|
||||
|
||||
return process
|
||||
}
|
||||
var config = LinuxProcessConfiguration()
|
||||
try configuration(&config)
|
||||
return try await makeExec(id, configuration: config)
|
||||
}
|
||||
|
||||
/// Execute a new process in the container. The process is not started after this call, and must be manually started
|
||||
/// via the `start` method.
|
||||
public func exec(_ id: String, configuration: LinuxProcessConfiguration) async throws -> LinuxProcess {
|
||||
try await self.state.withLock {
|
||||
var state = try $0.startedState("exec")
|
||||
try await makeExec(id, configuration: configuration)
|
||||
}
|
||||
|
||||
var spec = self.generateRuntimeSpec()
|
||||
spec.process = configuration.toOCI()
|
||||
// [Nucleic vendored patch] Per-exec memory ceiling → the exec's OCI resources (see above).
|
||||
if let limit = configuration.memoryLimitInBytes {
|
||||
spec.linux?.resources?.memory?.limit = Int64(limit)
|
||||
private struct ExecSnapshot: Sendable {
|
||||
let vm: any VirtualMachineInstance
|
||||
let reservation: State.ExecReservation
|
||||
}
|
||||
|
||||
private func makeExec(_ id: String, configuration: LinuxProcessConfiguration) async throws -> LinuxProcess {
|
||||
let snapshot = try await self.state.withLock { state in
|
||||
var startedState = try state.startedState("exec")
|
||||
let reservation = State.ExecReservation(operationID: UUID(), generation: self.generation)
|
||||
startedState.execReservations[id] = reservation
|
||||
state = .started(startedState)
|
||||
return ExecSnapshot(vm: startedState.vm, reservation: reservation)
|
||||
}
|
||||
|
||||
let agent: any VirtualMachineAgent
|
||||
do {
|
||||
agent = try await snapshot.vm.dialAgent()
|
||||
} catch {
|
||||
await releaseExecReservation(id: id, reservation: snapshot.reservation)
|
||||
throw error
|
||||
}
|
||||
|
||||
var spec = self.generateRuntimeSpec()
|
||||
spec.process = configuration.toOCI()
|
||||
// [Nucleic vendored patch] Per-exec memory ceiling → the exec's OCI resources, which the
|
||||
// guest applies as memory.max on this exec's own cgroup (patch #9).
|
||||
if let limit = configuration.memoryLimitInBytes {
|
||||
spec.linux?.resources?.memory?.limit = Int64(limit)
|
||||
}
|
||||
let stdio = IOUtil.setup(
|
||||
portAllocator: self.hostVsockPorts,
|
||||
stdin: configuration.stdin,
|
||||
stdout: configuration.stdout,
|
||||
stderr: configuration.stderr
|
||||
)
|
||||
let process = LinuxProcess(
|
||||
id,
|
||||
containerID: self.id,
|
||||
spec: spec,
|
||||
io: stdio,
|
||||
ociRuntimePath: self.config.ociRuntimePath,
|
||||
agent: agent,
|
||||
vm: snapshot.vm,
|
||||
logger: self.logger,
|
||||
onDelete: { [weak self = self] in
|
||||
await self?.removeProcess(id: id)
|
||||
}
|
||||
)
|
||||
|
||||
let stdio = IOUtil.setup(
|
||||
portAllocator: self.hostVsockPorts,
|
||||
stdin: configuration.stdin,
|
||||
stdout: configuration.stdout,
|
||||
stderr: configuration.stderr
|
||||
)
|
||||
let agent = try await state.vm.dialAgent()
|
||||
let process = LinuxProcess(
|
||||
id,
|
||||
containerID: self.id,
|
||||
spec: spec,
|
||||
io: stdio,
|
||||
ociRuntimePath: self.config.ociRuntimePath,
|
||||
agent: agent,
|
||||
vm: state.vm,
|
||||
logger: self.logger,
|
||||
onDelete: { [weak self = self] in
|
||||
await self?.removeProcess(id: id)
|
||||
}
|
||||
let committed = await self.state.withLock { state in
|
||||
guard case .started(var startedState) = state,
|
||||
self.generation == snapshot.reservation.generation,
|
||||
startedState.execReservations[id] == snapshot.reservation
|
||||
else {
|
||||
return false
|
||||
}
|
||||
startedState.execReservations.removeValue(forKey: id)
|
||||
startedState.vendedProcesses[id] = process
|
||||
state = .started(startedState)
|
||||
return true
|
||||
}
|
||||
guard committed else {
|
||||
await releaseExecReservation(id: id, reservation: snapshot.reservation)
|
||||
try? await agent.close()
|
||||
throw LinuxContainerOperationError.staleGeneration(
|
||||
expected: snapshot.reservation.generation,
|
||||
actual: self.generation
|
||||
)
|
||||
}
|
||||
return process
|
||||
}
|
||||
|
||||
state.vendedProcesses[id] = process
|
||||
$0 = .started(state)
|
||||
|
||||
return process
|
||||
private func releaseExecReservation(id: String, reservation: State.ExecReservation) async {
|
||||
await self.state.withLock { state in
|
||||
switch state {
|
||||
case .started(var startedState) where startedState.execReservations[id] == reservation:
|
||||
startedState.execReservations.removeValue(forKey: id)
|
||||
state = .started(startedState)
|
||||
case .paused(var pausedState) where pausedState.execReservations[id] == reservation:
|
||||
pausedState.execReservations.removeValue(forKey: id)
|
||||
state = .paused(pausedState)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,21 +1095,23 @@ extension LinuxContainer {
|
||||
|
||||
/// Get statistics for the container.
|
||||
public func statistics(categories: StatCategory = .all) async throws -> ContainerStatistics {
|
||||
try await self.state.withLock {
|
||||
let state = try $0.startedState("statistics")
|
||||
let snapshot = try await self.state.withLock { state in
|
||||
let startedState = try state.startedState("statistics")
|
||||
return (vm: startedState.vm, containerID: self.id, generation: self.generation)
|
||||
}
|
||||
|
||||
let stats = try await state.vm.withAgent { agent in
|
||||
let allStats = try await agent.containerStatistics(containerIDs: [self.id], categories: categories)
|
||||
guard let containerStats = allStats.first else {
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "statistics for container \(self.id) not found"
|
||||
)
|
||||
}
|
||||
return containerStats
|
||||
return try await snapshot.vm.withAgent { agent in
|
||||
let allStats = try await agent.containerStatistics(
|
||||
containerIDs: [snapshot.containerID],
|
||||
categories: categories
|
||||
)
|
||||
guard let containerStats = allStats.first else {
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "statistics for container \(snapshot.containerID) not found"
|
||||
)
|
||||
}
|
||||
|
||||
return stats
|
||||
return containerStats
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user