diff --git a/Sources/Containerization/Agent/Vminitd.swift b/Sources/Containerization/Agent/Vminitd.swift index 20c2fed2..aa894a8f 100644 --- a/Sources/Containerization/Agent/Vminitd.swift +++ b/Sources/Containerization/Agent/Vminitd.swift @@ -363,6 +363,32 @@ extension Vminitd { try await Task.sleep(for: .milliseconds(10)) try await self.sync() } + + /// Send a filesystem event notification to the guest. + public func notifyFileSystemEvent( + path: String, + eventType: Com_Apple_Containerization_Sandbox_V3_FileSystemEventType, + containerID: String + ) async throws -> Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse { + let request = Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest.with { + $0.path = path + $0.eventType = eventType + $0.containerID = containerID + } + + let requests = AsyncStream { continuation in + continuation.yield(request) + continuation.finish() + } + + let responses = client.notifyFileSystemEvent(requests) + + for try await response in responses { + return response + } + + throw ContainerizationError(.internalError, message: "No response received from notifyFileSystemEvent") + } } extension Hosts { diff --git a/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift b/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift index 4896f21f..fb19b05f 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift @@ -163,6 +163,11 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextClientProtoc _ request: Com_Apple_Containerization_Sandbox_V3_KillRequest, callOptions: CallOptions? ) -> UnaryCall + + func notifyFileSystemEvent( + callOptions: CallOptions?, + handler: @escaping (Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventResponse) -> Void + ) -> BidirectionalStreamingCall } extension Com_Apple_Containerization_Sandbox_V3_SandboxContextClientProtocol { @@ -638,6 +643,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 { + 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) @@ -832,6 +858,10 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientP _ request: Com_Apple_Containerization_Sandbox_V3_KillRequest, callOptions: CallOptions? ) -> GRPCAsyncUnaryCall + + func makeNotifyFileSystemEventCall( + callOptions: CallOptions? + ) -> GRPCAsyncBidirectionalStreamingCall } @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) @@ -1155,6 +1185,16 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientProtoco interceptors: self.interceptors?.makeKillInterceptors() ?? [] ) } + + public func makeNotifyFileSystemEventCall( + callOptions: CallOptions? = nil + ) -> GRPCAsyncBidirectionalStreamingCall { + 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, *) @@ -1470,6 +1510,30 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncClientProtoco interceptors: self.interceptors?.makeKillInterceptors() ?? [] ) } + + public func notifyFileSystemEvent( + _ requests: RequestStream, + callOptions: CallOptions? = nil + ) -> GRPCAsyncResponseStream 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( + _ requests: RequestStream, + callOptions: CallOptions? = nil + ) -> GRPCAsyncResponseStream 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, *) @@ -1568,6 +1632,9 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextClientInterc /// - Returns: Interceptors to use when invoking 'kill'. func makeKillInterceptors() -> [ClientInterceptor] + + /// - Returns: Interceptors to use when invoking 'notifyFileSystemEvent'. + func makeNotifyFileSystemEventInterceptors() -> [ClientInterceptor] } public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextClientMetadata { @@ -1601,6 +1668,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, ] ) @@ -1760,6 +1828,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 + ) } } @@ -1847,6 +1921,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 + + /// Notify guest of filesystem events from host. + func notifyFileSystemEvent(context: StreamingResponseCallContext) -> EventLoopFuture<(StreamEvent) -> Void> } extension Com_Apple_Containerization_Sandbox_V3_SandboxContextProvider { @@ -2095,6 +2172,15 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContextProvider { userFunction: self.kill(request:context:) ) + case "NotifyFileSystemEvent": + return BidirectionalStreamingServerHandler( + context: context, + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeNotifyFileSystemEventInterceptors() ?? [], + observerFactory: self.notifyFileSystemEvent(context:) + ) + default: return nil } @@ -2265,6 +2351,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, + responseStream: GRPCAsyncResponseStreamWriter, + context: GRPCAsyncServerCallContext + ) async throws } @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) @@ -2520,6 +2613,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(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeNotifyFileSystemEventInterceptors() ?? [], + wrapping: { try await self.notifyFileSystemEvent(requestStream: $0, responseStream: $1, context: $2) } + ) + default: return nil } @@ -2631,6 +2733,10 @@ public protocol Com_Apple_Containerization_Sandbox_V3_SandboxContextServerInterc /// - Returns: Interceptors to use when handling 'kill'. /// Defaults to calling `self.makeInterceptors()`. func makeKillInterceptors() -> [ServerInterceptor] + + /// - Returns: Interceptors to use when handling 'notifyFileSystemEvent'. + /// Defaults to calling `self.makeInterceptors()`. + func makeNotifyFileSystemEventInterceptors() -> [ServerInterceptor] } public enum Com_Apple_Containerization_Sandbox_V3_SandboxContextServerMetadata { @@ -2664,6 +2770,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, ] ) @@ -2823,5 +2930,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 + ) } } diff --git a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift index 90ea72f4..6be5d844 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift @@ -37,6 +37,56 @@ 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 undefined // = 99 + 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 + case 99: self = .undefined + 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 .undefined: return 99 + 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, + .undefined, + ] + +} + 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 @@ -989,10 +1039,60 @@ public struct Com_Apple_Containerization_Sandbox_V3_KillResponse: 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"), + 99: .same(proto: "UNDEFINED"), + ] +} + 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 = [ @@ -2867,3 +2967,89 @@ extension Com_Apple_Containerization_Sandbox_V3_KillResponse: 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(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(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(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(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 + } +} diff --git a/Sources/Containerization/SandboxContext/SandboxContext.proto b/Sources/Containerization/SandboxContext/SandboxContext.proto index 4cdc76b4..ea9e3b3b 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.proto +++ b/Sources/Containerization/SandboxContext/SandboxContext.proto @@ -60,6 +60,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 { @@ -279,3 +281,23 @@ message KillRequest { } message KillResponse { int32 result = 1; } + +enum FileSystemEventType { + CREATE = 0; + DELETE = 1; + LINK = 2; + UNLINK = 3; + MODIFY = 4; + UNDEFINED = 99; +} + +message NotifyFileSystemEventRequest { + string path = 1; + FileSystemEventType event_type = 2; + string container_id = 3; +} + +message NotifyFileSystemEventResponse { + bool success = 1; + optional string error = 2; +} diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 1ca64634..ff73d41e 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -95,8 +95,9 @@ struct IntegrationSuite: AsyncParsableCommand { .appendingPathComponent(name) } - func bootstrap() async throws -> (rootfs: Containerization.Mount, vmm: VirtualMachineManager, image: Containerization.Image) { - let reference = "ghcr.io/linuxcontainers/alpine:3.20" + func bootstrap(reference: String = "ghcr.io/linuxcontainers/alpine:3.20") async throws -> ( + rootfs: Containerization.Mount, vmm: VirtualMachineManager, image: Containerization.Image + ) { let store = Self.imageStore let initImage = try await store.getInitImage(reference: Self.initImage) @@ -216,6 +217,7 @@ struct IntegrationSuite: AsyncParsableCommand { "container manager": testContainerManagerCreate, "container reuse": testContainerReuse, "container /dev/console": testContainerDevConsole, + "fsnotify events": testFSNotifyEvents, ] var passed = 0 diff --git a/Sources/Integration/VMTests.swift b/Sources/Integration/VMTests.swift index f368c23f..c90a0ef1 100644 --- a/Sources/Integration/VMTests.swift +++ b/Sources/Integration/VMTests.swift @@ -15,11 +15,14 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import Containerization import ContainerizationError import ContainerizationOCI import Foundation import Logging +import NIOCore +import NIOPosix + +@testable import Containerization extension IntegrationSuite { func testMounts() async throws { @@ -305,6 +308,133 @@ extension IntegrationSuite { } } + func testFSNotifyEvents() async throws { + let id = "test-fsnotify-events" + + let bs = try await bootstrap(reference: "docker.io/library/node:18-alpine") + let directory = try createFSNotifyTestDirectory() + let inotifyBuffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = [ + "sh", + "-c", + """ + echo "=== Container Starting ===" + echo "PWD: $(pwd)" + echo "Files in /mnt:" + ls -la /mnt || echo "No /mnt directory" + echo "Node.js version:" + node --version || echo "Node.js not found" + echo "=== Starting Node.js fs.watch ===" + node -e " + console.log('Node.js script starting...'); + const fs = require('fs'); + console.log('fs module loaded'); + console.log('Setting up fs.watch on /mnt...'); + fs.watch('/mnt', (eventType, filename) => { + console.log('DETECTED:', eventType, filename); + }); + console.log('fs.watch setup complete'); + let counter = 0; + setInterval(() => { + counter++; + console.log('alive check', counter); + }, 3000); + " + """ + ] + config.process.stdout = inotifyBuffer + config.mounts.append(.share(source: directory.path, destination: "/mnt")) + } + + try await container.create() + try await container.start() + + // Wait for Node.js fs.watch to be set up + print("Waiting for Node.js fs.watch setup...") + try await Task.sleep(for: .seconds(5)) + + let currentOutput = String(data: inotifyBuffer.data, encoding: .utf8) ?? "" + print("Container output before FSNotify events:") + print(currentOutput) + + // 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) + + // Test 1: CREATE event on existing file + print("Sending CREATE event...") + let createResponse = try await agent.notifyFileSystemEvent( + path: "/mnt/existing.txt", + eventType: .create, + containerID: id + ) + + guard createResponse.success else { + throw IntegrationError.assert(msg: "CREATE event failed: \(createResponse.error)") + } + print("CREATE event succeeded") + + // Test 2: MODIFY event on existing file + print("Sending MODIFY event...") + let modifyResponse = try await agent.notifyFileSystemEvent( + path: "/mnt/existing.txt", + eventType: .modify, + containerID: id + ) + guard modifyResponse.success else { + throw IntegrationError.assert(msg: "MODIFY event failed: \(modifyResponse.error)") + } + print("MODIFY event succeeded") + + // Wait for events to be processed + print("Waiting for inotify events to be detected...") + try await Task.sleep(for: .seconds(3)) + + let inotifyOutput = String(data: inotifyBuffer.data, encoding: .utf8) ?? "" + print("=== Final Container Output ===") + print(inotifyOutput) + print("=== End Container Output ===") + + // Verify that inotify detected the modify event + guard inotifyOutput.contains("DETECTED:") && inotifyOutput.contains("existing.txt") else { + throw IntegrationError.assert(msg: "inotify did not detect FSNotify agent events. Output: '\(inotifyOutput)'") + } + + // Test 4: DELETE event on non-existent file + 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)") + } + + // Clean up + try await agent.close() + try await group.shutdownGracefully() + try await container.stop() + + print("All FSNotify events tested successfully") + } + + private func createFSNotifyTestDirectory() throws -> URL { + let dir = FileManager.default.uniqueTemporaryDirectory(create: true) + + // Create some test files and directories + try "initial content".write(to: dir.appendingPathComponent("existing.txt"), atomically: true, encoding: .utf8) + try "hello world".write(to: dir.appendingPathComponent("hello.txt"), atomically: true, encoding: .utf8) + + // Create a subdirectory + let subdir = dir.appendingPathComponent("subdir") + try FileManager.default.createDirectory(at: subdir, withIntermediateDirectories: true) + try "nested file".write(to: subdir.appendingPathComponent("nested.txt"), atomically: true, encoding: .utf8) + + return dir + } + private func createMountDirectory() throws -> URL { let dir = FileManager.default.uniqueTemporaryDirectory(create: true) try "hello".write(to: dir.appendingPathComponent("hi.txt"), atomically: true, encoding: .utf8) diff --git a/Sources/cctl/FSNotifyCommand.swift b/Sources/cctl/FSNotifyCommand.swift new file mode 100644 index 00000000..ea232378 --- /dev/null +++ b/Sources/cctl/FSNotifyCommand.swift @@ -0,0 +1,125 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization 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 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 --path --vsock-socket ") + 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() + } + } +} diff --git a/Sources/cctl/cctl.swift b/Sources/cctl/cctl.swift index 694b917f..17759cc1 100644 --- a/Sources/cctl/cctl.swift +++ b/Sources/cctl/cctl.swift @@ -66,6 +66,7 @@ struct Application: AsyncParsableCommand { Login.self, Rootfs.self, Run.self, + FSNotify.self, ] ) } diff --git a/vminitd/Sources/vminitd/ManagedContainer.swift b/vminitd/Sources/vminitd/ManagedContainer.swift index 548b30b1..9e8506a6 100644 --- a/vminitd/Sources/vminitd/ManagedContainer.swift +++ b/vminitd/Sources/vminitd/ManagedContainer.swift @@ -14,11 +14,19 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import Containerization import ContainerizationError import ContainerizationOCI import ContainerizationOS import Foundation import Logging +import Synchronization + +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#endif actor ManagedContainer { let id: String @@ -28,6 +36,370 @@ actor ManagedContainer { private let log: Logger private let bundle: ContainerizationOCI.Bundle private var execs: [String: ManagedProcess] = [:] + private var namespaceWorker: NamespaceWorker? + + /// Worker child process that runs in container's namespace for filesystem operations + private final class NamespaceWorker: @unchecked Sendable { + private let containerID: String + private let containerPID: Int32 + private var childPID: Int32? + private var parentSocket: Int32? + private var eventIDCounter: UInt32 = 0 + private let pendingEvents: Mutex<[UInt32: CheckedContinuation]> = Mutex([:]) + private var responseReaderTask: Task? + private let shouldStop: Atomic = Atomic(false) + + init(containerID: String, containerPID: Int32) { + self.containerID = containerID + self.containerPID = containerPID + } + + func start() throws { + guard childPID == nil else { + throw ContainerizationError(.invalidState, message: "NamespaceWorker already started") + } + + // Create socketpair for parent-child communication + 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] + + // Fork child process + let pid = fork() + guard pid >= 0 else { + close(parentSocket) + close(childSocket) + throw ContainerizationError(.internalError, message: "Failed to fork: errno \(errno)") + } + + if pid == 0 { + // Child process + close(parentSocket) + runChildProcess(socket: childSocket) + exit(0) + } else { + // Parent process + close(childSocket) + self.childPID = pid + self.parentSocket = parentSocket + + // Wait for child to signal ready or failure + var signal: UInt8 = 0 + let readResult = read(parentSocket, &signal, 1) + + if readResult != 1 { + // Child failed to send signal + close(parentSocket) + self.parentSocket = nil + var status: Int32 = 0 + waitpid(pid, &status, 0) + self.childPID = nil + throw ContainerizationError(.internalError, message: "Child process failed to start") + } + + if signal == 0xFF { + // Child failed to enter namespace + close(parentSocket) + self.parentSocket = nil + var status: Int32 = 0 + waitpid(pid, &status, 0) + self.childPID = nil + throw ContainerizationError(.internalError, message: "Child process failed to enter container namespace") + } + + if signal != 0xAA { + // Unexpected signal + close(parentSocket) + self.parentSocket = nil + var status: Int32 = 0 + waitpid(pid, &status, 0) + self.childPID = nil + throw ContainerizationError(.internalError, message: "Child process sent unexpected signal: \(signal)") + } + + // Start response reader task + self.responseReaderTask = Task { [weak self] in + await self?.readChildResponses() + } + } + } + + func enqueueEvent(path: String, eventType: Com_Apple_Containerization_Sandbox_V3_FileSystemEventType) async throws { + guard let socket = parentSocket, !shouldStop.load(ordering: .relaxed) else { + throw ContainerizationError(.invalidState, message: "NamespaceWorker not running") + } + + let eventID = eventIDCounter + eventIDCounter += 1 + + // Store continuation for this event + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + pendingEvents.withLock { events in + events[eventID] = continuation + } + + // Send event to child process + do { + try sendEventToChild(socket: socket, eventID: eventID, path: path, eventType: eventType) + } catch { + // Remove from pending events if send failed + _ = pendingEvents.withLock { events in + events.removeValue(forKey: eventID) + } + continuation.resume(throwing: error) + } + } + } + + func stop() { + shouldStop.store(true, ordering: .relaxed) + + // Cancel response reader task + responseReaderTask?.cancel() + responseReaderTask = nil + + // Close parent socket + if let socket = parentSocket { + close(socket) + parentSocket = nil + } + + // Terminate child process + if let pid = childPID { + #if canImport(Musl) + Musl.kill(pid, SIGTERM) + #elseif canImport(Glibc) + Glibc.kill(pid, SIGTERM) + #endif + + // Wait for child to exit + var status: Int32 = 0 + waitpid(pid, &status, 0) + childPID = nil + } + + // Cancel all pending events + pendingEvents.withLock { events in + for (_, continuation) in events { + continuation.resume(throwing: ContainerizationError(.cancelled, message: "NamespaceWorker stopped")) + } + events.removeAll() + } + } + + private func runChildProcess(socket: Int32) { + // Enter container namespace + do { + try enterContainerNamespace() + } catch { + // Signal parent that namespace entry failed, then exit + var failureResponse: UInt8 = 0xFF // Special failure signal + _ = write(socket, &failureResponse, 1) + close(socket) + exit(1) + } + + // Signal parent that we're ready + var readySignal: UInt8 = 0xAA // Ready signal + guard write(socket, &readySignal, 1) == 1 else { + close(socket) + exit(1) + } + + // Child event loop + while true { + do { + // Read event from parent + guard let (eventID, path, eventType) = try readEventFromParent(socket: socket) else { + break // Parent closed socket + } + + // Process filesystem event + var success: UInt8 = 1 + do { + try generateSyntheticInotifyEvent(path: path, eventType: eventType) + } catch { + success = 0 + } + + // Send response to parent + try sendResponseToParent(socket: socket, eventID: eventID, success: success) + } catch { + break + } + } + + close(socket) + } + + private func readChildResponses() async { + guard let socket = parentSocket else { return } + + while !shouldStop.load(ordering: .relaxed) { + do { + // Read response from child + guard let (eventID, success) = try readResponseFromChild(socket: socket) else { + break // Socket closed + } + + // Resume the corresponding continuation + pendingEvents.withLock { events in + if let continuation = events.removeValue(forKey: eventID) { + if success == 1 { + continuation.resume() + } else { + continuation.resume(throwing: ContainerizationError(.internalError, message: "Child process failed to process filesystem event")) + } + } + } + } catch { + break + } + } + } + + private func sendEventToChild(socket: Int32, eventID: UInt32, path: String, eventType: Com_Apple_Containerization_Sandbox_V3_FileSystemEventType) throws { + let pathData = path.data(using: .utf8) ?? Data() + let pathLen = UInt32(pathData.count) + let eventTypeValue = UInt32(eventType.rawValue) + + // Binary protocol: [event_type:4][path_len:4][path:N][event_id:4] + var buffer = Data() + buffer.append(contentsOf: withUnsafeBytes(of: eventTypeValue.bigEndian) { Data($0) }) + buffer.append(contentsOf: withUnsafeBytes(of: pathLen.bigEndian) { Data($0) }) + buffer.append(pathData) + buffer.append(contentsOf: withUnsafeBytes(of: eventID.bigEndian) { Data($0) }) + + try buffer.withUnsafeBytes { bytes in + let written = write(socket, bytes.bindMemory(to: UInt8.self).baseAddress, buffer.count) + guard written == buffer.count else { + throw ContainerizationError(.internalError, message: "Failed to write event to child: written \(written), expected \(buffer.count)") + } + } + } + + private func readEventFromParent(socket: Int32) throws -> (UInt32, String, Com_Apple_Containerization_Sandbox_V3_FileSystemEventType)? { + // Read event_type:4 + var eventTypeValue: UInt32 = 0 + guard read(socket, &eventTypeValue, 4) == 4 else { return nil } + eventTypeValue = UInt32(bigEndian: eventTypeValue) + + // Read path_len:4 + var pathLen: UInt32 = 0 + guard read(socket, &pathLen, 4) == 4 else { return nil } + pathLen = UInt32(bigEndian: pathLen) + + // Read path:N + let pathData = UnsafeMutablePointer.allocate(capacity: Int(pathLen)) + defer { pathData.deallocate() } + guard read(socket, pathData, Int(pathLen)) == pathLen else { return nil } + let pathBytes = Data(bytes: pathData, count: Int(pathLen)) + guard let path = String(data: pathBytes, encoding: .utf8) else { return nil } + + // Read event_id:4 + var eventID: UInt32 = 0 + guard read(socket, &eventID, 4) == 4 else { return nil } + eventID = UInt32(bigEndian: eventID) + + guard let eventType = Com_Apple_Containerization_Sandbox_V3_FileSystemEventType(rawValue: Int(eventTypeValue)) else { + return nil + } + + return (eventID, path, eventType) + } + + private func sendResponseToParent(socket: Int32, eventID: UInt32, success: UInt8) throws { + // Binary protocol: [event_id:4][success:1] + var buffer = Data() + buffer.append(contentsOf: withUnsafeBytes(of: eventID.bigEndian) { Data($0) }) + buffer.append(success) + + try buffer.withUnsafeBytes { bytes in + let written = write(socket, bytes.bindMemory(to: UInt8.self).baseAddress, buffer.count) + guard written == buffer.count else { + throw ContainerizationError(.internalError, message: "Failed to write response to parent") + } + } + } + + private func readResponseFromChild(socket: Int32) throws -> (UInt32, UInt8)? { + // Read event_id:4 + var eventID: UInt32 = 0 + guard read(socket, &eventID, 4) == 4 else { return nil } + eventID = UInt32(bigEndian: eventID) + + // Read success:1 + var success: UInt8 = 0 + guard read(socket, &success, 1) == 1 else { return nil } + + return (eventID, success) + } + + private func enterContainerNamespace() throws { + let nsPath = "/proc/\(containerPID)/ns/mnt" + let vmNsPath = "/proc/self/ns/mnt" + + guard FileManager.default.fileExists(atPath: nsPath) else { + throw ContainerizationError(.internalError, message: "Namespace file does not exist: \(nsPath)") + } + + // Compare namespace inodes to see if they're the same + let containerNsStatPtr = UnsafeMutablePointer.allocate(capacity: 1) + let vmNsStatPtr = UnsafeMutablePointer.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 { + // Skip setns() since we're already in the right namespace + 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) + } + + let setnsResult = setns(fd, CLONE_NEWNS) + guard setnsResult == 0 else { + throw ContainerizationError(.internalError, message: "Failed to setns to mount namespace: errno \(errno)") + } + } + + private func generateSyntheticInotifyEvent( + path: String, + eventType: Com_Apple_Containerization_Sandbox_V3_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 + ) + } + } var pid: Int32 { self.initProcess.pid @@ -79,6 +451,9 @@ actor ManagedContainer { self.id = id self.bundle = bundle self.log = log + + // Initialize namespace worker - will be started after process starts + self.namespaceWorker = nil } catch { try? cgManager.delete() throw error @@ -96,6 +471,26 @@ extension ManagedContainer { } } + /// Start namespace worker child process after container process starts + private func startNamespaceWorker() throws { + let pid = self.initProcess.pid + guard pid > 0 else { + throw ContainerizationError(.invalidState, message: "Container process not started") + } + + let worker = NamespaceWorker(containerID: self.id, containerPID: pid) + try worker.start() + self.namespaceWorker = worker + } + + /// Execute filesystem event using dedicated namespace child process + func executeFileSystemEvent(path: String, eventType: Com_Apple_Containerization_Sandbox_V3_FileSystemEventType) async throws { + guard let worker = self.namespaceWorker else { + throw ContainerizationError(.invalidState, message: "Namespace worker not started for container \(self.id)") + } + try await worker.enqueueEvent(path: path, eventType: eventType) + } + func createExec( id: String, stdio: HostStdio, @@ -121,7 +516,14 @@ 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 pid = try await ProcessSupervisor.default.start(process: proc) + + // Start namespace worker child process if this is the init process + if execID == self.id { + try self.startNamespaceWorker() + } + + return pid } func wait(execID: String) async throws -> Int32 { @@ -155,6 +557,10 @@ extension ManagedContainer { } func delete() throws { + // Stop namespace worker child process + self.namespaceWorker?.stop() + self.namespaceWorker = nil + try self.bundle.delete() try self.cgroupManager.delete(force: true) } diff --git a/vminitd/Sources/vminitd/Server+GRPC.swift b/vminitd/Sources/vminitd/Server+GRPC.swift index d052a695..5c65ba34 100644 --- a/vminitd/Sources/vminitd/Server+GRPC.swift +++ b/vminitd/Sources/vminitd/Server+GRPC.swift @@ -921,6 +921,57 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid $0.result = r } } + + func notifyFileSystemEvent( + requestStream: GRPCAsyncRequestStream, + responseStream: GRPCAsyncResponseStreamWriter, + 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 {