diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index 58ffa4a3..cfdc5f64 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -1033,6 +1033,20 @@ extension LinuxContainer { } } + // Perform filesystem operations in the container. + public func filesystemOperation(operation: FilesystemOperation, path: String) async throws { + try await self.state.withLock { + let state = try $0.startedState("filesystemOperation") + try await state.vm.withAgent { agent in + guard let vminitd = agent as? Vminitd else { + throw ContainerizationError(.unsupported, message: "filesystemOperation requires Vminitd agent") + } + let guestPath = URL(filePath: Self.guestRootfsPath(self.id)).appending(path: path).path + try await vminitd.filesystemOperation(operation: operation, path: guestPath) + } + } + } + private func relayUnixSocket( socket: UnixSocketConfiguration, relayManager: UnixSocketRelayManager, diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 3e5d58f0..92c8b181 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -1132,6 +1132,35 @@ extension LinuxPod { return try await fn(vm) } + // Perform filesystem operations in a container. + public func filesystemOperation(_ containerID: String, operation: FilesystemOperation, path: String) async throws { + try await self.state.withLock { state in + let createdState = try state.phase.createdState("filesystemOperation") + + guard let container = state.containers[containerID] else { + throw ContainerizationError( + .notFound, + message: "container \(containerID) not found in pod" + ) + } + + guard container.state == .started else { + throw ContainerizationError( + .invalidState, + message: "container \(containerID) must be started to perform filesystem operations" + ) + } + + try await createdState.vm.withAgent { agent in + guard let vminitd = agent as? Vminitd else { + throw ContainerizationError(.unsupported, message: "filesystemOperation requires Vminitd agent") + } + let guestPath = URL(filePath: Self.guestRootfsPath(containerID)).appending(path: path).path + try await vminitd.filesystemOperation(operation: operation, path: guestPath) + } + } + } + /// Close a container's standard input to signal no more input is arriving. public func closeContainerStdin(_ containerID: String) async throws { try await self.state.withLock { state in diff --git a/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift b/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift index 7018897b..6776ed1b 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift @@ -179,6 +179,19 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContext: Sendable { type: .unary ) } + /// Namespace for "FilesystemOperation" metadata. + public enum FilesystemOperation: Sendable { + /// Request type for "FilesystemOperation". + public typealias Input = Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest + /// Response type for "FilesystemOperation". + public typealias Output = Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse + /// Descriptor for "FilesystemOperation". + public static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "com.apple.containerization.sandbox.v3.SandboxContext"), + method: "FilesystemOperation", + type: .unary + ) + } /// Namespace for "CreateProcess" metadata. public enum CreateProcess: Sendable { /// Request type for "CreateProcess". @@ -426,6 +439,7 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContext: Sendable { WriteFile.descriptor, Copy.descriptor, Stat.descriptor, + FilesystemOperation.descriptor, CreateProcess.descriptor, DeleteProcess.descriptor, StartProcess.descriptor, @@ -673,6 +687,24 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext { context: GRPCCore.ServerContext ) async throws -> GRPCCore.StreamingServerResponse + /// Handle the "FilesystemOperation" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform a filesystem operation on a mounted filesystem. + /// + /// - Parameters: + /// - request: A streaming request of `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse` messages. + func filesystemOperation( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + /// Handle the "CreateProcess" method. /// /// > Source IDL Documentation: @@ -1211,6 +1243,24 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext { context: GRPCCore.ServerContext ) async throws -> GRPCCore.ServerResponse + /// Handle the "FilesystemOperation" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform a filesystem operation on a mounted filesystem. + /// + /// - Parameters: + /// - request: A request containing a single `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A response containing a single `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse` message. + func filesystemOperation( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse + /// Handle the "CreateProcess" method. /// /// > Source IDL Documentation: @@ -1748,6 +1798,24 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext { context: GRPCCore.ServerContext ) async throws -> Com_Apple_Containerization_Sandbox_V3_StatResponse + /// Handle the "FilesystemOperation" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform a filesystem operation on a mounted filesystem. + /// + /// - Parameters: + /// - request: A `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse` to respond with. + func filesystemOperation( + request: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest, + context: GRPCCore.ServerContext + ) async throws -> Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse + /// Handle the "CreateProcess" method. /// /// > Source IDL Documentation: @@ -2200,6 +2268,17 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext.StreamingServiceP ) } ) + router.registerHandler( + forMethod: Com_Apple_Containerization_Sandbox_V3_SandboxContext.Method.FilesystemOperation.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.filesystemOperation( + request: request, + context: context + ) + } + ) router.registerHandler( forMethod: Com_Apple_Containerization_Sandbox_V3_SandboxContext.Method.CreateProcess.descriptor, deserializer: GRPCProtobuf.ProtobufDeserializer(), @@ -2525,6 +2604,17 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext.ServiceProtocol { return GRPCCore.StreamingServerResponse(single: response) } + public func filesystemOperation( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + let response = try await self.filesystemOperation( + request: GRPCCore.ServerRequest(stream: request), + context: context + ) + return GRPCCore.StreamingServerResponse(single: response) + } + public func createProcess( request: GRPCCore.StreamingServerRequest, context: GRPCCore.ServerContext @@ -2874,6 +2964,19 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServiceProt ) } + public func filesystemOperation( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse { + return GRPCCore.ServerResponse( + message: try await self.filesystemOperation( + request: request.message, + context: context + ), + metadata: [:] + ) + } + public func createProcess( request: GRPCCore.ServerRequest, context: GRPCCore.ServerContext @@ -3377,6 +3480,29 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext { onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result ) async throws -> Result where Result: Sendable + /// Call the "FilesystemOperation" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform a filesystem operation on a mounted filesystem. + /// + /// - Parameters: + /// - request: A request containing a single `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest` message. + /// - serializer: A serializer for `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest` messages. + /// - deserializer: A deserializer for `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func filesystemOperation( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + /// Call the "CreateProcess" method. /// /// > Source IDL Documentation: @@ -4187,6 +4313,40 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext { ) } + /// Call the "FilesystemOperation" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform a filesystem operation on a mounted filesystem. + /// + /// - Parameters: + /// - request: A request containing a single `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest` message. + /// - serializer: A serializer for `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest` messages. + /// - deserializer: A deserializer for `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func filesystemOperation( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.client.unary( + request: request, + descriptor: Com_Apple_Containerization_Sandbox_V3_SandboxContext.Method.FilesystemOperation.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + /// Call the "CreateProcess" method. /// /// > Source IDL Documentation: @@ -5124,6 +5284,35 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext.ClientProtocol { ) } + /// Call the "FilesystemOperation" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform a filesystem operation on a mounted filesystem. + /// + /// - Parameters: + /// - request: A request containing a single `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func filesystemOperation( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.filesystemOperation( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } + /// Call the "CreateProcess" method. /// /// > Source IDL Documentation: @@ -6014,6 +6203,39 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext.ClientProtocol { ) } + /// Call the "FilesystemOperation" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform a filesystem operation on a mounted filesystem. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func filesystemOperation( + _ message: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.filesystemOperation( + request: request, + options: options, + onResponse: handleResponse + ) + } + /// Call the "CreateProcess" method. /// /// > Source IDL Documentation: diff --git a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift index e15abcbb..320d3bd9 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift @@ -1054,6 +1054,72 @@ public struct Com_Apple_Containerization_Sandbox_V3_StatResponse: @unchecked Sen fileprivate var _storage = _StorageClass.defaultInstance } +public struct Com_Apple_Containerization_Sandbox_V3_FiFreezeParams: 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 unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Com_Apple_Containerization_Sandbox_V3_FiThawParams: 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 unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest: 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 operation: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest.OneOf_Operation? = nil + + public var freeze: Com_Apple_Containerization_Sandbox_V3_FiFreezeParams { + get { + if case .freeze(let v)? = operation {return v} + return Com_Apple_Containerization_Sandbox_V3_FiFreezeParams() + } + set {operation = .freeze(newValue)} + } + + public var thaw: Com_Apple_Containerization_Sandbox_V3_FiThawParams { + get { + if case .thaw(let v)? = operation {return v} + return Com_Apple_Containerization_Sandbox_V3_FiThawParams() + } + set {operation = .thaw(newValue)} + } + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public enum OneOf_Operation: Equatable, Sendable { + case freeze(Com_Apple_Containerization_Sandbox_V3_FiFreezeParams) + case thaw(Com_Apple_Containerization_Sandbox_V3_FiThawParams) + + } + + public init() {} +} + +public struct Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse: 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 unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + public struct Com_Apple_Containerization_Sandbox_V3_IpLinkSetRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -3181,6 +3247,135 @@ extension Com_Apple_Containerization_Sandbox_V3_StatResponse: SwiftProtobuf.Mess } } +extension Com_Apple_Containerization_Sandbox_V3_FiFreezeParams: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".FiFreezeParams" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_FiFreezeParams, rhs: Com_Apple_Containerization_Sandbox_V3_FiFreezeParams) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Containerization_Sandbox_V3_FiThawParams: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".FiThawParams" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_FiThawParams, rhs: Com_Apple_Containerization_Sandbox_V3_FiThawParams) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".FilesystemOperationRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}path\0\u{1}freeze\0\u{1}thaw\0") + + 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 { + var v: Com_Apple_Containerization_Sandbox_V3_FiFreezeParams? + var hadOneofValue = false + if let current = self.operation { + hadOneofValue = true + if case .freeze(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.operation = .freeze(v) + } + }() + case 3: try { + var v: Com_Apple_Containerization_Sandbox_V3_FiThawParams? + var hadOneofValue = false + if let current = self.operation { + hadOneofValue = true + if case .thaw(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.operation = .thaw(v) + } + }() + 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.path.isEmpty { + try visitor.visitSingularStringField(value: self.path, fieldNumber: 1) + } + switch self.operation { + case .freeze?: try { + guard case .freeze(let v)? = self.operation else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + }() + case .thaw?: try { + guard case .thaw(let v)? = self.operation else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + }() + case nil: break + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest, rhs: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest) -> Bool { + if lhs.path != rhs.path {return false} + if lhs.operation != rhs.operation {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".FilesystemOperationResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse, rhs: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension Com_Apple_Containerization_Sandbox_V3_IpLinkSetRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".IpLinkSetRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}interface\0\u{1}up\0\u{1}mtu\0") diff --git a/Sources/Containerization/SandboxContext/SandboxContext.proto b/Sources/Containerization/SandboxContext/SandboxContext.proto index efce1ee5..5d0a03f6 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.proto +++ b/Sources/Containerization/SandboxContext/SandboxContext.proto @@ -30,6 +30,8 @@ service SandboxContext { rpc Copy(CopyRequest) returns (stream CopyResponse); // Stat a path in the guest filesystem. rpc Stat(StatRequest) returns (StatResponse); + // Perform a filesystem operation on a mounted filesystem. + rpc FilesystemOperation(FilesystemOperationRequest) returns (FilesystemOperationResponse); // Create a new process inside the container. rpc CreateProcess(CreateProcessRequest) returns (CreateProcessResponse); @@ -292,6 +294,19 @@ message StatResponse { string error = 2; // Non-empty if stat failed. } +message FiFreezeParams {} +message FiThawParams {} + +message FilesystemOperationRequest { + string path = 1; + oneof operation { + FiFreezeParams freeze = 2; + FiThawParams thaw = 3; + } +} + +message FilesystemOperationResponse {} + message IpLinkSetRequest { string interface = 1; bool up = 2; diff --git a/Sources/Containerization/VirtualMachineAgent.swift b/Sources/Containerization/VirtualMachineAgent.swift index 88c641ae..b01228a7 100644 --- a/Sources/Containerization/VirtualMachineAgent.swift +++ b/Sources/Containerization/VirtualMachineAgent.swift @@ -25,6 +25,11 @@ public struct WriteFileFlags { public var create = false } +public enum FilesystemOperation: Sendable { + case freeze + case thaw +} + /// A protocol for the agent running inside a virtual machine. If an operation isn't /// supported the implementation MUST return a ContainerizationError with a code of /// `.unsupported`. @@ -34,6 +39,8 @@ public protocol VirtualMachineAgent: Sendable { func standardSetup() async throws /// Close any resources held by the agent. func close() async throws + // Perform a filesystem operation on the given path. + func filesystemOperation(operation: FilesystemOperation, path: String) async throws // POSIX-y func getenv(key: String) async throws -> String diff --git a/Sources/Containerization/Vminitd.swift b/Sources/Containerization/Vminitd.swift index fd49f095..c4cb837d 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -207,6 +207,15 @@ extension Vminitd: VirtualMachineAgent { }) } + /// Perform a filesystem operation on a path inside the sandbox's environment. + public func filesystemOperation(operation: FilesystemOperation, path: String) async throws { + _ = try await client.filesystemOperation( + .with { + $0.operation = operation.toProtoOperation() + $0.path = path + }) + } + public func createProcess( id: String, containerID: String?, @@ -613,3 +622,15 @@ extension StatCategory { return categories } } + +extension FilesystemOperation { + /// Convert FilesystemOperation to proto oneof value. + fileprivate func toProtoOperation() -> Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest.OneOf_Operation { + switch self { + case .freeze: + return .freeze(.init()) + case .thaw: + return .thaw(.init()) + } + } +} diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 62c37918..60362c86 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -4215,6 +4215,118 @@ extension IntegrationSuite { } } + func testFrozenExt4Clone() async throws { + let id = "test-frozen-ext4-clone" + let bs = try await bootstrap(id) + + let diskImageURL = Self.testDir.appending(component: "\(id)-data.ext4") + try? FileManager.default.removeItem(at: diskImageURL) + + let filesystem = try EXT4.Formatter(FilePath(diskImageURL.absolutePath()), minDiskSize: 64.mib()) + try filesystem.close() + + let cloneImageURL = Self.testDir.appending(component: "\(id)-data-clone.ext4") + try? FileManager.default.removeItem(at: cloneImageURL) + + let writerContainer = try LinuxContainer("\(id)-writer", rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["/bin/sleep", "1000"] + config.mounts.append( + Mount.block( + format: "ext4", + source: diskImageURL.absolutePath(), + destination: "/data" + )) + config.bootLog = bs.bootLog + } + + do { + try await writerContainer.create() + try await writerContainer.start() + + try await writerContainer.filesystemOperation(operation: .freeze, path: "/data") + + let writeExec = try await writerContainer.exec("write-hello") { config in + config.arguments = ["/bin/sh", "-c", "echo hello > /data/hello.txt"] + } + try await writeExec.start() + let writeStatus = try await writeExec.wait() + try await writeExec.delete() + guard writeStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "write exec failed with status \(writeStatus)") + } + + try FileManager.default.copyItem(at: diskImageURL, to: cloneImageURL) + + try await writerContainer.filesystemOperation(operation: .thaw, path: "/data") + + try await writerContainer.kill(.kill) + _ = try await writerContainer.wait() + try await writerContainer.stop() + } catch { + try? await writerContainer.filesystemOperation(operation: .thaw, path: "/data") + try? await writerContainer.stop() + throw error + } + + let verifyContainer = try LinuxContainer("\(id)-reader", rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.mounts.append( + Mount.block( + format: "ext4", + source: cloneImageURL.absolutePath(), + destination: "/data" + )) + config.process.arguments = ["/bin/sleep", "1000"] + config.bootLog = bs.bootLog + } + + do { + try await verifyContainer.create() + try await verifyContainer.start() + + let mountBuffer = BufferWriter() + let mountExec = try await verifyContainer.exec("verify-mount") { config in + config.arguments = ["/bin/sh", "-c", "grep ' /data ' /proc/mounts"] + config.stdout = mountBuffer + } + try await mountExec.start() + var status = try await mountExec.wait() + try await mountExec.delete() + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "failed to verify /data mount, status \(status)") + } + + let mountOutput = String(decoding: mountBuffer.data, as: UTF8.self) + guard mountOutput.contains(" /data ") && mountOutput.contains(" ext4 ") else { + throw IntegrationError.assert(msg: "expected ext4 mount at /data, got: \(mountOutput)") + } + + let lsBuffer = BufferWriter() + let lsExec = try await verifyContainer.exec("verify-no-hello") { config in + config.arguments = ["ls", "-1", "/data"] + config.stdout = lsBuffer + } + try await lsExec.start() + status = try await lsExec.wait() + try await lsExec.delete() + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "ls /data failed with status \(status)") + } + + let lsOutput = String(decoding: lsBuffer.data, as: UTF8.self) + let listedFiles = Set(lsOutput.split(whereSeparator: \.isNewline).map(String.init)) + guard !listedFiles.contains("hello.txt") else { + throw IntegrationError.assert(msg: "expected cloned /data to not contain hello.txt, got: \(lsOutput)") + } + + try await verifyContainer.kill(.kill) + _ = try await verifyContainer.wait() + try await verifyContainer.stop() + } catch { + try? await verifyContainer.stop() + throw error + } + } + func testUseInitBasic() async throws { let id = "test-use-init-basic" diff --git a/Sources/Integration/PodTests.swift b/Sources/Integration/PodTests.swift index 74342013..35fe9e01 100644 --- a/Sources/Integration/PodTests.swift +++ b/Sources/Integration/PodTests.swift @@ -16,12 +16,14 @@ import ArgumentParser import Containerization +import ContainerizationEXT4 import ContainerizationError import ContainerizationExtras import ContainerizationOCI import ContainerizationOS import Foundation import Logging +import SystemPackage extension IntegrationSuite { /// Clone a rootfs mount to a new location for use by a container in a pod @@ -2144,4 +2146,77 @@ extension IntegrationSuite { msg: "expected fd00::2 on eth0 inside pod container, got: \(output)") } } + + func testPodFilesystemOperation() async throws { + let id = "test-pod-filesystem-operation" + + let bs = try await bootstrap(id) + + let diskImageURL = Self.testDir.appending(component: "\(id)-data.ext4") + try? FileManager.default.removeItem(at: diskImageURL) + let filesystem = try EXT4.Formatter(FilePath(diskImageURL.absolutePath()), minDiskSize: 64.mib()) + try filesystem.close() + + let pod = try LinuxPod(id, vmm: bs.vmm) { config in + config.cpus = 4 + config.memoryInBytes = 1024.mib() + config.bootLog = bs.bootLog + } + + try await pod.addContainer("container1", rootfs: bs.rootfs) { config in + config.process.arguments = ["/bin/sleep", "1000"] + config.mounts.append( + Mount.block( + format: "ext4", + source: diskImageURL.absolutePath(), + destination: "/data" + )) + } + + do { + try await pod.create() + try await pod.startContainer("container1") + + try await pod.filesystemOperation("container1", operation: .freeze, path: "/data") + + let writeExec = try await pod.execInContainer("container1", processID: "write-hello") { config in + config.arguments = ["/bin/sh", "-c", "echo hello > /data/hello.txt"] + } + try await writeExec.start() + let writeStatus = try await writeExec.wait() + guard writeStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "write exec failed with status \(writeStatus)") + } + try await writeExec.delete() + + try await pod.filesystemOperation("container1", operation: .thaw, path: "/data") + + let readBuffer = BufferWriter() + let readExec = try await pod.execInContainer("container1", processID: "read-hello") { config in + config.arguments = ["/bin/cat", "/data/hello.txt"] + config.stdout = readBuffer + } + try await readExec.start() + let readStatus = try await readExec.wait() + guard readStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "read exec failed with status \(readStatus)") + } + try await readExec.delete() + + let readOutput = String(decoding: readBuffer.data, as: UTF8.self) + guard readOutput == "hello\n" else { + throw IntegrationError.assert( + msg: "expected 'hello\\n' in /data/hello.txt, got: '\(readOutput)'" + ) + } + + try await pod.killContainer("container1", signal: .kill) + _ = try await pod.waitContainer("container1") + try await pod.stop() + } catch { + try? await pod.filesystemOperation("container1", operation: .thaw, path: "/data") + try? await pod.stop() + throw error + } + } } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 42da366e..83f93f2a 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -378,6 +378,7 @@ struct IntegrationSuite: AsyncParsableCommand { Test("container writable layer with ro lower", testWritableLayerWithReadOnlyLower), Test("container writable layer size", testWritableLayerSize), Test("container writable layer DNS and hosts", testWritableLayerWithDNSAndHosts), + Test("container frozen ext4 clone", testFrozenExt4Clone), Test("large stdin input", testLargeStdinInput), Test("exec large stdin input", testExecLargeStdinInput), Test("exec custom path resolution", testExecCustomPathResolution), @@ -462,6 +463,7 @@ struct IntegrationSuite: AsyncParsableCommand { Test("pod NBD volume identity", testPodNBDVolumeIdentity), Test("pod invalid volume reference", testPodInvalidVolumeReference), Test("pod duplicate volume name", testPodDuplicateVolumeName), + Test("pod filesystem operation", testPodFilesystemOperation), ] + macOS26Tests() let filteredTests: [Test] diff --git a/vminitd/Sources/VminitdCore/Server+GRPC.swift b/vminitd/Sources/VminitdCore/Server+GRPC.swift index 6bbd7ac8..10d38aa5 100644 --- a/vminitd/Sources/VminitdCore/Server+GRPC.swift +++ b/vminitd/Sources/VminitdCore/Server+GRPC.swift @@ -31,6 +31,7 @@ import Logging import NIOCore import NIOPosix import SwiftProtobuf +import SystemPackage private let _setenv = Foundation.setenv @@ -679,6 +680,80 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ } } + public func filesystemOperation(request: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest, context: GRPCCore.ServerContext) + async throws -> Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse + { + let path = FilePath(request.path) + + log.debug( + "filesystemOperation", + metadata: [ + "operation": "\(String(describing: request.operation))", + "path": "\(path)", + ]) + + if !path.isAbsolute { + throw RPCError(code: .invalidArgument, message: "path must be absolute") + } + + var finfo = sys_stat.stat() + let rc = lstat(path.string, &finfo) + if rc != 0 { + let error = swiftErrno("lstat") + throw RPCError(code: .notFound, message: "failed to stat path", cause: error) + } + + if (finfo.st_mode & S_IFMT) == S_IFLNK { + throw RPCError(code: .internalError, message: "path cannot be a symlink") + } + + let fd = open(path.string, O_RDONLY) + if fd < 0 { + let error = swiftErrno("open") + throw RPCError(code: .internalError, message: "failed to open path", cause: error) + } + + defer { close(fd) } + + do { + switch request.operation { + case .freeze: + try freezeFilesystem(fd: fd) + case .thaw: + try thawFilesystem(fd: fd) + case .none: + throw RPCError(code: .invalidArgument, message: "invalid operation") + } + } catch { + log.error( + "filesystemOperation", + metadata: [ + "error": "\(error)" + ]) + throw RPCError(code: .internalError, message: "filesystemOperation", cause: error) + } + + return .init() + } + + private func freezeFilesystem(fd: Int32) throws { + let FIFREEZE: UInt = 0xC004_5877 + let rc: CInt = ioctl(fd, FIFREEZE, 0) + if rc != 0 { + let error = swiftErrno("ioctl(FIFREEZE)") + throw RPCError(code: .internalError, message: "freeze failed", cause: error) + } + } + + private func thawFilesystem(fd: Int32) throws { + let FITHAW: UInt = 0xC004_5878 + let rc: CInt = ioctl(fd, FITHAW, 0) + if rc != 0 { + let error = swiftErrno("ioctl(FITHAW)") + throw RPCError(code: .internalError, message: "thaw failed", cause: error) + } + } + public func umount(request: Com_Apple_Containerization_Sandbox_V3_UmountRequest, context: GRPCCore.ServerContext) async throws -> Com_Apple_Containerization_Sandbox_V3_UmountResponse {