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.

170 lines
7.0 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.

#!/bin/bash
import os
import sys
import socket
import time
import subprocess
import configparser
import multiprocessing
CONFIG_FILE = "/etc/butler.ini"
# --- Функции, выполняющие действия ---
def are_users_there(check_cmd, service_name):
"""
Выполняет команду проверки через shell.
Возвращает True, если код возврата 0 (успех).
Возвращает False, если код возврата не 0 или произошла ошибка.
"""
try:
# shell=True позволяет использовать пайпы (|) и другие возможности shell
# check=True бросит исключение CalledProcessError при ненулевом коде возврата
print(f"[{service_name}] run {check_cmd}.", flush=True)
subprocess.run(
check_cmd,
shell=True,
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
executable='/bin/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)
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)
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 }
state = "wait_client" # Всегда начинаем с ожидания клиента
print(f"[{config['service_name']}] Butler process started.", flush=True)
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'),
"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()