mirror of
https://github.com/apple/container.git
synced 2026-09-27 01:55:43 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user