Add filesystem notification (FSNotify) support (#294)

Addresses apple/container#141, where containers don't receive filesystem
events on mounted volumes, preventing incremental rebuilds and other
file-watching features. This PR implements the guest-side components for
FSNotify. Host-side implementation in the container repo will complete
the pipeline.

Summary:
- Add gRPC protocol definitions for filesystem event notifications
- Implement guest-side event handler that generates Linux inotify events
- Add CLI testing tool (`cctl fsnotify`) and integration test
infrastructure
This commit is contained in:
Raj
2025-11-05 12:20:15 -08:00
committed by GitHub
parent bec1008f4d
commit 80e84a9ce1
13 changed files with 1028 additions and 8 deletions
@@ -168,6 +168,11 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextClientProtoc
_ request: Com_Apple_Containerization_Sandbox_V3_KillRequest,
callOptions: CallOptions?
) -> UnaryCall<Com_Apple_Containerization_Sandbox_V3_KillRequest, Com_Apple_Containerization_Sandbox_V3_KillResponse>
func notifyFileSystemEvent(
callOptions: CallOptions?,
handler: @escaping (Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse) -> Void
) -> BidirectionalStreamingCall<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest, Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse>
}
extension Com_Apple_Containerization_Sandbox_V3_SandboxContextClientProtocol {
@@ -661,6 +666,27 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextClientProtocol {
interceptors: self.interceptors?.makeKillInterceptors() ?? []
)
}
/// Notify guest of filesystem events from host.
///
/// Callers should use the `send` method on the returned object to send messages
/// to the server. The caller should send an `.end` after the final message has been sent.
///
/// - Parameters:
/// - callOptions: Call options.
/// - handler: A closure called when each response is received from the server.
/// - Returns: A `ClientStreamingCall` with futures for the metadata and status.
public func notifyFileSystemEvent(
callOptions: CallOptions? = nil,
handler: @escaping (Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse) -> Void
) -> BidirectionalStreamingCall<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest, Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse> {
return self.makeBidirectionalStreamingCall(
path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.notifyFileSystemEvent.path,
callOptions: callOptions ?? self.defaultCallOptions,
interceptors: self.interceptors?.makeNotifyFileSystemEventInterceptors() ?? [],
handler: handler
)
}
}
@available(*, deprecated)
@@ -860,6 +886,10 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientP
_ request: Com_Apple_Containerization_Sandbox_V3_KillRequest,
callOptions: CallOptions?
) -> GRPCAsyncUnaryCall<Com_Apple_Containerization_Sandbox_V3_KillRequest, Com_Apple_Containerization_Sandbox_V3_KillResponse>
func makeNotifyFileSystemEventCall(
callOptions: CallOptions?
) -> GRPCAsyncBidirectionalStreamingCall<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest, Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse>
}
@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
@@ -1195,6 +1225,16 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientProtoco
interceptors: self.interceptors?.makeKillInterceptors() ?? []
)
}
public func makeNotifyFileSystemEventCall(
callOptions: CallOptions? = nil
) -> GRPCAsyncBidirectionalStreamingCall<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest, Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse> {
return self.makeAsyncBidirectionalStreamingCall(
path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.notifyFileSystemEvent.path,
callOptions: callOptions ?? self.defaultCallOptions,
interceptors: self.interceptors?.makeNotifyFileSystemEventInterceptors() ?? []
)
}
}
@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
@@ -1522,6 +1562,30 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientProtoco
interceptors: self.interceptors?.makeKillInterceptors() ?? []
)
}
public func notifyFileSystemEvent<RequestStream>(
_ requests: RequestStream,
callOptions: CallOptions? = nil
) -> GRPCAsyncResponseStream<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse> where RequestStream: Sequence, RequestStream.Element == Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest {
return self.performAsyncBidirectionalStreamingCall(
path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.notifyFileSystemEvent.path,
requests: requests,
callOptions: callOptions ?? self.defaultCallOptions,
interceptors: self.interceptors?.makeNotifyFileSystemEventInterceptors() ?? []
)
}
public func notifyFileSystemEvent<RequestStream>(
_ requests: RequestStream,
callOptions: CallOptions? = nil
) -> GRPCAsyncResponseStream<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse> where RequestStream: AsyncSequence & Sendable, RequestStream.Element == Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest {
return self.performAsyncBidirectionalStreamingCall(
path: Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.notifyFileSystemEvent.path,
requests: requests,
callOptions: callOptions ?? self.defaultCallOptions,
interceptors: self.interceptors?.makeNotifyFileSystemEventInterceptors() ?? []
)
}
}
@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
@@ -1623,6 +1687,9 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextClientInterc
/// - Returns: Interceptors to use when invoking 'kill'.
func makeKillInterceptors() -> [ClientInterceptor<Com_Apple_Containerization_Sandbox_V3_KillRequest, Com_Apple_Containerization_Sandbox_V3_KillResponse>]
/// - Returns: Interceptors to use when invoking 'notifyFileSystemEvent'.
func makeNotifyFileSystemEventInterceptors() -> [ClientInterceptor<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest, Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse>]
}
public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata {
@@ -1657,6 +1724,7 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata {
Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.configureHosts,
Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.sync,
Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.kill,
Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata.Methods.notifyFileSystemEvent,
]
)
@@ -1822,6 +1890,12 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata {
path: "/com.apple.containerization.sandbox.v3.SandboxContext/Kill",
type: GRPCCallType.unary
)
public static let notifyFileSystemEvent = GRPCMethodDescriptor(
name: "NotifyFileSystemEvent",
path: "/com.apple.containerization.sandbox.v3.SandboxContext/NotifyFileSystemEvent",
type: GRPCCallType.bidirectionalStreaming
)
}
}
@@ -1912,6 +1986,9 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextProvider: Ca
/// Send a signal to a process via the PID.
func kill(request: Com_Apple_Containerization_Sandbox_V3_KillRequest, context: StatusOnlyCallContext) -> EventLoopFuture<Com_Apple_Containerization_Sandbox_V3_KillResponse>
/// Notify guest of filesystem events from host.
func notifyFileSystemEvent(context: StreamingResponseCallContext<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse>) -> EventLoopFuture<(StreamEvent<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest>) -> Void>
}
extension Com_Apple_Containerization_Sandbox_V3_SandboxContextProvider {
@@ -2169,6 +2246,15 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextProvider {
userFunction: self.kill(request:context:)
)
case "NotifyFileSystemEvent":
return BidirectionalStreamingServerHandler(
context: context,
requestDeserializer: ProtobufDeserializer<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest>(),
responseSerializer: ProtobufSerializer<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse>(),
interceptors: self.interceptors?.makeNotifyFileSystemEventInterceptors() ?? [],
observerFactory: self.notifyFileSystemEvent(context:)
)
default:
return nil
}
@@ -2345,6 +2431,13 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvide
request: Com_Apple_Containerization_Sandbox_V3_KillRequest,
context: GRPCAsyncServerCallContext
) async throws -> Com_Apple_Containerization_Sandbox_V3_KillResponse
/// Notify guest of filesystem events from host.
func notifyFileSystemEvent(
requestStream: GRPCAsyncRequestStream<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest>,
responseStream: GRPCAsyncResponseStreamWriter<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse>,
context: GRPCAsyncServerCallContext
) async throws
}
@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
@@ -2609,6 +2702,15 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvider {
wrapping: { try await self.kill(request: $0, context: $1) }
)
case "NotifyFileSystemEvent":
return GRPCAsyncServerHandler(
context: context,
requestDeserializer: ProtobufDeserializer<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest>(),
responseSerializer: ProtobufSerializer<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse>(),
interceptors: self.interceptors?.makeNotifyFileSystemEventInterceptors() ?? [],
wrapping: { try await self.notifyFileSystemEvent(requestStream: $0, responseStream: $1, context: $2) }
)
default:
return nil
}
@@ -2724,6 +2826,10 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextServerInterc
/// - Returns: Interceptors to use when handling 'kill'.
/// Defaults to calling `self.makeInterceptors()`.
func makeKillInterceptors() -> [ServerInterceptor<Com_Apple_Containerization_Sandbox_V3_KillRequest, Com_Apple_Containerization_Sandbox_V3_KillResponse>]
/// - Returns: Interceptors to use when handling 'notifyFileSystemEvent'.
/// Defaults to calling `self.makeInterceptors()`.
func makeNotifyFileSystemEventInterceptors() -> [ServerInterceptor<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest, Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse>]
}
public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata {
@@ -2758,6 +2864,7 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata {
Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.configureHosts,
Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.sync,
Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.kill,
Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata.Methods.notifyFileSystemEvent,
]
)
@@ -2923,5 +3030,11 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata {
path: "/com.apple.containerization.sandbox.v3.SandboxContext/Kill",
type: GRPCCallType.unary
)
public static let notifyFileSystemEvent = GRPCMethodDescriptor(
name: "NotifyFileSystemEvent",
path: "/com.apple.containerization.sandbox.v3.SandboxContext/NotifyFileSystemEvent",
type: GRPCCallType.bidirectionalStreaming
)
}
}
@@ -37,6 +37,52 @@ fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAP
typealias Version = _2
}
public enum Com_Apple_Containerization_Sandbox_V3_FileSystemEventType: SwiftProtobuf.Enum, Swift.CaseIterable {
public typealias RawValue = Int
case create // = 0
case delete // = 1
case link // = 2
case unlink // = 3
case modify // = 4
case UNRECOGNIZED(Int)
public init() {
self = .create
}
public init?(rawValue: Int) {
switch rawValue {
case 0: self = .create
case 1: self = .delete
case 2: self = .link
case 3: self = .unlink
case 4: self = .modify
default: self = .UNRECOGNIZED(rawValue)
}
}
public var rawValue: Int {
switch self {
case .create: return 0
case .delete: return 1
case .link: return 2
case .unlink: return 3
case .modify: return 4
case .UNRECOGNIZED(let i): return i
}
}
// The compiler won't synthesize support with the UNRECOGNIZED case.
public static let allCases: [Com_Apple_Containerization_Sandbox_V3_FileSystemEventType] = [
.create,
.delete,
.link,
.unlink,
.modify,
]
}
public struct Com_Apple_Containerization_Sandbox_V3_Stdio: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
@@ -1206,10 +1252,59 @@ public struct Com_Apple_Containerization_Sandbox_V3_NetworkStats: Sendable {
public init() {}
}
public struct Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
public var path: String = String()
public var eventType: Com_Apple_Containerization_Sandbox_V3_FileSystemEventType = .create
public var containerID: String = String()
public var unknownFields = SwiftProtobuf.UnknownStorage()
public init() {}
}
public struct Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
public var success: Bool = false
public var error: String {
get {return _error ?? String()}
set {_error = newValue}
}
/// Returns true if `error` has been explicitly set.
public var hasError: Bool {return self._error != nil}
/// Clears the value of `error`. Subsequent reads from it will return its default value.
public mutating func clearError() {self._error = nil}
public var unknownFields = SwiftProtobuf.UnknownStorage()
public init() {}
fileprivate var _error: String? = nil
}
// MARK: - Code below here is support for the SwiftProtobuf runtime.
fileprivate let _protobuf_package = "com.apple.containerization.sandbox.v3"
extension Com_Apple_Containerization_Sandbox_V3_FileSystemEventType: SwiftProtobuf._ProtoNameProviding {
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
0: .same(proto: "CREATE"),
1: .same(proto: "DELETE"),
2: .same(proto: "LINK"),
3: .same(proto: "UNLINK"),
4: .same(proto: "MODIFY"),
]
}
extension Com_Apple_Containerization_Sandbox_V3_Stdio: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = _protobuf_package + ".Stdio"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
@@ -3612,3 +3707,89 @@ extension Com_Apple_Containerization_Sandbox_V3_NetworkStats: SwiftProtobuf.Mess
return true
}
}
extension Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = _protobuf_package + ".NotifyFileSystemEventRequest"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "path"),
2: .standard(proto: "event_type"),
3: .standard(proto: "container_id"),
]
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularStringField(value: &self.path) }()
case 2: try { try decoder.decodeSingularEnumField(value: &self.eventType) }()
case 3: try { try decoder.decodeSingularStringField(value: &self.containerID) }()
default: break
}
}
}
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if !self.path.isEmpty {
try visitor.visitSingularStringField(value: self.path, fieldNumber: 1)
}
if self.eventType != .create {
try visitor.visitSingularEnumField(value: self.eventType, fieldNumber: 2)
}
if !self.containerID.isEmpty {
try visitor.visitSingularStringField(value: self.containerID, fieldNumber: 3)
}
try unknownFields.traverse(visitor: &visitor)
}
public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest, rhs: Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest) -> Bool {
if lhs.path != rhs.path {return false}
if lhs.eventType != rhs.eventType {return false}
if lhs.containerID != rhs.containerID {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
extension Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = _protobuf_package + ".NotifyFileSystemEventResponse"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "success"),
2: .same(proto: "error"),
]
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularBoolField(value: &self.success) }()
case 2: try { try decoder.decodeSingularStringField(value: &self._error) }()
default: break
}
}
}
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every if/case branch local when no optimizations
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182
if self.success != false {
try visitor.visitSingularBoolField(value: self.success, fieldNumber: 1)
}
try { if let v = self._error {
try visitor.visitSingularStringField(value: v, fieldNumber: 2)
} }()
try unknownFields.traverse(visitor: &visitor)
}
public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse, rhs: Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse) -> Bool {
if lhs.success != rhs.success {return false}
if lhs._error != rhs._error {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
@@ -66,6 +66,8 @@ service SandboxContext {
rpc Sync(SyncRequest) returns (SyncResponse);
// Send a signal to a process via the PID.
rpc Kill(KillRequest) returns (KillResponse);
// Notify guest of filesystem events from host.
rpc NotifyFileSystemEvent(stream NotifyFileSystemEventRequest) returns (stream NotifyFileSystemEventResponse);
}
message Stdio {
@@ -352,3 +354,22 @@ message NetworkStats {
uint64 receivedErrors = 6;
uint64 transmittedErrors = 7;
}
enum FileSystemEventType {
CREATE = 0;
DELETE = 1;
LINK = 2;
UNLINK = 3;
MODIFY = 4;
}
message NotifyFileSystemEventRequest {
string path = 1;
FileSystemEventType event_type = 2;
string container_id = 3;
}
message NotifyFileSystemEventResponse {
bool success = 1;
optional string error = 2;
}
+43
View File
@@ -333,6 +333,9 @@ extension Vminitd: VirtualMachineAgent {
/// Vminitd specific rpcs.
extension Vminitd {
public typealias FileSystemEventRequest = Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest
public typealias FileSystemEventResponse = Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse
public typealias FileSystemEventType = Com_Apple_Containerization_Sandbox_V3_FileSystemEventType
/// Sets up an emulator in the guest.
public func setupEmulator(binaryPath: String, configuration: Binfmt.Entry) async throws {
let request = Com_Apple_Containerization_Sandbox_V3_SetupEmulatorRequest.with {
@@ -427,6 +430,46 @@ extension Vminitd {
try await Task.sleep(for: .milliseconds(10))
try await self.sync()
}
/// Send filesystem event notifications to the guest
public func notifyFileSystemEvents(
_ events: [FileSystemEventRequest]
) async throws -> [FileSystemEventResponse] {
let requests = AsyncStream<FileSystemEventRequest> { continuation in
for event in events {
continuation.yield(event)
}
continuation.finish()
}
let responses = client.notifyFileSystemEvent(requests)
var results: [FileSystemEventResponse] = []
for try await response in responses {
results.append(response)
}
guard results.count == events.count else {
throw ContainerizationError(.internalError, message: "Expected \(events.count) responses, got \(results.count)")
}
return results
}
public func notifyFileSystemEvent(
path: String,
eventType: FileSystemEventType,
containerID: String
) async throws -> FileSystemEventResponse {
let request = FileSystemEventRequest.with {
$0.path = path
$0.eventType = eventType
$0.containerID = containerID
}
let responses = try await notifyFileSystemEvents([request])
return responses[0]
}
}
extension Hosts {
+83
View File
@@ -22,6 +22,8 @@ import ContainerizationOS
import Crypto
import Foundation
import Logging
import NIOCore
import NIOPosix
extension IntegrationSuite {
func testProcessTrue() async throws {
@@ -1022,6 +1024,87 @@ extension IntegrationSuite {
return socketPath
}
func testFSNotifyEvents() async throws {
let id = "test-fsnotify-events"
let bs = try await bootstrap(id, reference: "docker.io/library/node:18-alpine")
let directory = try createMountDirectory()
let inotifyBuffer: IntegrationSuite.BufferWriter = BufferWriter()
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = [
"node",
"-e",
"fs=require('fs');fs.watch(process.argv[1],(t,f)=>console.log(t,f))",
"/mnt",
]
config.process.stdout = inotifyBuffer
config.process.stderr = inotifyBuffer
config.mounts.append(.share(source: directory.path, destination: "/mnt"))
config.bootlog = bs.bootlog
}
try await container.create()
try await container.start()
// Get the vminitd agent to send notifications
let connection = try await container.dialVsock(port: 1024) // Default vminitd port
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let agent = Vminitd(connection: connection, group: group)
try await Task.sleep(for: .seconds(1))
// Send CREATE event
let createResponse = try await agent.notifyFileSystemEvent(
path: "/mnt/hi.txt",
eventType: .create,
containerID: id
)
guard createResponse.success else {
throw IntegrationError.assert(msg: "CREATE event failed: \(createResponse.error)")
}
try await Task.sleep(for: .seconds(1))
let output1 = String(data: inotifyBuffer.data, encoding: .utf8) ?? ""
let lines1 = output1.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines).filter { !$0.isEmpty }
guard lines1 == ["change hi.txt"] else {
throw IntegrationError.assert(msg: "CREATE should output 'change hi.txt'. Got: \(lines1)")
}
// Send MODIFY event
let modifyResponse = try await agent.notifyFileSystemEvent(
path: "/mnt/hi.txt",
eventType: .modify,
containerID: id
)
guard modifyResponse.success else {
throw IntegrationError.assert(msg: "MODIFY event failed: \(modifyResponse.error)")
}
try await Task.sleep(for: .seconds(1))
let output2 = String(data: inotifyBuffer.data, encoding: .utf8) ?? ""
let lines2 = output2.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines).filter { !$0.isEmpty }
guard lines2 == ["change hi.txt", "change hi.txt"] else {
throw IntegrationError.assert(msg: "After MODIFY, expected exactly 2 'change hi.txt'. Got: \(lines2)")
}
// Send DELETE event on non-existent file (should succeed but not crash)
let deleteResponse = try await agent.notifyFileSystemEvent(
path: "/mnt/nonexistent.txt",
eventType: .delete,
containerID: id
)
guard deleteResponse.success else {
throw IntegrationError.assert(msg: "DELETE event failed: \(deleteResponse.error)")
}
try await agent.close()
try await group.shutdownGracefully()
try await container.stop()
}
private func createMountDirectory() throws -> URL {
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
try "hello".write(to: dir.appendingPathComponent("hi.txt"), atomically: true, encoding: .utf8)
+6 -2
View File
@@ -162,8 +162,9 @@ struct IntegrationSuite: AsyncParsableCommand {
static let eventLoop = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
func bootstrap(_ testID: String) async throws -> (rootfs: Containerization.Mount, vmm: VirtualMachineManager, image: Containerization.Image, bootlog: URL) {
let reference = "ghcr.io/linuxcontainers/alpine:3.20"
func bootstrap(_ testID: String, reference: String = "ghcr.io/linuxcontainers/alpine:3.20") async throws -> (
rootfs: Containerization.Mount, vmm: VirtualMachineManager, image: Containerization.Image, bootlog: URL
) {
let store = Self.imageStore
let initImage = try await store.getInitImage(reference: Self.initImage)
@@ -310,6 +311,9 @@ struct IntegrationSuite: AsyncParsableCommand {
Test("pod container filesystem isolation", testPodContainerFilesystemIsolation),
Test("pod container PID namespace isolation", testPodContainerPIDNamespaceIsolation),
Test("pod container independent resource limits", testPodContainerIndependentResourceLimits),
// fsnotify
Test("fsnotify events", testFSNotifyEvents),
]
let passed: Atomic<Int> = Atomic(0)
+125
View File
@@ -0,0 +1,125 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the Containerization 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 ArgumentParser
import Containerization
import ContainerizationError
import Foundation
import GRPC
import NIOCore
import NIOPosix
extension Application {
struct FSNotify: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "fsnotify",
abstract: "Send filesystem notification events to a running container"
)
@Option(name: [.customLong("container"), .customShort("c")], help: "Container ID to send notification to")
var containerID: String
@Option(name: [.customLong("path"), .customShort("p")], help: "Path in the container to notify about")
var path: String
@Option(name: [.customLong("event"), .customShort("e")], help: "Event type (create, delete, modify, link, unlink)")
var eventType: String = "modify"
@Option(name: .customLong("vsock-socket"), help: "Path to the container's VSock socket")
var vsockSocket: String?
@Option(name: .customLong("vsock-port"), help: "VSock port to connect to (default: 1024)")
var vsockPort: UInt32 = 1024
func run() async throws {
let eventType = try parseEventType(eventType)
print("Sending FSNotify event to container '\(containerID)':")
print(" Path: \(path)")
print(" Event: \(eventType)")
guard let socket = vsockSocket else {
print("Error: --vsock-socket parameter required")
print("Usage: cctl fsnotify --container <id> --path <path> --vsock-socket <socket_path>")
print("")
print("Note: For end-to-end testing with real containers, use:")
print(" cctl test --include 'fsnotify events'")
throw ExitCode.failure
}
try await sendFSNotificationViaSocket(
socket: socket,
path: path,
eventType: eventType
)
print("FSNotify event sent successfully")
}
private func parseEventType(_ eventString: String) throws -> Com_Apple_Containerization_Sandbox_V3_FileSystemEventType {
switch eventString.lowercased() {
case "create":
return .create
case "delete":
return .delete
case "modify":
return .modify
case "link":
return .link
case "unlink":
return .unlink
default:
throw "Invalid event type '\(eventString)'. Valid options: create, delete, modify, link, unlink"
}
}
private func sendFSNotificationViaSocket(
socket: String,
path: String,
eventType: Com_Apple_Containerization_Sandbox_V3_FileSystemEventType
) async throws {
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
do {
// Connect to the container's VSock socket
let client = Vminitd.Client(socket: socket, group: group)
let vminitd = Vminitd(client: client)
// Send the notification using the public API
let response = try await vminitd.notifyFileSystemEvent(
path: path,
eventType: eventType,
containerID: containerID
)
if !response.success {
let errorMsg = response.hasError ? response.error : "Unknown error"
throw "FSNotify failed: \(errorMsg)"
}
// Close the connection
try await vminitd.close()
} catch {
// Ensure group is shutdown even if there's an error
try await group.shutdownGracefully()
throw error
}
// Shutdown the event loop group
try await group.shutdownGracefully()
}
}
}
+1
View File
@@ -66,6 +66,7 @@ struct Application: AsyncParsableCommand {
Login.self,
Rootfs.self,
Run.self,
FSNotify.self,
]
)
}
@@ -0,0 +1,346 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the Containerization 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 Containerization
import ContainerizationError
import ContainerizationOS
import Foundation
import Logging
import NIOCore
import NIOPosix
import Synchronization
#if canImport(Musl)
import Musl
#elseif canImport(Glibc)
import Glibc
#endif
typealias FileSystemEventType = Com_Apple_Containerization_Sandbox_V3_FileSystemEventType
final class FilesystemEventWorker: Sendable {
private static let handshakeReady: UInt8 = 0xAA
private static let handshakeFailure: UInt8 = 0xFF
private let containerID: String
private let containerPID: Int32
private let eventLoop: EventLoop
private let log: Logger
// Cross-thread state (synchronized via Mutex)
private struct State {
var isStarted: Bool = false
var isStopped: Bool = false
var channel: Channel?
}
private let state: Mutex<State> = Mutex(State(isStarted: false, isStopped: false))
init(containerID: String, containerPID: Int32, eventLoop: EventLoop, log: Logger) {
self.containerID = containerID
self.containerPID = containerPID
self.eventLoop = eventLoop
self.log = log
}
func start() throws {
guard !state.withLock({ $0.isStarted }) else {
throw ContainerizationError(.invalidState, message: "FilesystemEventWorker already started")
}
var sockets: [Int32] = [0, 0]
guard socketpair(AF_UNIX, SOCK_STREAM, 0, &sockets) == 0 else {
throw ContainerizationError(.internalError, message: "Failed to create socketpair: errno \(errno)")
}
let parentSocket = sockets[0]
let childSocket = sockets[1]
var errorPipe: [Int32] = [0, 0]
guard pipe(&errorPipe) == 0 else {
close(parentSocket)
close(childSocket)
throw ContainerizationError(.internalError, message: "Failed to create error pipe: errno \(errno)")
}
let errorReadFD = errorPipe[0]
let errorWriteFD = errorPipe[1]
let containerID = self.containerID
let containerPID = self.containerPID
let log = self.log
let thread = Thread { [weak self] in
defer {
close(childSocket)
}
self?.runWorkerThread(
socket: childSocket,
errorPipe: errorWriteFD,
containerID: containerID,
containerPID: containerPID,
log: log
)
}
thread.name = "fsnotify-\(containerID)"
thread.start()
state.withLock { $0.isStarted = true }
var handshake: UInt8 = 0
let readResult = read(parentSocket, &handshake, 1)
if readResult != 1 {
close(parentSocket)
close(errorReadFD)
state.withLock { $0.isStarted = false }
throw ContainerizationError(.internalError, message: "Worker thread failed to send handshake")
}
if handshake == Self.handshakeFailure {
close(parentSocket)
// Read error message from thread
var errorBuffer = [UInt8](repeating: 0, count: 1024)
let bytesRead = read(errorReadFD, &errorBuffer, errorBuffer.count)
close(errorReadFD)
state.withLock { $0.isStarted = false }
let errorMsg =
bytesRead > 0
? (String(bytes: errorBuffer.prefix(bytesRead), encoding: .utf8) ?? "unknown error")
: "no error message"
throw ContainerizationError(.internalError, message: "Worker thread failed: \(errorMsg)")
}
if handshake != Self.handshakeReady {
close(parentSocket)
close(errorReadFD)
state.withLock { $0.isStarted = false }
throw ContainerizationError(.internalError, message: "Worker thread sent unexpected handshake: \(handshake)")
}
// Success - close error pipe read end
close(errorReadFD)
do {
let eventChannel = try NIOPipeBootstrap(group: eventLoop)
.takingOwnershipOfDescriptor(inputOutput: parentSocket)
.wait()
state.withLock { state in
state.channel = eventChannel
}
} catch {
close(parentSocket)
state.withLock { $0.isStarted = false }
throw ContainerizationError(.internalError, message: "Failed to setup NIO channel: \(error)")
}
}
private func runWorkerThread(
socket: Int32,
errorPipe: Int32,
containerID: String,
containerPID: Int32,
log: Logger
) {
// Helper to send error and handshake failure
func sendError(_ message: String) {
_ = message.utf8CString.withUnsafeBufferPointer { buffer in
write(errorPipe, buffer.baseAddress, buffer.count - 1)
}
close(errorPipe)
var failureHandshake = Self.handshakeFailure
_ = write(socket, &failureHandshake, 1)
}
do {
try enterContainerNamespace(containerPID: containerPID, log: log)
} catch {
sendError("Failed to enter namespace: \(error)")
return
}
close(errorPipe)
var readyHandshake = Self.handshakeReady
guard write(socket, &readyHandshake, 1) == 1 else {
return
}
while true {
do {
guard let (path, eventType) = try readEventFromParent(socket: socket) else {
break
}
do {
try generateSyntheticInotifyEvent(path: path, eventType: eventType)
} catch {
let errorMsg = "Failed to generate inotify event: path=\(path), type=\(eventType), error=\(error)"
fputs(errorMsg + "\n", stderr)
fflush(stderr)
}
} catch {
fputs("Protocol error reading from parent: \(error)\n", stderr)
fflush(stderr)
break
}
}
}
private func enterContainerNamespace(containerPID: Int32, log: Logger) throws {
let nsPath = "/proc/\(containerPID)/ns/mnt"
let vmNsPath = "/proc/self/ns/mnt"
let containerNsStatPtr = UnsafeMutablePointer<stat>.allocate(capacity: 1)
let vmNsStatPtr = UnsafeMutablePointer<stat>.allocate(capacity: 1)
defer {
containerNsStatPtr.deallocate()
vmNsStatPtr.deallocate()
}
let containerStatResult = stat(nsPath, containerNsStatPtr)
let vmStatResult = stat(vmNsPath, vmNsStatPtr)
if containerStatResult == 0 && vmStatResult == 0 {
let containerInode = containerNsStatPtr.pointee.st_ino
let vmInode = vmNsStatPtr.pointee.st_ino
if containerInode == vmInode {
return
}
}
let fd = open(nsPath, O_RDONLY)
guard fd >= 0 else {
throw ContainerizationError(.internalError, message: "Failed to open namespace file: \(nsPath), errno \(errno)")
}
defer {
close(fd)
}
#if canImport(Musl)
let unshareResult = Musl.unshare(CLONE_FS)
#elseif canImport(Glibc)
let unshareResult = Glibc.unshare(CLONE_FS)
#endif
guard unshareResult == 0 else {
throw ContainerizationError(.internalError, message: "Failed to unshare filesystem structure: errno \(errno)")
}
#if canImport(Musl)
let setnsResult = Musl.setns(fd, CLONE_NEWNS)
#elseif canImport(Glibc)
let setnsResult = Glibc.setns(fd, CLONE_NEWNS)
#endif
guard setnsResult == 0 else {
throw ContainerizationError(.internalError, message: "Failed to setns to mount namespace: errno \(errno)")
}
}
private func readEventFromParent(socket: Int32) throws -> (String, FileSystemEventType)? {
var eventTypeValue: UInt32 = 0
guard read(socket, &eventTypeValue, 4) == 4 else {
return nil
}
eventTypeValue = UInt32(bigEndian: eventTypeValue)
var pathLen: UInt32 = 0
guard read(socket, &pathLen, 4) == 4 else {
throw ContainerizationError(.internalError, message: "Failed to read path length from parent")
}
pathLen = UInt32(bigEndian: pathLen)
let pathData = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(pathLen))
defer { pathData.deallocate() }
guard read(socket, pathData, Int(pathLen)) == pathLen else {
throw ContainerizationError(.internalError, message: "Failed to read path from parent")
}
let pathBytes = Data(bytes: pathData, count: Int(pathLen))
guard let path = String(data: pathBytes, encoding: .utf8) else {
throw ContainerizationError(.internalError, message: "Failed to decode path as UTF-8")
}
guard let eventType = FileSystemEventType(rawValue: Int(eventTypeValue)) else {
throw ContainerizationError(.internalError, message: "Invalid event type: \(eventTypeValue)")
}
return (path, eventType)
}
private func generateSyntheticInotifyEvent(
path: String,
eventType: FileSystemEventType
) throws {
if eventType == .delete && !FileManager.default.fileExists(atPath: path) {
return
}
let attributes = try FileManager.default.attributesOfItem(atPath: path)
guard let permissions = attributes[.posixPermissions] as? NSNumber else {
throw ContainerizationError(.internalError, message: "Failed to get file permissions for path: \(path)")
}
try FileManager.default.setAttributes(
[.posixPermissions: permissions],
ofItemAtPath: path
)
}
func enqueueEvent(path: String, eventType: FileSystemEventType) throws {
guard !state.withLock({ $0.isStopped }) else {
throw ContainerizationError(.invalidState, message: "FilesystemEventWorker not running")
}
eventLoop.execute {
let channel = self.state.withLock { $0.channel }
guard let channel = channel else { return }
// Build ByteBuffer with binary protocol:
// [event_type:4 bytes][path_len:4 bytes][path:N bytes]
let pathUTF8Count = path.utf8.count
var buffer = channel.allocator.buffer(capacity: 8 + pathUTF8Count)
buffer.writeInteger(UInt32(eventType.rawValue), endianness: .big)
buffer.writeInteger(UInt32(pathUTF8Count), endianness: .big)
buffer.writeString(path)
channel.writeAndFlush(buffer).whenFailure { error in
self.log.warning(
"Failed to send event to fs-notify child",
metadata: [
"container": "\(self.containerID)",
"path": "\(path)",
"error": "\(error)",
])
}
}
}
func stop() {
state.withLock { state in
state.isStopped = true
state.isStarted = false
}
eventLoop.execute {
self.state.withLock { state in
state.channel?.close(promise: nil)
state.channel = nil
}
}
}
}
+51 -2
View File
@@ -15,11 +15,19 @@
//===----------------------------------------------------------------------===//
import Cgroup
import Containerization
import ContainerizationError
import ContainerizationOCI
import ContainerizationOS
import Foundation
import Logging
import NIOCore
#if canImport(Musl)
import Musl
#elseif canImport(Glibc)
import Glibc
#endif
actor ManagedContainer {
let id: String
@@ -28,7 +36,9 @@ actor ManagedContainer {
private let cgroupManager: Cgroup2Manager
private let log: Logger
private let bundle: ContainerizationOCI.Bundle
private let group: EventLoopGroup
private var execs: [String: ManagedProcess] = [:]
private var filesystemEventWorker: FilesystemEventWorker?
var pid: Int32? {
self.initProcess.pid
@@ -38,7 +48,8 @@ actor ManagedContainer {
id: String,
stdio: HostStdio,
spec: ContainerizationOCI.Spec,
log: Logger
log: Logger,
group: EventLoopGroup
) throws {
var cgroupsPath: String
if let cgPath = spec.linux?.cgroupsPath {
@@ -77,6 +88,8 @@ actor ManagedContainer {
self.id = id
self.bundle = bundle
self.log = log
self.group = group
self.filesystemEventWorker = nil
} catch {
try? cgManager.delete()
throw error
@@ -94,6 +107,17 @@ extension ManagedContainer {
}
}
private func installWorker(_ worker: FilesystemEventWorker) {
self.filesystemEventWorker = worker
}
func executeFileSystemEvent(path: String, eventType: FileSystemEventType) throws {
guard let worker = self.filesystemEventWorker else {
throw ContainerizationError(.invalidState, message: "Filesystem event worker not started for container \(self.id)")
}
try worker.enqueueEvent(path: path, eventType: eventType)
}
func createExec(
id: String,
stdio: HostStdio,
@@ -119,7 +143,29 @@ extension ManagedContainer {
func start(execID: String) async throws -> Int32 {
let proc = try self.getExecOrInit(execID: execID)
return try await ProcessSupervisor.default.start(process: proc)
let onPidReady: (@Sendable (Int32) throws -> Void)?
if execID == self.id {
// Capture needed values for callback
let containerID = self.id
let eventLoop = self.group.next()
let log = self.log
onPidReady = { [weak self] pid in
let worker = FilesystemEventWorker(containerID: containerID, containerPID: pid, eventLoop: eventLoop, log: log)
try worker.start()
// Hop back to actor to install worker
Task { [weak self] in
await self?.installWorker(worker)
}
}
} else {
onPidReady = nil
}
let pid = try await ProcessSupervisor.default.start(process: proc, onPidReady: onPidReady)
return pid
}
func wait(execID: String) async throws -> ManagedProcess.ExitStatus {
@@ -153,6 +199,9 @@ extension ManagedContainer {
}
func delete() throws {
self.filesystemEventWorker?.stop()
self.filesystemEventWorker = nil
try self.bundle.delete()
try self.cgroupManager.delete(force: true)
}
+3 -1
View File
@@ -148,7 +148,7 @@ final class ManagedProcess: Sendable {
}
extension ManagedProcess {
func start() throws -> Int32 {
func start(onPidReady: (@Sendable (Int32) throws -> Void)? = nil) throws -> Int32 {
try self.state.withLock {
log.info(
"starting managed process",
@@ -197,6 +197,8 @@ extension ManagedProcess {
try cgManager.addProcess(pid: pid)
}
try onPidReady?(pid)
log.info(
"sending pid acknowledgement",
metadata: [
@@ -98,7 +98,7 @@ actor ProcessSupervisor {
}
}
func start(process: ManagedProcess) throws -> Int32 {
func start(process: ManagedProcess, onPidReady: (@Sendable (Int32) throws -> Void)? = nil) throws -> Int32 {
self.log?.debug("in supervisor lock to start process")
defer {
self.log?.debug("out of supervisor lock to start process")
@@ -106,7 +106,7 @@ actor ProcessSupervisor {
do {
self.processes.append(process)
return try process.start()
return try process.start(onPidReady: onPidReady)
} catch {
self.log?.error("process start failed \(error)", metadata: ["process-id": "\(process.id)"])
throw error
+53 -1
View File
@@ -492,7 +492,8 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid
id: request.id,
stdio: stdioPorts,
spec: ociSpec,
log: self.log
log: self.log,
group: self.group
)
try await self.state.add(container: ctr)
}
@@ -1081,6 +1082,57 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid
$0.result = r
}
}
func notifyFileSystemEvent(
requestStream: GRPCAsyncRequestStream<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest>,
responseStream: GRPCAsyncResponseStreamWriter<Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse>,
context: GRPC.GRPCAsyncServerCallContext
) async throws {
for try await request in requestStream {
log.debug(
"notifyFileSystemEvent",
metadata: [
"containerID": "\(request.containerID)",
"path": "\(request.path)",
"eventType": "\(request.eventType)",
])
guard let container = await self.state.containers[request.containerID] else {
log.warning(
"fs event for non-existent container",
metadata: [
"containerID": "\(request.containerID)"
])
let response = Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse.with {
$0.success = false
$0.error = "fs event for non-existent container: \(request.containerID)"
}
try await responseStream.send(response)
return
}
do {
try await container.executeFileSystemEvent(path: request.path, eventType: request.eventType)
let response = Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse.with {
$0.success = true
}
try await responseStream.send(response)
} catch {
log.error(
"notifyFileSystemEvent",
metadata: [
"error": "\(error)"
])
let response = Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse.with {
$0.success = false
$0.error = error.localizedDescription
}
try await responseStream.send(response)
}
}
}
}
extension Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest {