import subprocess import os import re import shlex from abc import ABC, abstractmethod from typing import Optional, List, Callable from dialog import Dialog from postinstall.logger import logger import socket REGISTRY_URL = "dr.gosrv.ru" REGISTRY_LOGIN = "anon_user" REGISTRY_PASSWORD = "Qweasdzxc123C@" DOCKER_COMPOSE_FILE_PATH = "/usr/local/share/postinstall/portainer/" class CommandExecutor: @staticmethod def run_command( command: str, shell: bool = False, input_data: Optional[str] = None, ignore_errors: List[int] = [], ) -> subprocess.CompletedProcess: try: return subprocess.run( command if shell else shlex.split(command), shell=shell, check=True, input=input_data, text=True, capture_output=True, ) except subprocess.CalledProcessError as e: if e.returncode not in ignore_errors: logger.error(f"Ошибка выполнения команды: {e}") raise else: logger.warning( f"Команда завершилась с игнорируемым кодом ошибки {e.returncode}: {e}" ) return e class SystemChecker: @staticmethod def is_command_installed(command: str) -> bool: try: return CommandExecutor.run_command(f"which {command}").returncode == 0 except subprocess.CalledProcessError: return False class ProgressGauge: def __init__(self, dialog: Dialog, title: str, total_steps: int): self.dialog = dialog self.title = title self.total_steps = total_steps self.current_step = 0 def update(self, step: int, message: str): self.current_step = step percent = int((step / self.total_steps) * 100) self.dialog.gauge_update(percent, f"{message} ({percent}%)", update_text=True) def __enter__(self): self.dialog.gauge_start(self.title, percent=0) return self def __exit__(self, exc_type, exc_val, exc_tb): self.dialog.gauge_stop() class DockerManager: @staticmethod def install(progress_callback: Callable[[int, str], None]): logger.info("Установка Docker...") progress_callback(1, "Обновление пакетов") CommandExecutor.run_command("apt-get update") progress_callback(2, "Установка Docker") CommandExecutor.run_command("apt-get install -y docker.io") progress_callback(3, "Установка Docker Compose") CommandExecutor.run_command("apt-get install -y docker-compose") progress_callback(4, "Выполнение входа в Docker registry") DockerManager.login_to_registry() progress_callback(5, "Docker успешно установлен") logger.info("Docker успешно установлен") @staticmethod def uninstall(progress_callback: Callable[[int, str], None]): logger.info("Удаление Docker...") progress_callback(1, "Удаление Docker и Docker Compose") CommandExecutor.run_command("apt-get remove -y docker.io docker-compose") progress_callback(2, "Очистка зависимостей") CommandExecutor.run_command("apt-get autoremove -y") progress_callback(3, "Удаление файлов Docker") CommandExecutor.run_command("rm -rf /var/run/docker") CommandExecutor.run_command("rm -rf /var/lib/docker") progress_callback(4, "Docker успешно удален") logger.info("Docker успешно удален") @staticmethod def login_to_registry(): logger.info("Выполняется вход в Docker registry...") try: result = CommandExecutor.run_command( f"echo {REGISTRY_PASSWORD} | docker login --username {REGISTRY_LOGIN} --password-stdin {REGISTRY_URL}", shell=True, ) logger.info("Успешный вход в Docker registry") logger.debug(f"Docker login output: {result.stdout}") except subprocess.CalledProcessError as e: logger.error(f"Ошибка входа в Docker registry: {e}") logger.error(f"Stderr: {e.stderr}") raise class UFWManager: @staticmethod def add_rule(port: str): rule_name = "portainer" logger.info( f"Добавление именованного правила UFW '{rule_name}' для порта {port}..." ) CommandExecutor.run_command(f"sudo ufw allow {port}/tcp comment '{rule_name}'") CommandExecutor.run_command(f"sudo ufw app update {rule_name}") logger.info( f"Именованное правило UFW '{rule_name}' для порта {port} успешно добавлено" ) @staticmethod def remove_rule(port: str): rule_name = "portainer" logger.info( f"Удаление именованного правила UFW '{rule_name}' для порта {port}..." ) CommandExecutor.run_command(f"sudo ufw delete allow {port}/tcp") CommandExecutor.run_command(f"sudo ufw app update {rule_name}") logger.info( f"Именованное правило UFW '{rule_name}' для порта {port} успешно удалено" ) class PortainerManager: @staticmethod def install( port: str, password: str, ip: str, progress_callback: Callable[[int, str], None] ): logger.info("Установка Portainer...") progress_callback(1, "Вход в Docker registry") DockerManager.login_to_registry() progress_callback(2, "Остановка предыдущей версии Portainer") try: CommandExecutor.run_command( f"sudo docker-compose -f {DOCKER_COMPOSE_FILE_PATH}docker-compose.yml down", ignore_errors=[1], ) except subprocess.CalledProcessError: pass progress_callback(3, "Удаление старых данных Portainer") try: CommandExecutor.run_command( "docker volume ls -q -f name=portainer* | xargs -r docker volume rm", ignore_errors=[1], ) except subprocess.CalledProcessError: pass progress_callback(4, "Установка apache2-utils для htpasswd") if not SystemChecker.is_command_installed("htpasswd"): CommandExecutor.run_command("apt-get install -y apache2-utils") progress_callback(5, "Создание конфигурации Portainer") os.makedirs(os.path.dirname(DOCKER_COMPOSE_FILE_PATH), exist_ok=True) password_hash = PortainerManager._generate_password_hash(password) PortainerManager._create_docker_compose_file(port, password_hash, ip) progress_callback(6, "Запуск Portainer") CommandExecutor.run_command( f"sudo docker-compose -f {DOCKER_COMPOSE_FILE_PATH}docker-compose.yml up -d" ) # if ip != "127.0.0.1": if True: progress_callback(7, "Добавление правила UFW") UFWManager.add_rule(port) progress_callback(8, "Portainer успешно установлен") logger.info("Portainer успешно установлен") @staticmethod def uninstall(progress_callback: Callable[[int, str], None]): logger.info("Удаление Portainer...") progress_callback(1, "Остановка Portainer") try: CommandExecutor.run_command( f"docker-compose -f {DOCKER_COMPOSE_FILE_PATH}docker-compose.yml down --volumes", ignore_errors=[1], ) except subprocess.CalledProcessError: pass progress_callback(2, "Удаление файлов конфигурации") CommandExecutor.run_command( f"rm -f {DOCKER_COMPOSE_FILE_PATH}docker-compose.yml" ) # try: # with open(f"{DOCKER_COMPOSE_FILE_PATH}docker-compose.yml", "r") as f: # config = f.read() # port_ip_match = re.search(r'"(.+):(\d+):9000"', config) # if port_ip_match: # ip, port = port_ip_match.groups() # # if True: # # progress_callback(3, "Удаление правила UFW") # # UFWManager.remove_rule(port) # except FileNotFoundError: # logger.warning("Файл конфигурации не найден. Пропуск удаления правила UFW.") progress_callback(4, "Portainer успешно удален") logger.info("Portainer успешно удален") @staticmethod def _generate_password_hash(password: str) -> str: return CommandExecutor.run_command( f"htpasswd -nbB admin {shlex.quote(password)} | cut -d ':' -f 2 | sed 's/\\$/\\$\\$/g'", shell=True, ).stdout.strip() @staticmethod def _create_docker_compose_file(port: str, password_hash: str, ip: str): docker_compose_content = f""" version: '2' services: portainer: image: {REGISTRY_URL}/base_images/portainer:latest ports: - "{ip}:{port}:9000" command: --admin-password '{password_hash}' networks: - local volumes: - /var/run/docker.sock:/var/run/docker.sock - portainer-data:/data environment: - PORTAINER_LOCAL_IMAGES_ENVIRONMENT_ID=1 deploy: mode: global networks: local: driver: bridge volumes: portainer-data: """ with open(f"{DOCKER_COMPOSE_FILE_PATH}docker-compose.yml", "w") as f: f.write(docker_compose_content) class InputValidator: @staticmethod def validate(value: str, pattern: str) -> bool: return bool(re.match(pattern, value)) class UIElement(ABC): def __init__(self, dialog: Dialog): self.dialog = dialog @abstractmethod def show(self): pass class Menu(UIElement): def show(self): choices = [] if not SystemChecker.is_command_installed("docker"): choices.append(("1", "Установить Docker")) else: choices.append(("1", "Удалить Docker")) if SystemChecker.is_command_installed("docker"): if not os.path.exists(f"{DOCKER_COMPOSE_FILE_PATH}docker-compose.yml"): choices.append(("2", "Установить Portainer")) else: choices.append(("2", "Удалить Portainer")) code, choice = self.dialog.menu("Выберите действие:", choices=choices) return code, choice class YesNoUI(UIElement): def show(self, text: str): return self.dialog.yesno(text) == self.dialog.OK class PasswordInput(UIElement): def show(self): while True: code, password = self.dialog.passwordbox( "Введите пароль для Portainer:", insecure=True ) if code == self.dialog.CANCEL: return None if InputValidator.validate(password, r"^.{12,}$"): return password self.dialog.msgbox( "Пароль должен содержать минимум 12 символов.", width=50, height=10 ) class PortInput(UIElement): def show(self): while True: code, port = self.dialog.inputbox( "Введите порт для Portainer:", init="9000" ) if code == self.dialog.CANCEL: return None if InputValidator.validate(port, r"^[1-9][0-9]{0,4}$"): return port self.dialog.msgbox( "Некорректный порт. Введите число от 1 до 65535.", width=50, height=10 ) class IPInput(UIElement): def get_default_interface_ip(self): try: # Выполнение команды для получения IP-адреса основного интерфейса command = "ip -o -4 addr show $(ip route | grep default | awk '{print $5}') | awk '{print $4}' | cut -d/ -f1" result = subprocess.check_output(command, shell=True, text=True) return result.strip() # Возвращаем IP-адрес без лишних символов except subprocess.CalledProcessError: return "127.0.0.1" # В случае ошибки вернём стандартный IP def show(self): # Получаем IP-адрес основного сетевого интерфейса default_ip = self.get_default_interface_ip() while True: # Используем полученный IP-адрес как начальное значение code, ip = self.dialog.inputbox( "Введите IP адрес для Portainer:", init=default_ip ) if code == self.dialog.CANCEL: return None if InputValidator.validate(ip, r"^(\d{1,3}\.){3}\d{1,3}$"): return ip self.dialog.msgbox( "Некорректный IP адрес. Введите адрес в формате X.X.X.X", width=50, height=10, ) class NetworkInterfaceInput(UIElement): def get_network_interfaces(self): try: # Get all network interfaces interfaces = subprocess.check_output( "ip -o link show", shell=True, text=True ) # Extract interface names and their IP addresses interface_list = [] for line in interfaces.split("\n"): if line: interface = line.split(":")[1].strip() ip = self.get_ip_address(interface) if ip: interface_list.append((interface, ip)) return interface_list except subprocess.CalledProcessError: return [] def get_ip_address(self, interface): try: ip = subprocess.check_output( f"ip -o -4 addr list {interface}", shell=True, text=True ) return ip.split()[3].split("/")[0] except subprocess.CalledProcessError: return None def show(self): interfaces = self.get_network_interfaces() if not interfaces: self.dialog.msgbox( "Не удалось получить список сетевых интерфейсов.", width=50, height=10 ) return None choices = [(f"{interface} ({ip})", interface) for interface, ip in interfaces] code, choice = self.dialog.menu( "Выберите сетевой интерфейс для Docker Swarm:", choices=choices ) if code == self.dialog.OK: return choice return None class DockerConfigUI: def __init__(self): self.dialog = Dialog(dialog="dialog") self.menu = Menu(self.dialog) self.password_input = PasswordInput(self.dialog) self.port_input = PortInput(self.dialog) self.ip_input = IPInput(self.dialog) self.network_interface_input = NetworkInterfaceInput(self.dialog) def run(self): try: while True: code, choice = self.menu.show() if code == self.dialog.OK: if choice == "1": self._handle_docker() elif choice == "2": self.handle_portainer() elif choice == "3": break else: break except Exception as e: logger.error(f"Произошла ошибка: {e}") self.dialog.msgbox(f"Произошла ошибка: {str(e)}", width=50, height=10) def _handle_docker(self): if not SystemChecker.is_command_installed("docker"): with ProgressGauge(self.dialog, "Установка Docker", 4) as gauge: DockerManager.install(gauge.update) self.dialog.msgbox("Docker успешно установлен.", width=50, height=10) logger.info("Docker успешно установлен.") yes_no_ui = YesNoUI(self.dialog) if yes_no_ui.show( text="Вы хотите установить Docker Swarm - инструмент для управления кластером докер серверов?" ): interface = self.network_interface_input.show() if interface: ip = interface.split("(")[1].split(")")[0] if ip: try: CommandExecutor.run_command( f"docker swarm init --advertise-addr {ip}" ) self.dialog.msgbox( f"Docker Swarm успешно инициализирован на интерфейсе {interface}", width=50, height=10, ) except subprocess.CalledProcessError as e: self.dialog.msgbox( f"Ошибка при инициализации Docker Swarm: {str(e)}", width=50, height=10, ) else: self.dialog.msgbox( f"Не удалось получить IP-адрес для интерфейса {interface}.", width=50, height=10, ) else: self.dialog.msgbox( "Инициализация Docker Swarm отменена.", width=50, height=10 ) if yes_no_ui.show( text="Вы хотите установить Portainer - удобный Web интерфейс для управления докер контейнерами?" ): self.handle_portainer() else: with ProgressGauge(self.dialog, "Удаление Docker", 4) as gauge: PortainerManager.uninstall(gauge.update) DockerManager.uninstall(gauge.update) self.dialog.msgbox("Docker успешно удален.", width=50, height=10) logger.info("Docker успешно удален.") def handle_portainer(self): if not SystemChecker.is_command_installed("docker"): self.dialog.msgbox( "Docker не установлен. Пожалуйста, сначала установите Docker.", width=50, height=10, ) return if not os.path.exists(f"{DOCKER_COMPOSE_FILE_PATH}docker-compose.yml"): self._install_portainer() else: with ProgressGauge(self.dialog, "Удаление Portainer", 3) as gauge: PortainerManager.uninstall(gauge.update) self.dialog.msgbox("Portainer успешно удален.", width=50, height=10) logger.info("Portainer успешно удален.") def _install_portainer(self): password = self.password_input.show() if not password: return port = self.port_input.show() if not port: return ip = self.ip_input.show() if not ip: return try: with ProgressGauge(self.dialog, "Установка Portainer", 6) as gauge: PortainerManager.install(port, password, ip, gauge.update) self.dialog.msgbox( f"Portainer успешно установлен.\nВы можете войти в Portainer по адресу {ip}:{port} используя логин admin и введенный пароль", width=50, height=10, ) logger.info( f"Portainer успешно установлен.\nВы можете войти в Portainer по адресу {ip}:{port} используя логин admin и введенный пароль" ) except Exception as e: self.dialog.msgbox( f"Ошибка при установке Portainer: {str(e)}", width=50, height=10 ) def run(): ui = DockerConfigUI() ui.run()