/
sapertlt
/
deploy_to_production
Обзор
Документация
Войти
/
sapertlt
/
deploy_to_production
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
deploy_wizard.py
1 142 строки
54 KB
Andrey
docs(wizard): clarify organization runner token setup in instructions
10 часов назад
10 часов назад
093dc75
Код
Авторство
О чём код?
#!/usr/bin/env python3 import os import sys import re import json import subprocess import getpass import urllib.request import urllib.error import builtins from pathlib import Path class ReturnToMenu(Exception): pass original_input = builtins.input def wizard_input(prompt=""): val = original_input(prompt) if val.strip().lower() in ('<', 'back'): raise ReturnToMenu() return val builtins.input = wizard_input # ================= ГЛОБАЛЬНЫЕ НАСТРОЙКИ ПО УМОЛЧАНИЮ ================= DEFAULT_PROD_IP = "192.168.1.34" DEFAULT_NPM_URL = "http://192.168.1.21:81" PROD_BASE_DIR = "/opt" # ===================================================================== LOCAL_PORTS_FILE = "/home/andrey/local_development/antigravity_dev/local_service_port_used.yaml" PROD_PORTS_FILE = "/opt/prod_service_port_used.yaml" CONFIG_FILE = Path.home() / ".deploy_wizard_config.json" def print_header(msg): print(f"\n\033[1;36m=== {msg} ===\033[0m") def print_success(msg): print(f"\033[1;32m[✓] {msg}\033[0m") def print_info(msg): print(f"\033[1;34m[i] {msg}\033[0m") def print_warning(msg): print(f"\033[1;33m[!] {msg}\033[0m") def print_error(msg): print(f"\033[1;31m[x] {msg}\033[0m") sys.exit(1) def get_ssh_control_args(host, user): cm_dir = Path.home() / ".ssh" / "cm" cm_dir.mkdir(parents=True, exist_ok=True) socket_path = cm_dir / f"{user}@{host}" return [ "-o", "StrictHostKeyChecking=no", "-o", "ControlMaster=auto", "-o", f"ControlPath={socket_path}", "-o", "ControlPersist=10m" ] def ssh_run_cmd(host, user, cmd): ssh_cmd = ["ssh"] + get_ssh_control_args(host, user) + [f"{user}@{host}", cmd] res = subprocess.run(ssh_cmd, capture_output=True, text=True) return res.returncode, res.stdout, res.stderr def ensure_ssh_key_installed(host, user): ssh_dir = Path.home() / ".ssh" ssh_dir.mkdir(parents=True, exist_ok=True) key_path = ssh_dir / "id_ed25519" pub_key_path = ssh_dir / "id_ed25519.pub" if not pub_key_path.exists(): key_path_rsa = ssh_dir / "id_rsa.pub" if key_path_rsa.exists(): pub_key_path = key_path_rsa else: print_info("Локальный SSH-ключ не найден. Генерируем новый (Ed25519)...") subprocess.run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)], check=True) pub_key_path = key_path.with_name(key_path.name + ".pub") if pub_key_path.exists(): pub_key = pub_key_path.read_text(encoding='utf-8').strip() cmd = ( "mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && " f"grep -qF '{pub_key}' ~/.ssh/authorized_keys 2>/dev/null || echo '{pub_key}' >> ~/.ssh/authorized_keys" ) ssh_run_cmd(host, user, cmd) # --- ВЫБОР ПРОЕКТА --- def select_project(cfg): print_header("Выбор проекта") base_dir_str = input(f"Базовая папка проектов [{cfg.get('base_projects_dir', str(Path.home()))}]: ").strip() or cfg.get('base_projects_dir', str(Path.home())) cfg['base_projects_dir'] = base_dir_str CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding='utf-8') base_dir = Path(base_dir_str).resolve() if not base_dir.exists(): print_error("Базовая директория не существует.") projects = [d for d in base_dir.iterdir() if d.is_dir() and not d.name.startswith('.')] projects.sort(key=lambda x: x.name) if not projects: print_error("В базовой директории нет папок проектов.") print("\nДоступные проекты:") for i, p in enumerate(projects, 1): print(f" [{i}] {p.name}") proj_idx_str = input(f"\nВыберите номер проекта [1-{len(projects)}]: ").strip() try: proj_idx = int(proj_idx_str) - 1 project_path = projects[proj_idx] if proj_idx < 0 or proj_idx >= len(projects): raise ValueError except (ValueError, IndexError): print_error("Неверный выбор проекта.") project_name = project_path.name print_success(f"Выбран проект: {project_name} ({project_path})") return project_path, project_name # --- BOOTSTRAP (ПЕРВОНАЧАЛЬНАЯ НАСТРОЙКА) --- def bootstrap_server(cfg): print_header("ИНИЦИАЛИЗАЦИЯ ИЛИ ОБНОВЛЕНИЕ СЕРВЕРА (BOOTSTRAP)") host = input(f"IP-адрес сервера [{DEFAULT_PROD_IP}]: ").strip() or DEFAULT_PROD_IP print("Режимы:") print(" [1] Новый сервер (первичная настройка, потребуется ввод пароля)") print(" [2] Существующий сервер (обновление настроек через deploy_user, без пароля)") mode = input("Выберите режим [1/2]: ").strip() if mode == '1': admin_user = input("Логин администратора (например, sysadmin): ").strip() if not admin_user: print_error("Логин обязателен!") ssh_user = admin_user need_password_prompt = True print_info(f"Очистка старых ключей SSH для {host} (на случай переустановки сервера)...") subprocess.run(["ssh-keygen", "-R", host], capture_output=True) else: ssh_user = "deploy_user" need_password_prompt = False ssh_dir = Path.home() / ".ssh" ssh_dir.mkdir(parents=True, exist_ok=True) key_path = ssh_dir / "id_ed25519" pub_key_path = ssh_dir / "id_ed25519.pub" if not pub_key_path.exists(): key_path_rsa = ssh_dir / "id_rsa.pub" if key_path_rsa.exists(): pub_key_path = key_path_rsa else: print_info("Локальный SSH-ключ не найден. Генерируем новый (Ed25519)...") subprocess.run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)], check=True) print_success("SSH-ключ успешно сгенерирован.") pub_key_content = pub_key_path.read_text(encoding='utf-8').strip() bootstrap_script = f"""#!/bin/bash set -e echo "--- Настройка локалей (устранение предупреждений perl) ---" locale-gen en_US.UTF-8 ru_RU.UTF-8 >/dev/null 2>&1 || true update-locale >/dev/null 2>&1 || true echo "--- Обновление системы ---" apt-get update && apt-get upgrade -y echo "--- Установка Docker ---" if ! command -v docker &> /dev/null; then curl -fsSL https://get.docker.com -o get-docker.sh sh get-docker.sh else echo "Docker уже установлен." fi echo "--- Настройка UFW для Docker (разрешение SSH для CI/CD раннеров) ---" if command -v ufw &> /dev/null; then ufw allow from 172.16.0.0/12 to any port 22 >/dev/null 2>&1 || true ufw allow from 192.168.0.0/16 to any port 22 >/dev/null 2>&1 || true echo "UFW правила для локальных сетей Docker добавлены." else echo "UFW не установлен, пропускаем настройку файрвола." fi echo "--- Создание www_user ---" if ! id "www_user" &>/dev/null; then useradd -M -s /usr/sbin/nologin www_user echo "Пользователь www_user создан." fi echo "--- Создание deploy_user ---" if ! id "deploy_user" &>/dev/null; then useradd -m -s /bin/bash deploy_user usermod -aG sudo,docker deploy_user echo "deploy_user ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/99_deploy_user echo "Пользователь deploy_user создан." fi echo "--- Установка SSH ключа ---" mkdir -p /home/deploy_user/.ssh echo "{pub_key_content}" > /home/deploy_user/.ssh/authorized_keys chown -R deploy_user:deploy_user /home/deploy_user/.ssh chmod 700 /home/deploy_user/.ssh chmod 600 /home/deploy_user/.ssh/authorized_keys echo "Ключ успешно установлен." echo "--- Установка и настройка Gitverse Act Runner ---" mkdir -p /home/deploy_user/act_runner if [ ! -f /home/deploy_user/act_runner/act_runner ]; then echo "Скачивание бинарного файла act_runner..." curl -sSL https://dl.gitea.com/act_runner/0.2.11/act_runner-0.2.11-linux-amd64 -o /home/deploy_user/act_runner/act_runner || true chmod +x /home/deploy_user/act_runner/act_runner || true fi chown -R deploy_user:deploy_user /home/deploy_user/act_runner echo "--- Создание службы systemd act_runner ---" cat << 'EOF_RUNNER' > /etc/systemd/system/act_runner.service [Unit] Description=Gitverse Act Runner Daemon After=network.target docker.service [Service] Type=simple User=deploy_user WorkingDirectory=/home/deploy_user/act_runner ExecStart=/home/deploy_user/act_runner/act_runner daemon Restart=always RestartSec=5 [Install] WantedBy=multi-user.target EOF_RUNNER systemctl daemon-reload systemctl enable act_runner >/dev/null 2>&1 || true """ local_script_path = Path("/tmp/antigravity_bootstrap.sh") local_script_path.write_text(bootstrap_script, encoding='utf-8') print_info("Копирование установочного скрипта на сервер...") if need_password_prompt: print_warning(f"Сейчас будет запрошен ПАРОЛЬ от пользователя {ssh_user} на сервере {host} (возможно, дважды).") ssh_cmd_flags = ["-t"] else: print_info(f"Подключение через {ssh_user} по SSH-ключу (пароль не требуется)...") ssh_cmd_flags = [] remote_script_path = f"/tmp/bootstrap_{ssh_user}.sh" scp_res = subprocess.run(["scp"] + get_ssh_control_args(host, ssh_user) + [str(local_script_path), f"{ssh_user}@{host}:{remote_script_path}"]) if scp_res.returncode != 0: print_error("Не удалось скопировать скрипт.") print_info("Запуск скрипта на сервере (от имени sudo)...") ssh_res = subprocess.run(["ssh"] + ssh_cmd_flags + get_ssh_control_args(host, ssh_user) + [f"{ssh_user}@{host}", f"sudo bash {remote_script_path} && sudo rm -f {remote_script_path}"]) if ssh_res.returncode == 0: print_success("СЕРВЕР УСПЕШНО НАСТРОЕН/ОБНОВЛЕН!") cfg['prod_host'] = host cfg['prod_user'] = "deploy_user" CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding='utf-8') setup_gitverse_runner(host, "deploy_user") else: print_error("Ошибка при выполнении bootstrap-скрипта.") def setup_gitverse_runner(host, user): print_header("Настройка и привязка Gitverse Act Runner") code, stdout, _ = ssh_run_cmd(host, user, "sudo systemctl is-active act_runner 2>/dev/null") if code == 0 and "active" in stdout.strip(): print_success(f"Служба act_runner уже активна и работает на сервере {host}!") ans_re = input("Перезарегистрировать раннер с новым токеном от Gitverse? [y/N]: ").strip().lower() if ans_re not in ('y', 'yes', 'д'): return True setup_script = r"""#!/bin/bash set -e if [ -f /opt/act_runner/act_runner ]; then RUNNER_DIR="/opt/act_runner" elif [ -f /home/deploy_user/act_runner/act_runner ]; then RUNNER_DIR="/home/deploy_user/act_runner" else RUNNER_DIR="/opt/act_runner" mkdir -p "$RUNNER_DIR" echo "Скачивание бинарного файла act_runner в $RUNNER_DIR..." curl -sSL https://dl.gitea.com/act_runner/0.2.11/act_runner-0.2.11-linux-amd64 -o "$RUNNER_DIR/act_runner" || true chmod +x "$RUNNER_DIR/act_runner" || true fi cd "$RUNNER_DIR" # Генерируем конфигурационный файл config.yaml по стандарту Gitverse if [ ! -f config.yaml ]; then echo "Генерация файла конфигурации config.yaml..." ./act_runner generate-config > config.yaml || true fi chown -R deploy_user:deploy_user "$RUNNER_DIR" cat << EOF_RUNNER > /etc/systemd/system/act_runner.service [Unit] Description=GitVerse Runner Service Documentation=https://gitverse.ru/docs/cicd/docs/runners/ After=docker.service network.target [Service] ExecStart=$RUNNER_DIR/act_runner daemon --config $RUNNER_DIR/config.yaml ExecReload=/bin/kill -s HUP \$MAINPID WorkingDirectory=$RUNNER_DIR TimeoutSec=0 RestartSec=10 Restart=always User=deploy_user [Install] WantedBy=multi-user.target EOF_RUNNER systemctl daemon-reload """ local_script_path = Path("/tmp/setup_runner.sh") local_script_path.write_text(setup_script, encoding='utf-8') remote_script_path = "/tmp/setup_runner.sh" subprocess.run(["scp"] + get_ssh_control_args(host, user) + [str(local_script_path), f"{user}@{host}:{remote_script_path}"], capture_output=True) code_setup, _, _ = ssh_run_cmd(host, user, f"sudo bash {remote_script_path} && sudo rm -f {remote_script_path}") if code_setup != 0: print_warning("Замечание при развертывании файлов службы act_runner на сервере.") # Определяем реальный каталог раннера на сервере _, stdout_dir, _ = ssh_run_cmd(host, user, "if [ -f /opt/act_runner/act_runner ]; then echo /opt/act_runner; else echo /home/deploy_user/act_runner; fi") runner_dir = stdout_dir.strip() or "/opt/act_runner" print_info(f"\nОбнаружена папка раннера: {runner_dir}") print_info("Инструкция по получению единого токена Организации:") print(" 1. Перейдите в Gitverse в настройки Организации atsky:") print(" https://gitverse.ru/org/atsky/settings/runners (или Настройки репозитория -> Actions -> Runners)") print(" 2. Нажмите 'Добавить раннер' (New Runner) и скопируйте предоставленный Токен.") print(" 3. Использование токена Организации позволяет 1 раннеру обслуживать ВСЕ проекты (shared_services, tender_helper и др.).") runner_token = input("\nВведите Registration Token от Gitverse (или нажмите Enter чтобы отложить): ").strip() if not runner_token: print_warning("Токен не введен. Конфигурация и служба созданы, привязка отложена.") return False runner_name = input("Имя раннера [atsky-prod-runner]: ").strip() or "atsky-prod-runner" # Pipe y\n to automatically answer any overwrite prompts if runner already exists on Gitverse reg_cmd = ( f"cd {runner_dir} && " "rm -f .runner && " f"printf 'y\\ny\\n' | ./act_runner --config config.yaml register --instance https://gitverse.ru --token {runner_token} --no-interactive --name {runner_name} --labels self-hosted,ubuntu-latest && " "sudo systemctl restart act_runner" ) code_reg, stdout_reg, err_reg = ssh_run_cmd(host, user, reg_cmd) if code_reg == 0: print_success(f"Раннер '{runner_name}' успешно зарегистрирован на Gitverse с файлом config.yaml и запущен в systemd!") return True else: print_warning(f"Результат регистрации раннера: {err_reg or stdout_reg}") return False # --- АНАЛИЗ СИСТЕМЫ И МОНИТОРИНГ --- def check_system_requirements(host, user): print_info(f"Анализ системных ресурсов на PROD ({host})...") code, stdout, _ = ssh_run_cmd(host, user, f"df -h {PROD_BASE_DIR}") if code == 0: lines = stdout.strip().split('\n') if len(lines) > 1: print_info(f"Дисковое пространство ({PROD_BASE_DIR}):") print(" " + lines[0]) print(" " + lines[1]) parts = lines[1].split() if len(parts) >= 5: percent_str = parts[-2].replace('%', '') if percent_str.isdigit() and int(percent_str) > 85: print_warning("ВНИМАНИЕ: На диске осталось мало свободного места!") code, stdout, _ = ssh_run_cmd(host, user, "free -m") if code == 0: lines = stdout.strip().split('\n') if len(lines) > 1: print_info("Оперативная память (RAM):") print(" " + lines[0]) print(" " + lines[1]) parts = lines[1].split() if len(parts) >= 7: available = int(parts[-1]) if available < 512: print_warning(f"ВНИМАНИЕ: Мало свободной памяти ({available} MB)! Риск OOM.") code, stdout, _ = ssh_run_cmd(host, user, "uptime") if code == 0: print_info(f"Load Average: {stdout.strip()}") print_info("Топ самых тяжелых Docker-контейнеров на сервере:") code, stdout, _ = ssh_run_cmd(host, user, "sudo docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}' | sort -k 2 -h -r | head -n 10") if code == 0 and stdout: for line in stdout.strip().split('\n'): print(" " + line) else: print_warning("Не удалось получить статистику docker stats. Убедитесь что Docker установлен.") def install_monitoring_stack(host, user): ans = input("\nУстановить/Обновить стек мониторинга (Prometheus+Grafana+Дашборд админок) на PROD? [y/N] (По умолчанию N): ").strip().lower() if ans not in ('y', 'yes', 'д'): return print_info("Генерация файлов мониторинга на PROD-сервере...") mon_dir = f"{PROD_BASE_DIR}/monitoring_stack" ssh_run_cmd(host, user, f"sudo mkdir -p {mon_dir}/nginx && sudo chown -R {user}:{user} {mon_dir}") compose_content = """version: '3.8' services: prometheus: image: prom/prometheus:latest volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml command: - '--config.file=/etc/prometheus/prometheus.yml' ports: - '9090:9090' node_exporter: image: quay.io/prometheus/node-exporter:latest pid: host restart: unless-stopped volumes: - '/:/host:ro,rslave' cadvisor: image: gcr.io/cadvisor/cadvisor:latest volumes: - /:/rootfs:ro - /var/run:/var/run:ro - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:ro grafana: image: grafana/grafana:latest ports: - '3000:3000' environment: - GF_SECURITY_ADMIN_PASSWORD=admin admin_dashboard: image: nginx:alpine volumes: - ./nginx/index.html:/usr/share/nginx/html/index.html ports: - '8080:80' """ prom_content = """global: scrape_interval: 15s scrape_configs: - job_name: 'node' static_configs: - targets: ['node_exporter:9100'] - job_name: 'cadvisor' static_configs: - targets: ['cadvisor:8080'] """ html_content = """<!DOCTYPE html><html><head><meta charset="UTF-8"><title>ATSky Admin Dashboard</title><style>body{font-family:sans-serif;background:#1e1e1e;color:#fff;padding:2rem;}a{color:#00ffcc;text-decoration:none;display:block;margin:10px 0;font-size:1.2rem;}</style></head><body> <h1>Внутренний Дашборд Администратора</h1> <a href="http://192.168.1.34:3000" target="_blank">📊 Grafana (Мониторинг Ресурсов)</a> <a href="http://192.168.1.34:9090" target="_blank">⚙️ Prometheus</a> <!-- Сюда можно добавлять ссылки на админки Serverpod Insights и Qdrant, указывая их PROD порты --> </body></html> """ ssh_run_cmd(host, user, f"cat << 'EOF' > {mon_dir}/docker-compose.yml\n{compose_content}EOF") ssh_run_cmd(host, user, f"cat << 'EOF' > {mon_dir}/prometheus.yml\n{prom_content}EOF") ssh_run_cmd(host, user, f"cat << 'EOF' > {mon_dir}/nginx/index.html\n{html_content}EOF") print_info("Запуск стека мониторинга...") code, stdout, stderr = ssh_run_cmd(host, user, f"cd {mon_dir} && docker compose up -d") if code == 0: print_success(f"Мониторинг запущен! Дашборд доступен внутри сети по адресу: http://{host}:8080") print_info("Логин от Grafana: admin / admin (порт 3000)") else: print_error(f"Ошибка запуска мониторинга: {stderr}") def update_admin_dashboard(prod_host, prod_user, project_name, base_port, stack): mon_dir = f"{PROD_BASE_DIR}/monitoring_stack" index_file = f"{mon_dir}/nginx/index.html" code, _, _ = ssh_run_cmd(prod_host, prod_user, f"ls {index_file}") if code != 0: return links = "" if stack == "FLUTTER_SERVERPOD": links += f'<li><a href="http://{prod_host}:{base_port+2}" target="_blank">Serverpod Insights (Admin)</a></li>' links += f'<li><a href="http://{prod_host}:{base_port+1}" target="_blank">Serverpod API</a></li>' if not links: return html_block = f'''<details style="margin:15px 0; background:#2a2a2a; padding:10px; border-radius:5px;"> <summary style="font-size:1.2rem; font-weight:bold; cursor:pointer;">📦 Проект: {project_name}</summary> <ul style="list-style:none; padding-left:10px; margin-top:10px;">{links}</ul> </details>''' html_escaped = html_block.replace("'", "'\\''") insert_cmd = f"sed -i '/<\\/body>/i {html_escaped}' {index_file}" code, _, err = ssh_run_cmd(prod_host, prod_user, insert_cmd) if code == 0: print_success(f"Проект {project_name} добавлен на Дашборд Администратора.") else: print_warning(f"Не удалось обновить Дашборд: {err}") # --- ОПРЕДЕЛЕНИЕ СТЕКА --- def detect_stack(project_path): print_info("Анализ проекта для определения стека технологий...") is_flutter, is_serverpod, is_python = False, False, False search_paths = [project_path] try: search_paths.extend([d for d in project_path.iterdir() if d.is_dir()]) except PermissionError: pass for d in search_paths: if (d / "pubspec.yaml").exists(): is_flutter = True content = (d / "pubspec.yaml").read_text(encoding='utf-8') if 'serverpod' in content: is_serverpod = True if any((d / f).exists() for f in ["requirements.txt", "pyproject.toml", "Pipfile", "main.py"]): is_python = True if is_serverpod and is_flutter: return "FLUTTER_SERVERPOD" elif is_python and is_flutter: return "FLUTTER_PYTHON" elif is_python: return "PYTHON" elif is_flutter: return "FLUTTER" else: return "UNKNOWN" def check_dockerfile_security(project_path): print_info("Проверка безопасности Dockerfile (анализ на запуск от root)...") dockerfiles = list(project_path.glob("Dockerfile*")) if not dockerfiles: return is_safe = False for df in dockerfiles: try: content = df.read_text(encoding='utf-8') if re.search(r'^\s*USER\s+[a-zA-Z0-9_:-]+', content, re.IGNORECASE | re.MULTILINE): is_safe = True break except Exception: pass if is_safe: print_success("Анализ Dockerfile: Процессы внутри контейнера будут работать БЕЗ root-прав. Отлично!") else: print_warning("Анализ Dockerfile: Директива USER не найдена!") print_warning("Контейнер будет запущен от имени ROOT, что крайне небезопасно для PROD-среды.") print_warning("Настоятельно рекомендуется добавить создание пользователя и 'USER www_user' в ваш Dockerfile.") # --- ПОРТЫ --- def find_existing_port(content_str, project_name): safe_name = re.escape(project_name) match = re.search(rf'^{safe_name}:\s*\n\s*-\s*"(\d{{4,5}})\s*-', content_str, re.MULTILINE) if match: return int(match.group(1)) return None def find_next_available_port(content_str): max_port = 8000 for line in content_str.splitlines(): match = re.search(r'-\s*"(\d{4,5})\s*-', line) if match: port = int(match.group(1)) if 8000 <= port < 10000 and port > max_port: max_port = port return ((max_port // 10) + 1) * 10 def manage_prod_ports(prod_host, prod_user, project_name, base_port, stack): code, stdout, _ = ssh_run_cmd(prod_host, prod_user, f"cat {PROD_PORTS_FILE}") if code != 0: ssh_run_cmd(prod_host, prod_user, f"sudo touch {PROD_PORTS_FILE} && sudo chmod 666 {PROD_PORTS_FILE}") prod_content = "" else: prod_content = stdout existing_prod_port = find_existing_port(prod_content, project_name) if existing_prod_port: print_info(f"Проект {project_name} уже зарегистрирован на PROD (порт {existing_prod_port}). Переиспользуем.") base_port = existing_prod_port already_on_prod = True else: prod_next_port = find_next_available_port(prod_content) if prod_next_port > base_port: base_port = prod_next_port already_on_prod = False lines_to_add = [f"{project_name}:"] if stack == "FLUTTER_SERVERPOD": lines_to_add.extend([ f' - "{base_port} - Web App (Main Proxy Target)"', f' - "{base_port + 1} - Serverpod API"', f' - "{base_port + 2} - Serverpod Insights (Admin)"', f' - "{base_port + 3} - PostgreSQL"', f' - "{base_port + 4} - Redis"', f' - "{base_port + 5} - Spare Port 1"', f' - "{base_port + 6} - Spare Port 2"', f' - "{base_port + 7} - Spare Port 3"' ]) else: lines_to_add.extend([ f' - "{base_port} - Web App"', f' - "{base_port + 1} - Database"', f' - "{base_port + 2} - Spare Port 1"', f' - "{base_port + 3} - Spare Port 2"', f' - "{base_port + 4} - Spare Port 3"' ]) if not already_on_prod: new_content = "\n" + "\n".join(lines_to_add) + "\n" append_cmd = f"sudo echo '{new_content}' >> {PROD_PORTS_FILE}" if ssh_run_cmd(prod_host, prod_user, append_cmd)[0] == 0: print_success(f"Выделен пул портов на PROD начиная с {base_port}.") return base_port, lines_to_add # --- NPM --- def npm_request(url, method, token=None, data=None): headers = {'Content-Type': 'application/json'} if token: headers['Authorization'] = f'Bearer {token}' req_data = json.dumps(data).encode('utf-8') if data else None req = urllib.request.Request(url, data=req_data, headers=headers, method=method) with urllib.request.urlopen(req) as resp: return json.loads(resp.read().decode('utf-8')) def setup_npm(npm_url, email, password, domain, forward_ip, base_port, stack): print_info("Авторизация в NPM API...") url_base = npm_url.rstrip('/') try: auth_resp = npm_request(f"{url_base}/api/tokens", 'POST', data={"identity": email, "secret": password}) token = auth_resp.get('token') except Exception as e: print_error(f"Ошибка авторизации NPM: {e}") locations = [] if stack == "FLUTTER_SERVERPOD": locations.append({ "path": "/api/", "forward_scheme": "http", "forward_host": forward_ip, "forward_port": base_port + 1, "advanced_config": "" }) payload = { "domain_names": [domain], "forward_scheme": "http", "forward_host": forward_ip, "forward_port": base_port, "access_list_id": "0", "certificate_id": "new", "meta": {"letsencrypt_email": email, "letsencrypt_agree": True}, "advanced_config": "", "locations": locations, "block_exploits": True, "caching_enabled": False, "allow_websocket_upgrade": True, "http2_support": True, "ssl_forced": True } try: hosts = npm_request(f"{url_base}/api/nginx/proxy-hosts", 'GET', token=token) existing_host = next((h for h in hosts if domain in h.get('domain_names', [])), None) if existing_host: host_id = existing_host['id'] print_info(f"Домен {domain} уже существует (ID {host_id}). Выполняется обновление (PUT)...") payload['certificate_id'] = existing_host.get('certificate_id', 0) npm_request(f"{url_base}/api/nginx/proxy-hosts/{host_id}", 'PUT', token=token, data=payload) print_success(f"NPM Proxy Host успешно ОБНОВЛЕН для {domain}") else: print_info(f"Создание нового Proxy Host для {domain} (POST)...") npm_request(f"{url_base}/api/nginx/proxy-hosts", 'POST', token=token, data=payload) print_success(f"NPM Proxy Host успешно СОЗДАН для {domain}") except Exception as e: print_error(f"Ошибка конфигурации Proxy Host: {e}") # --- ГЕНЕРАЦИЯ ФАЙЛОВ --- def generate_files(project_path, prod_host, prod_user, project_name, base_port, stack, gitverse_user, runner_label, sys_cmd=None): workflows_dir = project_path / ".gitverse" / "workflows" workflows_dir.mkdir(parents=True, exist_ok=True) prod_path = f"{PROD_BASE_DIR}/{project_name}" image_uri = f"gitverse.ru/{gitverse_user}/{project_name}:latest" # Динамический поиск относительного пути к Dockerfile dockerfile_rel = "Dockerfile" if (project_path / "Dockerfile.multi-stage").exists(): dockerfile_rel = "Dockerfile.multi-stage" elif (project_path / "Dockerfile").exists(): dockerfile_rel = "Dockerfile" else: found_dfs = list(project_path.glob("**/Dockerfile*")) valid_dfs = [f for f in found_dfs if not any(part in f.parts for part in ['build', 'node_modules', '.git', '.dart_tool', 'dist'])] if valid_dfs: dockerfile_rel = str(valid_dfs[0].relative_to(project_path)) prod_yml = workflows_dir / "deploy-prod.yml" prod_content = f"""name: Deploy to Production on: push: branches: [ "production" ] workflow_dispatch: jobs: deploy: runs-on: {runner_label} steps: - name: Checkout code uses: actions/checkout@v4 - name: Log in to Gitverse Registry uses: docker/login-action@v3 with: registry: gitverse.ru username: ${{{{ github.repository_owner }}}} password: ${{{{ secrets.REGISTRY_TOKEN }}}} - name: Build and push Docker Image uses: docker/build-push-action@v5 with: context: . file: {dockerfile_rel} push: true tags: {image_uri} - name: Deploy via SSH uses: appleboy/ssh-action@master with: host: {prod_host} username: ${{{{ secrets.PROD_USER }}}} key: ${{{{ secrets.PROD_SSH_KEY }}}} script: | sudo mkdir -p {prod_path} sudo chown -R deploy_user:deploy_user {prod_path} cd {prod_path} echo "${{{{ secrets.PROD_ENV_FILE }}}}" > .env if ! grep -q "^COMPOSE_PROJECT_NAME=" .env; then echo "COMPOSE_PROJECT_NAME={project_name}" >> .env fi # Автоматический логин в Gitverse Registry на PROD-сервере echo "${{{{ secrets.REGISTRY_TOKEN }}}}" | docker login gitverse.ru -u "${{{{ github.repository_owner }}}}" --password-stdin docker network create shared_services_network || true docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -d docker image prune -af """ if stack == "SYSTEM_TOOL": prod_content = f"""name: Deploy System Tool on: push: branches: [ "production", "main", "master" ] jobs: deploy: runs-on: {runner_label} steps: - name: Checkout code uses: actions/checkout@v4 - name: Copy files to server run: | mkdir -p ~/.ssh echo "${{{{ secrets.PROD_SSH_KEY }}}}" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519 {prod_user}@{prod_host} "sudo mkdir -p {prod_path} && sudo chown -R {prod_user}:{prod_user} {prod_path}" rsync -avz -e "ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519" --exclude '.git' ./ {prod_user}@{prod_host}:{prod_path}/ - name: Set permissions run: | ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519 {prod_user}@{prod_host} "sudo chown -R deploy_user:deploy_user {prod_path} && sudo chmod +x {prod_path}/*.sh 2>/dev/null || true && sudo chmod +x {prod_path}/* 2>/dev/null || true" """ if sys_cmd and sys_cmd.get("install_cmd"): prod_content += f""" - name: Run installation script run: | ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519 {prod_user}@{prod_host} "cd {prod_path} && sudo {sys_cmd['install_cmd']}" """ if sys_cmd and sys_cmd.get("cmd_name"): sudo_str = "sudo " if sys_cmd["use_sudo"] else "" prod_content += f""" - name: Setup System Alias run: | echo '#!/bin/bash' > {sys_cmd["cmd_name"]} echo 'exec {sudo_str}{prod_path}/{sys_cmd["exec_file"]} "$@"' >> {sys_cmd["cmd_name"]} scp -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519 {sys_cmd["cmd_name"]} {prod_user}@{prod_host}:/tmp/{sys_cmd["cmd_name"]} ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519 {prod_user}@{prod_host} "sudo mv /tmp/{sys_cmd['cmd_name']} /usr/local/bin/{sys_cmd['cmd_name']} && sudo chmod +x /usr/local/bin/{sys_cmd['cmd_name']}" """ prod_yml.write_text(prod_content, encoding='utf-8') if stack != "SYSTEM_TOOL": compose = project_path / "docker-compose.prod.yml" if stack == "FLUTTER_SERVERPOD": ports_mapping = f""" ports: - "{base_port}:80" # Web (Nginx) - "{base_port+1}:8080" # API - "{base_port+2}:8081" # Insights - "{base_port+3}:5432" # PostgreSQL""" else: ports_mapping = f""" ports: - "{base_port}:80"\n""" compose_content = f"""version: '3.8' services: {project_name}: image: {image_uri} restart: always env_file: - .env {ports_mapping} networks: - shared_services_network networks: shared_services_network: external: true """ compose.write_text(compose_content, encoding='utf-8') print_success(f"Созданы шаблоны Gitverse CI/CD и Docker Compose в директории проекта.") else: print_success(f"Создан шаблон Gitverse CI/CD для системной утилиты.") if stack == "SYSTEM_TOOL": secrets_count = "3" extra_secrets = "" else: secrets_count = "5" extra_secrets = """ 4. **REGISTRY_TOKEN** Значение: (Ваш Personal Access Token, созданный в настройках вашего профиля Gitverse с правами write:packages) 5. **PROD_ENV_FILE** Значение: (Полное содержимое боевого файла `.env` для PROD-среды на сервере. Включите туда все необходимые переменные окружения, такие как `PROJECT_RUN_MODE=PROD`, пароли к БД, API-ключи и т.д.) """ instructions_file = project_path / ".gitverse" / "SECRETS_INSTRUCTION.md" instructions_content = f"""# Инструкция по настройке Gitverse CI/CD для {project_name} Чтобы авто-деплой заработал, перейдите в ваш репозиторий на gitverse.ru (Настройки -> Секреты / Secrets) и добавьте {secrets_count} секретов: 1. **PROD_USER** Значение: `{prod_user}` 2. **PROD_HOST** Значение: `{prod_host}` 3. **PROD_SSH_KEY** Значение: (Скопируйте всё содержимое вашего локального приватного ключа `~/.ssh/id_ed25519` на вашей машине разработчика, включая блоки BEGIN и END) {extra_secrets} После добавления секретов просто сделайте `git commit` и `git push`! """ instructions_file.write_text(instructions_content, encoding='utf-8') print_success(f"Памятка по секретам сохранена в файл: {instructions_file}") # --- ФАЗЫ МАСТЕРА --- def select_executable_file(project_path, purpose="alias"): candidates = [] for f in project_path.iterdir(): if f.is_file(): if os.access(f, os.X_OK) or f.name.endswith(('.sh', '.py', '.pl', '.rb')): candidates.append(f.name) exec_file = "" if candidates: if purpose == "alias": print("\nНайдены возможные исполняемые файлы (если нужного файла здесь еще нет, выберите 'Ввести вручную'):") else: print("\nНайдены возможные скрипты (для установки/сборки):") for i, cand in enumerate(candidates, 1): print(f" [{i}] {cand}") print(f" [{len(candidates)+1}] Ввести вручную") c_idx = input(f"Выберите файл [1-{len(candidates)+1}] (По умолчанию 1): ").strip() or "1" try: c_idx = int(c_idx) if 1 <= c_idx <= len(candidates): exec_file = candidates[c_idx - 1] except ValueError: pass if not exec_file: if purpose == "alias": exec_file = input("Имя исполняемого файла в проекте (например, bin/main.sh): ").strip() or "main.sh" else: exec_file = input("Команда или скрипт установки (например, 'make install' или './setup.sh'): ").strip() or "./setup.sh" else: if purpose == "install" and " " not in exec_file and not exec_file.startswith(("./", "/")): exec_file = f"./{exec_file}" return exec_file def configure_project(cfg): project_path, project_name = select_project(cfg) stack = detect_stack(project_path) print_success(f"Определен стек: {stack}") ans = input(f"Изменить тип развертывания на 'Системная утилита' (без Docker, простое копирование)? [y/N]: ").strip().lower() sys_cmd = None if ans in ('y', 'yes', 'д'): stack = "SYSTEM_TOOL" print_success("Установлен стек: SYSTEM_TOOL") ans_inst = input("Выполнить скрипт/команду сборки или установки после деплоя (например, 'make install' или './setup.sh')? [y/N] (По умолчанию N): ").strip().lower() install_cmd = None if ans_inst in ('y', 'yes', 'д'): install_cmd = select_executable_file(project_path, purpose="install") ans_cmd = input("Создать глобальную команду (alias) для запуска утилиты из любого каталога сервера? [y/N] (По умолчанию N): ").strip().lower() if ans_cmd in ('y', 'yes', 'д'): cmd_name = input(f"Имя команды (например, {project_name}): ").strip() or project_name exec_file = select_executable_file(project_path, purpose="alias") use_sudo = input("Запускать всегда с правами sudo? [y/N]: ").strip().lower() in ('y', 'yes', 'д') sys_cmd = {"cmd_name": cmd_name, "exec_file": exec_file, "use_sudo": use_sudo, "install_cmd": install_cmd} else: if install_cmd: sys_cmd = {"install_cmd": install_cmd} if stack != "SYSTEM_TOOL": check_dockerfile_security(project_path) print_header("Настройки CI/CD и Сервера") gitverse_user = input(f"Логин/Организация на Gitverse [{cfg.get('gitverse_username', 'atsky')}]: ").strip() or cfg.get('gitverse_username', 'atsky') prod_host = input(f"IP/Domain PROD сервера [{cfg.get('prod_host', DEFAULT_PROD_IP)}]: ").strip() or cfg.get('prod_host', DEFAULT_PROD_IP) prod_user = input(f"SSH Пользователь PROD сервера [{cfg.get('prod_user', 'deploy_user')}]: ").strip() or cfg.get('prod_user', 'deploy_user') ans_runner = input(f"Использовать локальный self-hosted раннер (для защиты от перехвата облаком Gitverse)? [Y/n] (По умолчанию Y): ").strip().lower() cfg['runner_label'] = 'ubuntu-latest' if ans_runner in ('n', 'no', 'нет') else 'self-hosted' if cfg.get('runner_label') == 'self-hosted': code, stdout, _ = ssh_run_cmd(prod_host, prod_user, "sudo systemctl is-active act_runner 2>/dev/null") if code != 0 or "active" not in stdout.strip(): print_warning(f"Раннер Gitverse 'act_runner' еще не активен на сервере {prod_host}.") ans_r = input("Настроить и зарегистрировать Gitverse Act Runner прямо сейчас? [Y/n]: ").strip().lower() if ans_r in ('', 'y', 'yes', 'д'): setup_gitverse_runner(prod_host, prod_user) print("\n[РЕЖИМ ДОСТУПА К ПРОЕКТУ]") npm_enable = False if stack != "SYSTEM_TOOL": npm_enable = input("Сделать проект доступным из интернета? (Настроить публичный домен через Nginx Proxy Manager) [Y/n]: ").strip().lower() in ('', 'y', 'yes', 'д') if npm_enable: npm_url = input(f"NPM API URL [{cfg.get('npm_url', DEFAULT_NPM_URL)}]: ").strip() or cfg.get('npm_url', DEFAULT_NPM_URL) npm_email = input(f"NPM Email [{cfg.get('npm_email', 'admin@atsky.ru')}]: ").strip() or cfg.get('npm_email', 'admin@atsky.ru') npm_password = getpass.getpass("NPM Пароль (не сохраняется): ") domain = input("Домен для проекта (например, app.atsky.ru): ").strip() else: print_info("Проект будет развернут ТОЛЬКО для локальных запросов (доступен другим проектам по IP сервера и выделенному порту).") if npm_enable: cfg.update({'npm_url': npm_url, 'npm_email': npm_email}) else: print_info("Режим доступа: Системная утилита (доступ по портам и домену не требуется).") cfg.update({'gitverse_username': gitverse_user, 'prod_host': prod_host, 'prod_user': prod_user}) CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding='utf-8') print_info("Инициализация SSH-сессии и авторизация локального ключа...") ensure_ssh_key_installed(prod_host, prod_user) print_info("Проверка статуса проекта на сервере...") code, _, _ = ssh_run_cmd(prod_host, prod_user, f"test -d {PROD_BASE_DIR}/{project_name}") if code == 0: print_warning(f"ВНИМАНИЕ: Проект '{project_name}' УЖЕ развернут на сервере ({PROD_BASE_DIR}/{project_name}).") ans = input("Что делаем? [1] Пересоздать конфигурацию заново, [2] Отменить настройку: ").strip() if ans != '1': print_info("Операция отменена пользователем.") sys.exit(0) print_header("Системный аудит и Мониторинг") check_system_requirements(prod_host, prod_user) install_monitoring_stack(prod_host, prod_user) final_port = 0 if stack != "SYSTEM_TOOL": print_header("Выделение пула портов (N+3)") local_content = Path(LOCAL_PORTS_FILE).read_text(encoding='utf-8') if os.path.exists(LOCAL_PORTS_FILE) else "" existing_local_port = find_existing_port(local_content, project_name) if existing_local_port: print_info(f"Проект {project_name} уже зарегистрирован локально (порт {existing_local_port}). Переиспользуем.") local_base_port = existing_local_port is_new_local = False else: local_base_port = find_next_available_port(local_content) is_new_local = True ans = input("Провести синхронизацию портов с PROD сервером? [Y/n]: ").strip().lower() if ans in ('', 'y', 'yes', 'д'): final_port, lines_to_add = manage_prod_ports(prod_host, prod_user, project_name, local_base_port, stack) if is_new_local and lines_to_add and os.path.exists(LOCAL_PORTS_FILE): with open(LOCAL_PORTS_FILE, 'a', encoding='utf-8') as f: f.write("\n" + "\n".join(lines_to_add) + "\n") else: final_port = local_base_port print_header("Генерация файлов пайплайна") generate_files(project_path, prod_host, prod_user, project_name, final_port, stack, gitverse_user, cfg.get('runner_label', 'ubuntu-latest'), sys_cmd) if npm_enable: print_header("Настройка NPM") setup_npm(npm_url, npm_email, npm_password, domain, prod_host, final_port, stack) update_admin_dashboard(prod_host, prod_user, project_name, final_port, stack) print_header("ГОТОВО! ЧТО ДЕЛАТЬ ДАЛЬШЕ (ОБЯЗАТЕЛЬНО):") print_warning("Ваш CI/CD пайплайн для Gitverse успешно сгенерирован!") print("Чтобы авто-деплой заработал, посмотрите инструкцию, которая была сохранена в файл:") print(f" -> {project_path}/.gitverse/SECRETS_INSTRUCTION.md") def deploy_project(cfg): project_path, project_name = select_project(cfg) prod_host = cfg.get('prod_host') prod_user = cfg.get('prod_user') gitverse_user = cfg.get('gitverse_username') stack = detect_stack(project_path) if not prod_host or not prod_user or not gitverse_user: print_error("Конфигурация сервера не найдена в профиле. Сначала выполните настройку (Configure).") print_header("Ручной Деплой (Pull & Restart)") print_info(f"Подключение к {prod_host} для деплоя проекта {project_name}...") ensure_ssh_key_installed(prod_host, prod_user) code, _, _ = ssh_run_cmd(prod_host, prod_user, f"test -d {PROD_BASE_DIR}/{project_name}") if code != 0: print_error(f"Проект не найден на сервере. Возможно, вы еще не настроили его (Configure).") print_info(f"Отправка команды серверу на скачивание актуального образа {project_name} и перезапуск...") if (project_path / ".gitverse" / "workflows" / "deploy-prod.yml").exists() and "Deploy System Tool" in (project_path / ".gitverse" / "workflows" / "deploy-prod.yml").read_text(): print_warning("Для системных утилит ручной рестарт через docker недоступен.") ans_install = input("Выполнить скрипт/команду установки повторно на сервере? [y/N] (По умолчанию N): ").strip().lower() deploy_cmd = f"cd {PROD_BASE_DIR}/{project_name} && sudo chmod +x *.sh 2>/dev/null || true" if ans_install in ('y', 'yes', 'д'): install_cmd = select_executable_file(project_path, purpose="install") deploy_cmd += f" && sudo {install_cmd}" code, stdout, stderr = ssh_run_cmd(prod_host, prod_user, deploy_cmd) if code == 0: print_success("Команды подготовки утилиты выполнены!") if stdout: print(stdout) else: print_error(f"Ошибка:\n{stderr}") return ans_alias = input("Перейти к управлению глобальным алиасом (командой) на сервере? [y/N]: ").strip().lower() if ans_alias in ('y', 'yes', 'д'): print(" [1] Создать / Обновить алиас") print(" [2] Удалить алиас") alias_action = input("Выберите действие [1-2] (По умолчанию 1): ").strip() or "1" cmd_name = input("Имя команды (алиаса): ").strip() if alias_action == "1": exec_file = select_executable_file(project_path) use_sudo = input("Запускать всегда с правами sudo? [y/N]: ").strip().lower() in ('y', 'yes', 'д') sudo_str = "sudo " if use_sudo else "" alias_cmd = f"""sudo bash -c 'cat << "EOF" > /usr/local/bin/{cmd_name} #!/bin/bash exec {sudo_str}{PROD_BASE_DIR}/{project_name}/{exec_file} "$@" EOF' && sudo chmod +x /usr/local/bin/{cmd_name}""" code, _, err = ssh_run_cmd(prod_host, prod_user, alias_cmd) if code == 0: print_success(f"Алиас '{cmd_name}' успешно создан/обновлен!") else: print_error(f"Ошибка при создании алиаса: {err}") elif alias_action == "2": code, _, err = ssh_run_cmd(prod_host, prod_user, f"sudo rm -f /usr/local/bin/{cmd_name}") if code == 0: print_success(f"Алиас '{cmd_name}' удален.") else: print_error(f"Ошибка при удалении алиаса: {err}") return else: deploy_cmd = f"docker network create shared_services_network || true && cd {PROD_BASE_DIR}/{project_name} && docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d && docker image prune -af" code, stdout, stderr = ssh_run_cmd(prod_host, prod_user, deploy_cmd) if code == 0: print_success("Проект успешно обновлен на сервере!") print(stdout) else: print_error(f"Ошибка деплоя:\n{stderr}") def install_wizard(): print_header("Установка мастера как глобальной команды") wizard_path = os.path.abspath(__file__) target_bin = "/usr/local/bin/deploy_wizard" print_info(f"Команда: deploy_wizard") print_info(f"Путь: {target_bin} -> {wizard_path}") ans = input("\nСоздать символическую ссылку? (потребуются права sudo) [Y/n]: ").strip().lower() if ans in ('', 'y', 'yes', 'д'): code = subprocess.run(["sudo", "ln", "-sf", wizard_path, target_bin]).returncode subprocess.run(["sudo", "chmod", "+x", wizard_path]) if code == 0: print_success("Успешно! Теперь вы можете писать 'deploy_wizard' из любой папки.") else: print_error("Не удалось создать ссылку. Проверьте права sudo.") def main(): while True: try: cfg = {} if CONFIG_FILE.exists(): try: cfg = json.loads(CONFIG_FILE.read_text(encoding='utf-8')) except: pass default_choice = '2' if cfg.get('prod_host') else '1' print_header("ATSky Deploy Wizard (v5.0 Enterprise)") print_info("Подсказка: В любой момент введите '<' или 'back' чтобы отменить текущее действие и вернуться сюда.") print(" [1] Bootstrap: Базовая настройка или обновление чистого PROD-сервера") print(" [2] Configure: Подготовка проекта (Веб-сервис ИЛИ Системная утилита)") print(" [3] Deploy: Ручной деплой (перезапуск) проекта на сервере") print(" [4] Runner Setup: Настройка / Привязка Gitverse Act Runner на PROD-сервере") print(" [10] Self-Installer: Установить этот мастер как глобальную команду 'deploy_wizard'") print(" [0] Выход") choice = input(f"\nВыберите действие [0-4, 10] (По умолчанию {default_choice}): ").strip() or default_choice if choice == '1': bootstrap_server(cfg) elif choice == '2': configure_project(cfg) elif choice == '3': deploy_project(cfg) elif choice == '4': host = cfg.get('prod_host') or DEFAULT_PROD_IP user = cfg.get('prod_user') or 'deploy_user' setup_gitverse_runner(host, user) elif choice == '10': install_wizard() elif choice in ('0', 'q', 'quit', 'exit'): print("Выход.") break else: print_error("Неверный выбор.") break except ReturnToMenu: print("\n\033[93m[!] Отмена операции. Возврат в главное меню...\033[0m\n") continue except KeyboardInterrupt: print("\nВыход.") break if __name__ == "__main__": main()