You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

197 lines
8.2 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#/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()