class Discovery: """Utility class for socket service discovery built on top of zeroconf Since individual sockets must be bound and connect using protocols, addresses and ports, a service discovery layer is utilized to map topic strings for each server client to the appropriate protocol, address and port on the network. Services (zmq sockets that bind to ports) are broadcast as zeroconf services using mdns (to both bonjour and avahi). Then, a listener receives those broadcasts and passes them upstream so connections can be made by clients that are looking for those services. Naming conventions: service: Any zmq socket with an associated topic, address and port (local or remote) client: Any zmq socket (service) that connects to a port at an address server: Any zmq socket (service) that binds to a port directory: List of services servers: Directory of servers clients: Directory of clients Attributes: logger: Logger instance, specific to activities within the service discovery layers zeroconf: Zeroconf object that runs it's own thread and handles mdns broadcasts browser: Zeroconf object that listens for changes in service being broadcast node_uuid: Unique identifier for the node that houses this class object on_add: Application level callback for when new services are received by the browser on_remove: Application level callback for when removed services are received by the browser servers: Maintains a list of servers that are known to be active on the network clients: Maintains a list of clients that are known to be active on the network """ def __init__(self, node_uuid, on_add, on_remove): """Constructor Args: node_uuid: Unique identifier for the node that houses this class object on_add: Callback for when new services are received by the browser on_remove: Callback for when removed services are received by the browser """ # grab the logger with the same name as the node self.logger = logging.getLogger("Discovery") self.zeroconf = Zeroconf() self.browser = ServiceBrowser(self.zeroconf, "_colugo._tcp.local.", self) self.node_uuid = node_uuid self.on_add = on_add self.on_remove = on_remove self.servers = Directory(self.node_uuid) self.clients = Directory(self.node_uuid) def register_server(self, topic, socket_type, node_uuid, socket, address, port): """Informs zeroconf that a new service should be broadcast to the network This is typically used when a server socket is being constructed by a node. This should be used for zmq sockets that bind as servers only. Clients should not be broadcast over the network since they simply observe. Args: topic: Topic string associated with the socket socket_type: ZMQ socket type (int) (default: None) node_uuid: Unique identifier of the local node where the socket is located socket: Integer where the socket is bound address: Address string (eg, 127.0.0.1) associated with the socket port: Integer where the socket is bound """ service = Service(topic, address, port, socket_type, node_uuid, socket) # for local sockets, we need to add to the directory manually, not from the mdns callback # since we won't have access to the socket object for the mdns callbacks self.servers.add(service) self.zeroconf.register_service(service.get_service_info()) def unregister_server(self, service): """Informs zeroconf that a service is being removed and broadcasts that to the network This is typically used when a server socket (or an entire node) is going down. Args: service: colugo.py.Service object to broadcast as being removed """ self.zeroconf.unregister_service(service.get_service_info()) def register_client(self, topic, socket_type, node_uuid, socket, address=None, port=None): """Add a client to the clients directory This will not broadcast the service over zeroconf since other nodes don't care about clients. TODO(pickledgator): Can server sockets detect if clients connect/disconnect? Do we care? Args: topic: Topic string associated with the socket socket_type: ZMQ socket type (int) node_uuid: Unique identifier of the local node where the socket is located socket: Integer where the socket is bound address: Address string (eg, 127.0.0.1) associated with the socket (default: None) port: Integer where the socket is bound (default: None) """ service = Service(topic, address, port, socket_type, node_uuid, socket) self.clients.add(service) def unregister_client(self, service): """Remove a client from the clients directory Args: service: colugo.py.Service object to remove """ # TODO(pickledgator): Do other network servers/clients care if a local client goes down? self.clients.remove(service) def service_from_zeroconf_query(self, topic, uuid): """Helper function to create a colguo.py.Service from a zeroconf query Args: topic: Topic string associated with the socket uuid: Unique identifier of the local node where the socket is located Returns: colugo.py.Service|None: Populated service if found on the network, otherwise None """ def fix_socket_type(info): # For whatever reason, zeroconf is casting a property=1 to property=True # This just undoes that cast since socket_type will be an int (not bool) if info.properties['socket_type'.encode('utf-8')] == True: info.properties['socket_type'.encode('utf-8')] = 1 return info info = ServiceInfo(type_=COLUGO_TYPE_STR, name="_{}._{}.{}".format(topic, uuid, COLUGO_TYPE_STR)) res = info.request(self.zeroconf, 1000) address = port = node_uuid = None service = Service() if res: info = fix_socket_type(info) service.fill_from_info(info) return service return None def unregister_all_servers(self): """Helper function to unregister all services that are servers This is typically called when an entire node is exiting. """ for s in self.servers.services: if s.node_uuid == self.node_uuid: self.unregister_server(s) def unregister_all_clients(self): """Helper function to unregister all services that are clients This is typically called when an entire node is exiting. """ for c in self.clients.services: if c.node_uuid == self.node_uuid: self.unregister_client(c) def stop_listening(self): """Stop the zeroconf browser callbacks from firing. Techicanlly speaking, zeroconf.close() also calls this, but it turns out that when nodes are exiting, they spam the network that their sockets are going down and we get loop back messages for the local node's sockets. Since we don't care about these loopback messages when a node is closing, we just turn of the service listeners for zeroconf early. """ self.zeroconf.remove_all_service_listeners() def stop(self): """Stop zeroconf and clean up threads """ self.unregister_all_servers() self.zeroconf.close() def topic_from_mdns_name(self, name): """Helper to get topic and uuid information about a service Args: name: mdns name to parse Returns: (String, String): Topic string and uuid string of the socket """ # assumes name is rigidly structured eg, _topic.string._colugo._tcp.local. tokens = [t[:-1] for t in name.split("_")][1:] return (tokens[0], tokens[1]) def add_service(self, zeroconf, service_type, name): """This function is utilized by the zeroconf.ServiceBrowser callbacks """ # get details of the newly discovered service (topic, uuid) = self.topic_from_mdns_name(name) # generate our full topic object from the acquired info service = self.service_from_zeroconf_query(topic, uuid) if service: self.logger.debug("Service added: {}".format(name)) # try to add the topic to the directory # this will fail for local topics that were already added if not self.servers.check_existance(service): if self.servers.add(service): self.on_add(service) def remove_service(self, zeroconf, service_type, name): """This function is utilized by the zeroconf.ServiceBrowser callbacks """ (topic, uuid) = self.topic_from_mdns_name(name) self.logger.debug("Service removed: {}".format(name)) # By time this callback occurs, we can no longer access the ServiceInfo # for the specified service, so we can have to remove our service from the # Directory based on the topic only. # TODO(pickledgator): This wont work if we have two services with the same topic # within the same node, however it works fine if the two services with the same # topic are on different nodes (due to inclusion of the uuid in the check) self.servers.remove(topic, uuid) self.on_remove(topic)