github.com/google/fleetspeak@v0.1.15-0.20240426164851-4f31f62c1aea/fleetspeak_python/fleetspeak/server_connector/connector.py (about) 1 # Copyright 2017 Google Inc. 2 # 3 # Licensed under the Apache License, Version 2.0 (the "License"); 4 # you may not use this file except in compliance with the License. 5 # You may obtain a copy of the License at 6 # 7 # https://www.apache.org/licenses/LICENSE-2.0 8 # 9 # Unless required by applicable law or agreed to in writing, software 10 # distributed under the License is distributed on an "AS IS" BASIS, 11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 # See the License for the specific language governing permissions and 13 # limitations under the License. 14 15 """Python library to communicate with Fleetspeak over grpc.""" 16 17 import abc 18 import collections 19 from concurrent import futures 20 import datetime 21 import logging 22 import os 23 import threading 24 import time 25 from typing import Callable 26 from typing import Optional 27 from typing import TypeVar 28 29 from absl import flags 30 from fleetspeak.src.common.proto.fleetspeak import common_pb2 31 from fleetspeak.src.server.grpcservice.proto.fleetspeak_grpcservice import grpcservice_pb2_grpc 32 from fleetspeak.src.server.proto.fleetspeak_server import admin_pb2 33 from fleetspeak.src.server.proto.fleetspeak_server import admin_pb2_grpc 34 import grpc 35 36 FLAGS = flags.FLAGS 37 38 flags.DEFINE_string( 39 "fleetspeak_message_listen_address", 40 "", 41 "The address to bind to, to listen for fleetspeak messages.", 42 ) 43 flags.DEFINE_string( 44 "fleetspeak_server", 45 "", 46 "The address to find the fleetspeak admin server, e.g. 'localhost:8080'", 47 ) 48 49 DEFAULT_TIMEOUT = datetime.timedelta(seconds=30) 50 51 _T = TypeVar("_T") 52 53 54 # TODO: Remove retry logic when possible. I.e. when grpc supports it 55 # natively - https://github.com/grpc/proposal/blob/master/A6-client-retries.md 56 def RetryLoop( 57 func: Callable[[datetime.timedelta], _T], 58 timeout: Optional[datetime.timedelta] = None, 59 single_try_timeout: Optional[datetime.timedelta] = None, 60 ) -> _T: 61 """Retries an operation until success or deadline. 62 63 func() calls are retried if func raises a grpc.RpcError. 64 65 Args: 66 func: The function to run. Must take a timeout, in datetime.timedelta, as a 67 single parameter. If it raises grpc.RpcError and deadline has not be 68 reached, it will be run again. 69 timeout: Retries will continue until timeout has passed. If not specified, a 70 default of 30 seconds is used. 71 single_try_timeout: A timeout for each try. If not specified, will be set to 72 the same value as "timeout". 73 """ 74 timeout = timeout or DEFAULT_TIMEOUT 75 single_try_timeout = single_try_timeout or timeout 76 77 deadline = datetime.datetime.now() + timeout 78 cur_timeout = single_try_timeout 79 sleep = datetime.timedelta(seconds=1) 80 while True: 81 try: 82 return func(cur_timeout) 83 except grpc.RpcError: 84 if datetime.datetime.now() + sleep > deadline: 85 raise 86 time.sleep(sleep.total_seconds()) 87 sleep *= 2 88 time_left = max(datetime.timedelta(0), deadline - datetime.datetime.now()) 89 cur_timeout = min(time_left, single_try_timeout) 90 91 92 class Servicer(grpcservice_pb2_grpc.ProcessorServicer): 93 """A wrapper to collect messages from incoming grpcs. 94 95 This implementation of grpcservice_pb2_grpc.ProcessorServicer, it passes all 96 received messages into a provided callback, after performing some basic sanity 97 checking. 98 99 Note that messages may be delivered twice. 100 """ 101 102 def __init__(self, process_callback, service_name, **kwargs): 103 """Create a Servicer. 104 105 Args: 106 process_callback: A callback to be executed when a message arrives. Will 107 be called as process_callback(msg, context) where msg is a 108 common_pb2.Message and context is a grpc.ServicerContext. Must be 109 thread safe. 110 service_name: The name of the service that we are running as. Used to 111 sanity check the destination address of received messages. 112 **kwargs: Extra arguments passed to the constructor of the base class, 113 grpcservice_pb2_grpc.ProcessorServicer. 114 """ 115 super(Servicer, self).__init__(**kwargs) 116 self._process_callback = process_callback 117 self._service_name = service_name 118 119 def Process(self, request, context): 120 if not isinstance(request, common_pb2.Message): 121 logging.error( 122 "Received unexpected request type: %s", request.__class__.__name__ 123 ) 124 context.set_code(grpc.StatusCode.UNKNOWN) 125 return common_pb2.EmptyMessage() 126 if request.destination.client_id: 127 logging.error( 128 "Received message for client: %s", request.destination.client_id 129 ) 130 context.set_code(grpc.StatusCode.INVALID_ARGUMENT) 131 return common_pb2.EmptyMessage() 132 if request.destination.service_name != self._service_name: 133 logging.error( 134 "Received message for unknown service: %s", 135 request.destination.service_name, 136 ) 137 context.set_code(grpc.StatusCode.INVALID_ARGUMENT) 138 return common_pb2.EmptyMessage() 139 140 self._process_callback(request, context) 141 return common_pb2.EmptyMessage() 142 143 144 class InvalidArgument(Exception): 145 """Exception indicating unexpected input.""" 146 147 148 class NotConfigured(Exception): 149 """Exception indicating that the requested operation is not configured.""" 150 151 152 class OutgoingConnection(object): 153 """An outgoing connection to Fleetspeak over grpc. 154 155 This wraps an admin_pb2_grpc.AdminStub, providing the same interface but 156 adding retry support and some sanity checks. 157 158 See the definition of the Admin grpc service in 159 server/proto/fleetspeak_server/admin.proto for full interface documentation. 160 """ 161 162 def __init__(self, channel, service_name, stub=None): 163 """Create a Sender. 164 165 Args: 166 channel: The grpc.Channel over which we should send messages. 167 service_name: The name of the service that we are running as. 168 stub: If set, used instead of AdminStub(channel). Intended to ease unit 169 tests. 170 """ 171 if stub: 172 self._stub = stub 173 else: 174 self._stub = admin_pb2_grpc.AdminStub(channel) 175 176 self._service_name = service_name 177 178 self._shutdown = False 179 self._shutdown_cv = threading.Condition() 180 self._keep_alive_thread = threading.Thread(target=self._KeepAliveLoop) 181 self._keep_alive_thread.daemon = True 182 self._keep_alive_thread.start() 183 184 def _KeepAliveLoop(self): 185 try: 186 while True: 187 with self._shutdown_cv: 188 if self._shutdown: 189 return 190 self._shutdown_cv.wait(timeout=5) 191 if self._shutdown: 192 return 193 try: 194 self._stub.KeepAlive(common_pb2.EmptyMessage(), timeout=1.0) 195 except grpc.RpcError as e: 196 logging.warning("KeepAlive rpc failed: %s", e) 197 except Exception as e: # pylint: disable=broad-except 198 logging.error("Exception in KeepAlive: %s", e) 199 200 def InsertMessage( 201 self, 202 message: common_pb2.Message, 203 timeout: Optional[datetime.timedelta] = None, 204 single_try_timeout: Optional[datetime.timedelta] = None, 205 ) -> None: 206 """Inserts a message into the Fleetspeak server. 207 208 Sets message.source, if unset. 209 210 Args: 211 message: common_pb2.Message The message to send. 212 timeout: Retries will continue until timeout has passed. If not specified, 213 a default of 30 seconds is used. 214 single_try_timeout: A timeout for each try. If not specified, will be set 215 to the same value as "timeout". 216 217 Raises: 218 grpc.RpcError: if the RPC fails. 219 InvalidArgument: if message is not a common_pb2.Message. 220 """ 221 if not isinstance(message, common_pb2.Message): 222 raise InvalidArgument( 223 "Attempt to send unexpected message type: %s" 224 % message.__class__.__name__ 225 ) 226 227 if not message.HasField("source"): 228 message.source.service_name = self._service_name 229 230 # Sometimes GRPC reports failure, even though the call succeeded. To prevent 231 # retry logic from creating duplicate messages we fix the message_id. 232 if not message.message_id: 233 message.message_id = os.urandom(32) 234 235 def Fn(t: datetime.timedelta) -> None: 236 self._stub.InsertMessage(message, timeout=t.total_seconds()) 237 238 return RetryLoop(Fn, timeout=timeout, single_try_timeout=single_try_timeout) 239 240 def DeletePendingMessages( 241 self, 242 request: admin_pb2.DeletePendingMessagesRequest, 243 timeout: Optional[datetime.timedelta] = None, 244 single_try_timeout: Optional[datetime.timedelta] = None, 245 ) -> None: 246 if not isinstance(request, admin_pb2.DeletePendingMessagesRequest): 247 raise TypeError( 248 "Expected fleetspeak.admin.DeletePendingMessagesRequest " 249 "as an argument." 250 ) 251 252 def Fn(t: datetime.timedelta) -> None: 253 self._stub.DeletePendingMessages(request, timeout=t.total_seconds()) 254 255 return RetryLoop(Fn, timeout=timeout, single_try_timeout=single_try_timeout) 256 257 def GetPendingMessages( 258 self, 259 request: admin_pb2.GetPendingMessagesRequest, 260 timeout: Optional[datetime.timedelta] = None, 261 single_try_timeout: Optional[datetime.timedelta] = None, 262 ) -> admin_pb2.GetPendingMessagesResponse: 263 def Fn(t: datetime.timedelta) -> admin_pb2.GetPendingMessagesResponse: 264 return self._stub.GetPendingMessages(request, timeout=t.total_seconds()) 265 266 return RetryLoop( 267 Fn, 268 timeout=timeout, 269 single_try_timeout=single_try_timeout, 270 ) 271 272 def GetPendingMessageCount( 273 self, 274 request: admin_pb2.GetPendingMessageCountRequest, 275 timeout: Optional[datetime.timedelta] = None, 276 single_try_timeout: Optional[datetime.timedelta] = None, 277 ) -> admin_pb2.GetPendingMessageCountResponse: 278 """Returns the number of pending messages.""" 279 280 def Fn(t: datetime.timedelta) -> admin_pb2.GetPendingMessageCountResponse: 281 return self._stub.GetPendingMessageCount( 282 request, 283 timeout=t.total_seconds(), 284 ) 285 286 return RetryLoop( 287 Fn, 288 timeout=timeout, 289 single_try_timeout=single_try_timeout, 290 ) 291 292 def ListClients( 293 self, 294 request: admin_pb2.ListClientsRequest, 295 timeout: Optional[datetime.timedelta] = None, 296 single_try_timeout: Optional[datetime.timedelta] = None, 297 ) -> admin_pb2.ListClientsResponse: 298 """Provides basic information about Fleetspeak clients. 299 300 Args: 301 request: fleetspeak.admin.ListClientsRequest 302 timeout: Retries will continue until timeout has passed. If not specified, 303 a default of 30 seconds is used. 304 single_try_timeout: A timeout for each try. If not specified, will be set 305 to the same value as "timeout". 306 307 Returns: fleetspeak.admin.ListClientsResponse 308 """ 309 310 def Fn(t: datetime.timedelta) -> admin_pb2.ListClientsResponse: 311 return self._stub.ListClients(request, timeout=t.total_seconds()) 312 313 return RetryLoop(Fn, timeout=timeout, single_try_timeout=single_try_timeout) 314 315 def FetchClientResourceUsageRecords( 316 self, 317 request: admin_pb2.FetchClientResourceUsageRecordsRequest, 318 timeout: Optional[datetime.timedelta] = None, 319 single_try_timeout: Optional[datetime.timedelta] = None, 320 ) -> admin_pb2.FetchClientResourceUsageRecordsResponse: 321 """Provides resource usage metrics of a single Fleetspeak client. 322 323 Args: 324 request: fleetspeak.admin.FetchClientResourceUsageRecordsRequest 325 timeout: Retries will continue until timeout has passed. If not specified, 326 a default of 30 seconds is used. 327 single_try_timeout: A timeout for each try. If not specified, will be set 328 to the same value as "timeout". 329 330 Returns: fleetspeak.admin.FetchClientResourceUsageRecordsResponse 331 """ 332 333 def Fn( 334 t: datetime.timedelta, 335 ) -> admin_pb2.FetchClientResourceUsageRecordsResponse: 336 return self._stub.FetchClientResourceUsageRecords( 337 request, timeout=t.total_seconds() 338 ) 339 340 return RetryLoop(Fn, timeout=timeout, single_try_timeout=single_try_timeout) 341 342 def Shutdown(self): 343 with self._shutdown_cv: 344 self._shutdown = True 345 self._shutdown_cv.notify() 346 self._keep_alive_thread.join() 347 348 349 class ServiceClient(metaclass=abc.ABCMeta): 350 """Bidirectional connection to Fleetspeak. 351 352 This abstract class can be used to represent a bidirectional connection with 353 fleetspeak. Users of this library are encourage to select (or provide) an 354 implementation of this according to their grpc connection requirements. 355 """ 356 357 @abc.abstractmethod 358 def __init__( 359 self, 360 service_name: str, 361 ) -> None: 362 """Abstract constructor for ServiceClient. 363 364 Args: 365 service_name: The Fleetspeak service name to communicate with. 366 """ 367 368 @abc.abstractmethod 369 def Listen( 370 self, 371 callback: Callable[[common_pb2.Message, grpc.ServicerContext], None], 372 ) -> None: 373 """Listens to messages from the Fleetspeak server. 374 375 Args: 376 callback: A callback to be executed when a messages arrives from the 377 Fleetspeak server. See the process argument of Servicer.__init__. 378 """ 379 380 381 class InsecureGRPCServiceClient(ServiceClient): 382 """An insecure bidirectional connection to Fleetspeak. 383 384 This class implements ServiceClient by creating insecure grpc connections. It 385 is meant primarily for integration testing. 386 387 Attributes: 388 outgoing: The underlying OutgoingConnection object. Present when configured 389 for writing. 390 """ 391 392 def __init__( 393 self, 394 service_name: str, 395 fleetspeak_message_listen_address: Optional[str] = None, 396 fleetspeak_server: Optional[str] = None, 397 threadpool_size: int = 5, 398 ): 399 """Constructor. 400 401 Args: 402 service_name: The name of the service to communicate as. 403 fleetspeak_message_listen_address: The connection's read end address. If 404 unset, the argv flag fleetspeak_message_listen_address will be used. If 405 still unset, the connection will not be open for reading and Listen() 406 will raise NotConfigured. 407 fleetspeak_server: The connection's write end address. If unset, the argv 408 flag fleetspeak_server will be used. If still unset, the connection will 409 not be open for writing and Send() will raise NotConfigured. 410 threadpool_size: The number of threads to use to process messages. 411 412 Raises: 413 NotConfigured: 414 If both fleetspeak_message_listen_address and fleetspeak_server are 415 unset. 416 """ 417 super(InsecureGRPCServiceClient, self).__init__(service_name) 418 419 if fleetspeak_message_listen_address is None: 420 fleetspeak_message_listen_address = ( 421 FLAGS.fleetspeak_message_listen_address or None 422 ) 423 424 if fleetspeak_server is None: 425 fleetspeak_server = FLAGS.fleetspeak_server or None 426 427 if fleetspeak_message_listen_address is None and fleetspeak_server is None: 428 raise NotConfigured( 429 "At least one of the arguments (fleetspeak_message_listen_address, " 430 "fleetspeak_server) has to be provided." 431 ) 432 433 self._service_name = service_name 434 self._listen_address = fleetspeak_message_listen_address 435 self._threadpool_size = threadpool_size 436 437 if fleetspeak_server is None: 438 logging.info( 439 "fleetspeak_server is unset, not creating outbound connection to " 440 "fleetspeak." 441 ) 442 self.outgoing = None 443 else: 444 channel = grpc.insecure_channel(fleetspeak_server) 445 self.outgoing = OutgoingConnection(channel, service_name) 446 logging.info( 447 "Fleetspeak GRPCService client connected to %s", fleetspeak_server 448 ) 449 450 def Send( 451 self, 452 message: common_pb2.Message, 453 ) -> None: 454 """Send one message. 455 456 Deprecated, users should migrate to call self.outgoing.InsertMessage 457 directly. 458 459 Args: 460 message: A message to send. 461 """ 462 if not self.outgoing: 463 raise NotConfigured("Send address not provided.") 464 self.outgoing.InsertMessage(message) 465 466 def Listen( 467 self, 468 callback: Callable[[common_pb2.Message, grpc.ServicerContext], None], 469 ) -> None: 470 if self._listen_address is None: 471 raise NotConfigured("Listen address not provided.") 472 self._server = grpc.server( 473 futures.ThreadPoolExecutor(max_workers=self._threadpool_size) 474 ) 475 self._server.add_insecure_port(self._listen_address) 476 servicer = Servicer(callback, self._service_name) 477 grpcservice_pb2_grpc.add_ProcessorServicer_to_server(servicer, self._server) 478 self._server.start() 479 logging.info( 480 "Fleetspeak GRPCService client listening on %s", self._listen_address 481 )