#!/usr/bin/env python3 """ Copyright (c) 2018-2019 Alan Yorinks - All Rights Reserved. Python Banyan is free software; you can redistribute it and/or modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE Version 3 as published by the Free Software Foundation; either or (at your option) any later version. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA """ import argparse import logging import signal import subprocess import sys import time from os.path import expanduser from subprocess import Popen import psutil from apscheduler.schedulers.background import BackgroundScheduler from python_banyan.banyan_base import BanyanBase class BLC(BanyanBase): """ This is the banyan launcher client. It receives launch instructions from the server and then locally launches and manages those processes. """ def __init__(self, subscriber_port='43125', publisher_port='43124', back_plane_ip_address=None, topic=None): """ :param back_plane_ip_address: address of backplane. This is a required parameter :param subscriber_port: backplane subscriber port :param publisher_port: backplane publisher port :param topic: subscriber topic containing launch instructions. This is a required parameter """ # To use this class the backplane address is a required parameter if not any((back_plane_ip_address, topic)): print('You must specify both the backplane ip address and topic.') sys.exit(0) # start the logging process # get the home directory path home = expanduser("~") # set up the log file logging.basicConfig(filename=home + '/banyan_launcher.log', filemode='w', level=logging.ERROR) # a popen process object self.proc = None # maintain a database of information pertaining to each launch item # this will be an array of dictionaries, with each row describing a single launched process self.launch_db = [] # the keys defined for a given row are as follows: # # auto_restart - restart process if it dies # append_bp_address - the backplane ip address appended to # the command_string with -b option # command_string - the command used to launch the process # process - the value returned from popen after launching process # process_id - pid of launched process # spawn - spawn process in its own window # topic - used to publish to remote launcher # reply_topic - reply topic from remote launcher # call the parent class to attach this banyan component to the backplane super(BLC, self).__init__(back_plane_ip_address=back_plane_ip_address, subscriber_port=subscriber_port, publisher_port=publisher_port, process_name='Banyan Launch Client', loop_time=.1) print('Listening for ' + topic + ' messages.') # subscribe to the launch topic specified in the init parameter self.set_subscriber_topic(topic) # subscribe to the killall topic to exit this program via message self.set_subscriber_topic('killall') # start the background scheduler to periodically run check_processes and confirm self.scheduler = BackgroundScheduler() self.job = self.scheduler.add_job(self.check_local_processes, 'interval', seconds=.5) self.scheduler.start() try: # initial launching is complete, so just wait to receive incoming messages. self.receive_loop() except (KeyboardInterrupt, SystemExit): self.clean_up() def spawn_local(self, idx): """ This method launches processes that are needed to run on this computer. :param idx: An index into launch_db """ # get the launch entry in launch_db db_entry = self.launch_db[idx] # skip over the entry for the backplane. # there shouldn't be one for the client, but the code is # kept consist with the server. # launch the process either in its own window or just launch it. # differentiate between windows and other os's. if not db_entry['command_string'] == 'backplane': if sys.platform.startswith('win32'): if db_entry['spawn'] == 'yes': self.proc = Popen(db_entry['command_string'], creationflags=subprocess.CREATE_NEW_CONSOLE) else: command_list = db_entry['command_string'] self.proc = Popen(command_list, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP) else: if db_entry['spawn'] == 'yes': self.proc = Popen(['xterm', '-e', db_entry['command_string']], stdin=subprocess.PIPE, stderr=subprocess.PIPE, stdout=subprocess.PIPE) else: command_list = db_entry['command_string'].split(' ') self.proc = Popen(command_list) # update the entry with the launch information db_entry['process'] = self.proc db_entry['process_id'] = self.proc.pid print('{:35} PID = {}'.format(db_entry['command_string'], str(self.proc.pid))) # allow a little time for the process to startup try: time.sleep(0.5) except (KeyboardInterrupt, SystemExit): # self.scheduler.shutdown() self.clean_up() sys.exit(0) """ def check_local_processes(self): # This method is called by the scheduler periodically. # Check to make sure if a local process previously started is still running. # If a process is not dead, print that to the console and if the process # has the restart flag set, then restart it. for x, record in enumerate(self.launch_db): # ignore backplane if not record['command_string'] == 'backplane': # get a list of all pids running pids = psutil.pids() status = None # check to see if the process in the launch_db is in this list if record['process'].pid in pids: proc = psutil.Process(record['process'].pid) # get the status for this process status = proc.status() # if it is not in the list, declare it a zombie to force the print if not record['process'].pid in pids: status = psutil.STATUS_ZOMBIE if status == psutil.STATUS_ZOMBIE: log_string = '{:35} PID = {} DIED'.format(record['command_string'], str(record['process'].pid)) print(log_string) # log_string = record['command_string'] + " PID = " + str(record['process'].pid + 'DIED') logger = logging.getLogger() logger.error(log_string) # reset its state and process, and process ID record['process'] = None record['process_id'] = None # do we need to restart it? if record['auto_restart'] == 'yes': self.spawn_local(x) """ def check_local_processes(self): """ This method is called by the scheduler periodically. Check to make sure if a local process previously started is still running. If a process is dead, print that to the console and if the process has the restart flag set, then restart it. Affect the launch_db entry so that only one message is printed and the if the process to be restarted, it is restarted once. """ if sys.platform.startswith('win32'): for x, record in enumerate(self.launch_db): # ignore backplane if not record['command_string'] == 'backplane': # get a list of all pids running pids = psutil.pids() # status = None # check to see if the process in the launch_db is in this list if record['process'].pid in pids: proc = psutil.Process(record['process'].pid) # get the status for this process status = proc.status() elif not record['process'].pid in pids: status = psutil.STATUS_ZOMBIE else: status = None # print(status) if status == psutil.STATUS_ZOMBIE: log_string = '{:35} PID = {} DIED'.format(record['command_string'], str(record['process'].pid)) print(log_string) # log_string = record['command_string'] + " PID = " + str(record['process'].pid + 'DIED') logger = logging.getLogger() logger.error(log_string) # reset its state and process, and process ID record['process'] = None record['process_id'] = None # do we need to restart it? if record['auto_restart'] == 'yes': self.spawn_local(x) else: for x, record in enumerate(self.launch_db): if not record['command_string'] == 'backplane': if record['process_id']: pids = psutil.pids() # print(record) # check to see if the process in the launch_db is in this list # status = None try: if record['process'].pid in pids: proc = psutil.Process(record['process'].pid) status = proc.status() # print('command: ' + record['command_string'] + 'status: ' + status) if status == psutil.STATUS_SLEEPING: continue if not status: status = psutil.STATUS_ZOMBIE if status == psutil.STATUS_ZOMBIE: log_string = '{:35} PID = {} DIED'.format(record['command_string'], str(record['process'].pid)) print(log_string) logger = logging.getLogger() logger.error(log_string) # reset its process, and process ID record['process'] = None record['process_id'] = None # do we need to restart it? if record['auto_restart'] == 'yes': self.spawn_local(x) except AttributeError: pass def incoming_message_processing(self, topic, payload): """ Messages are sent here from the receive_loop :param topic: Message Topic string :param payload: Message Data :return: """ # make sure this is not a duplicate launch request if topic == 'killall': # self.scheduler.shutdown() self.clean_up() # sys.exit(0) else: # check to see if the process was already launched and ignore # if it was for idx, record in enumerate(self.launch_db): if record['launch_id'] == payload['launch_id']: return # print(topic, payload) # idx = len(self.launch_db) self.launch_db.append(payload) self.spawn_local(len(self.launch_db) - 1) record = self.launch_db[-1] # pid = str(record['process_id']) # send acknowledgement to server ack = {'command_string': record['command_string'], 'process_id': record['process_id'], 'launch_id': record['launch_id']} topic = record['reply_topic'] self.publish_payload(ack, topic) def clean_up(self): """ Graceful shutdown - all newly opened windows and associated processes are killed :return: """ # self.scheduler.shutdown() # self.publish_payload({'kill': True}, 'killall') # time.sleep(.5) self.scheduler.pause() for idx, record in enumerate(self.launch_db): if record['process']: print('{:35} PID = {} KILLED'.format(record['command_string'], str(record['process'].pid))) proc = psutil.Process(record['process'].pid) proc.kill() record['process'] = None record['process_id'] = None sys.exit(0) def blc(): # allow user to bypass the IP address auto-discovery. This is necessary if the component resides on a computer # other than the computing running the backplane. parser = argparse.ArgumentParser() parser.add_argument("-b", dest="back_plane_ip_address", default="None", help="None or IP address used by Back Plane", required=True) parser.add_argument("-p", dest="publisher_port", default='43124', help="Publisher IP port") parser.add_argument("-t", dest="topic", default='None', help="Command Receiver Topic", required=True) parser.add_argument("-s", dest="subscriber_port", default='43125', help="Subscriber IP port") args = parser.parse_args() kw_options = { 'back_plane_ip_address': args.back_plane_ip_address, 'publisher_port': args.publisher_port, 'subscriber_port': args.subscriber_port, 'topic': args.topic } # replace with the name of your class BLC(**kw_options) # signal handler function called when Control-C occurs # noinspection PyShadowingNames,PyUnusedLocal,PyUnusedLocal def signal_handler(sig, frame): print('Exiting Through Signal Handler') raise KeyboardInterrupt # listen for SIGINT signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) if __name__ == '__main__': # replace with name of function you defined above blc()