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      )