diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift index 527839153..ad605768c 100644 --- a/Sources/APIServer/APIServer+Start.swift +++ b/Sources/APIServer/APIServer+Start.swift @@ -297,6 +297,7 @@ extension APIServer { routes[XPCRoute.containerCopyIn] = XPCServer.route(harness.copyIn) routes[XPCRoute.containerCopyOut] = XPCServer.route(harness.copyOut) routes[XPCRoute.containerExport] = XPCServer.route(harness.export) + routes[XPCRoute.containerEvent] = XPCServer.route(harness.events) return service } diff --git a/Sources/ContainerResource/Container/ContainerEvent.swift b/Sources/ContainerResource/Container/ContainerEvent.swift new file mode 100644 index 000000000..57ab7d21a --- /dev/null +++ b/Sources/ContainerResource/Container/ContainerEvent.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// 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 + +/// A discrete container lifecycle event recorded by the daemon and returned +/// to clients via ``ContainerClient/events()``. +/// +/// Events are recorded in-process by `ContainersService` at the moment a +/// container transitions through `create` / `start` / `stop` / `die` / +/// `destroy`. The daemon retains a bounded ring buffer of the most recent +/// events; callers requesting events after the buffer rolls over will miss +/// the dropped frames. There is no persistence across daemon restarts. +public struct ContainerEvent: Codable, Sendable, Equatable { + public enum Action: String, Codable, Sendable, Equatable { + case create + case start + case stop + case die + case destroy + } + + public let containerId: String + public let action: Action + public let timestamp: Date + + public init(containerId: String, action: Action, timestamp: Date = Date()) { + self.containerId = containerId + self.action = action + self.timestamp = timestamp + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index 291e8cf2f..52734d82d 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -388,4 +388,27 @@ public struct ContainerClient: Sendable { ) } } + + /// Fetch the daemon's recent container lifecycle events. + /// + /// Returns the events currently held in the daemon's bounded ring + /// buffer (`create` / `start` / `stop` / `die` / `destroy`). Buffer + /// rollover drops the oldest entries; events are not persisted across + /// daemon restarts. + public func events() async throws -> [ContainerEvent] { + do { + let request = XPCMessage(route: .containerEvent) + let response = try await xpcClient.send(request) + guard let data = response.dataNoCopy(key: .containerEvent) else { + return [] + } + return try JSONDecoder().decode([ContainerEvent].self, from: data) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get container events", + cause: error + ) + } + } } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index d7da46e3d..8f1a3705c 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -386,4 +386,13 @@ public struct ContainersHarness: Sendable { try await service.exportRootfs(id: id, archive: archiveUrl) return message.reply() } + + @Sendable + public func events(_ message: XPCMessage) async throws -> XPCMessage { + let events = await service.recentEvents() + let data = try JSONEncoder().encode(events) + let reply = message.reply() + reply.set(key: .containerEvent, value: data) + return reply + } } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index b18bf55d5..f58f92256 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -61,6 +61,8 @@ public actor ContainersService { private let lock: AsyncLock private var containers: [String: ContainerState] + private var eventBuffer: [ContainerEvent] = [] + private static let maxEventBufferSize = 1000 // FIXME: Find a better mechanism for services running on the APIServer to work with each other private weak var networksService: NetworksService? @@ -394,6 +396,7 @@ public actor ContainersService { startedDate: nil ) await self.setContainerState(configuration.id, ContainerState(snapshot: snapshot), context: context) + await self.recordEvent(configuration.id, action: .create) } catch { throw error } @@ -464,6 +467,7 @@ public actor ContainersService { state.client = runtimeClient await self.setContainerState(id, state, context: context) + await self.recordEvent(id, action: .start) } catch { let label = Self.fullLaunchdServiceLabel( runtimeName: config.runtimeHandler, @@ -649,6 +653,8 @@ public actor ContainersService { } } try await handleContainerExit(id: id) + recordEvent(id, action: .stop) + recordEvent(id, action: .die) } public func dial(id: String, port: UInt32) async throws -> FileHandle { @@ -867,6 +873,7 @@ public actor ContainersService { "id": "\(id)", ] ) + await self.recordEvent(id, action: .destroy) } case .stopping: throw ContainerizationError( @@ -876,10 +883,24 @@ public actor ContainersService { default: try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { context in try await self.cleanUp(id: id, context: context) + await self.recordEvent(id, action: .destroy) } } } + private func recordEvent(_ containerId: String, action: ContainerEvent.Action) { + let event = ContainerEvent(containerId: containerId, action: action) + eventBuffer.append(event) + if eventBuffer.count > Self.maxEventBufferSize { + eventBuffer.removeFirst(eventBuffer.count - Self.maxEventBufferSize) + } + } + + public func recentEvents(since: Date? = nil) -> [ContainerEvent] { + guard let since else { return eventBuffer } + return eventBuffer.filter { $0.timestamp >= since } + } + public func containerDiskUsage(id: String) async throws -> UInt64 { log.debug( "ContainersService: enter",