#/usr/bin/python3 import os import sys import socket import time import subprocess import configparser import multiprocessing CONFIG_FILE = "/etc/butler.ini" SHELL_EXECUTABLE = '/bin/bash' # Гарантируем использование bash # --- Функции, выполняющие действия --- def is_service_running(is_running_cmd, service_name): """ Проверяет, запущен ли сервис, с помощью предоставленной команды. Возвращает True, если код возврата 0. """ print(f"[{service_name}] Checking if service is already running...", flush=True) try: subprocess.run( is_running_cmd, shell=True, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, executable=SHELL_EXECUTABLE ) return True except (subprocess.CalledProcessError, FileNotFoundError): return False def are_users_there(check_cmd, service_name): """ Выполняет команду проверки через shell. Возвращает True, если код возврата 0 (успех). Возвращает False, если код возврата не 0 или произошла ошибка. """ try: # shell=True позволяет использовать пайпы (|) и другие возможности shell # check=True бросит исключение CalledProcessError при ненулевом коде возврата subprocess.run( check_cmd, shell=True, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, executable=SHELL_EXECUTABLE # Явно указываем bash ) return True except (subprocess.CalledProcessError, FileNotFoundError): # CalledProcessError: команда вернула ненулевой код (считаем, что активности нет) # FileNotFoundError: команда не найдена (например, опечатка в пути) return False def server_shutdown(stop_cmd, service_name): print(f"[{service_name}] Stopping server...", flush=True) subprocess.run(stop_cmd, shell=True, executable=SHELL_EXECUTABLE) print(f"[{service_name}] Stop command sent.", flush=True) def server_startup(start_cmd, service_name): print(f"[{service_name}] Starting server...", flush=True) subprocess.run(start_cmd, shell=True, executable=SHELL_EXECUTABLE) print(f"[{service_name}] Start command sent.", flush=True) def wait_client(host, port, service_name): """ Слушает порт и ждет одного подключения. Использует SO_REUSEADDR для немедленного освобождения порта. """ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: # Эта опция решает проблему "Address already in use" sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((host, port)) sock.listen(0) print(f"[{service_name}] Listening on {host}:{port}...", flush=True) connection, address = sock.accept() print(f"[{service_name}] Connection from: {address}", flush=True) connection.close() print(f"[{service_name}] Socket closed, proceeding to start.", flush=True) # --- Функции состояний для конечного автомата (FSM) --- def state_check_users(ctx, config): service_name = config['service_name'] if are_users_there(config['check_command'], service_name): ctx["wasted"] = 0 print(f"[{service_name}] Activity detected, check is OK.", flush=True) return "check_users", config['check_interval_sec'] else: ctx["wasted"] += config['check_interval_sec'] print(f"[{service_name}] No activity detected ({ctx['wasted']}/{config['idle_limit_sec']} sec).", flush=True) if ctx["wasted"] >= config['idle_limit_sec']: print(f"[{service_name}] Idle limit reached.", flush=True) return "shutdown", 0 else: return "check_users", config['check_interval_sec'] def state_shutdown(ctx, config): server_shutdown(config['stop_command'], config['service_name']) return "wait_client", 0 def state_wait_client(ctx, config): wait_client(config['listen_host'], config['listen_port'], config['service_name']) return "startup", 0 def state_startup(ctx, config): server_startup(config['start_command'], config['service_name']) ctx["wasted"] = 0 # Даем сервису время на инициализацию перед первой проверкой return "check_users", config['startup_delay_sec'] # Карта переходов state_map = { "check_users": state_check_users, "shutdown": state_shutdown, "wait_client": state_wait_client, "startup": state_startup, } def run_service_butler(config): """ Основной цикл конечного автомата для одного сервиса. """ ctx = { "wasted": 0 } service_name = config['service_name'] print(f"[{service_name}] Butler process started.", flush=True) if is_service_running(config['is_running_command'], service_name): print(f"[{service_name}] Service is already running. Starting in 'check_users' state.", flush=True) state = "check_users" else: print(f"[{service_name}] Service is not running. Starting in 'wait_client' state.", flush=True) state = "wait_client" while True: state_fn = state_map[state] state, interval = state_fn(ctx, config) if interval > 0: time.sleep(interval) def main(): """ Главная функция: читает конфиг и запускает по процессу на каждый сервис. """ config = configparser.ConfigParser() if not os.path.exists(CONFIG_FILE): print(f"Error: Configuration file '{CONFIG_FILE}' not found.", file=sys.stderr) sys.exit(1) config.read(CONFIG_FILE) service_names = config.sections() if not service_names: print(f"Error: No services defined in '{CONFIG_FILE}'.", file=sys.stderr) sys.exit(1) print(f"Found services: {', '.join(service_names)}", flush=True) processes = [] for service_name in service_names: try: # Собираем конфигурацию для одного сервиса в словарь service_config = { "service_name": service_name, "start_command": config.get(service_name, 'start_command'), "stop_command": config.get(service_name, 'stop_command'), "is_running_command": config.get(service_name, 'is_running_command'), "listen_port": config.getint(service_name, 'listen_port'), "listen_host": config.get(service_name, 'listen_host'), "check_command": config.get(service_name, 'check_command'), "check_interval_sec": config.getint(service_name, 'check_interval_sec'), "idle_limit_sec": config.getint(service_name, 'idle_limit_sec'), "startup_delay_sec": config.getint(service_name, 'startup_delay_sec'), } # Создаем и запускаем отдельный процесс для каждого сервиса process = multiprocessing.Process(target=run_service_butler, args=(service_config,)) process.start() processes.append(process) except (configparser.NoOptionError, configparser.NoSectionError) as e: print(f"Error parsing config for service '{service_name}': {e}", file=sys.stderr) # Можно остановить уже запущенные процессы, если один конфиг невалиден for p in processes: p.terminate() sys.exit(1) # Главный процесс ждет завершения дочерних (бесконечных) процессов for p in processes: p.join() if __name__ == '__main__': main()