Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/APIServer/APIServer+Start.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
45 changes: 45 additions & 0 deletions Sources/ContainerResource/Container/ContainerEvent.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
23 changes: 23 additions & 0 deletions Sources/Services/ContainerAPIService/Client/ContainerClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -867,6 +873,7 @@ public actor ContainersService {
"id": "\(id)",
]
)
await self.recordEvent(id, action: .destroy)
}
case .stopping:
throw ContainerizationError(
Expand All @@ -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",
Expand Down