# -*- coding: utf-8 -*- """ target.py — Orchestrator режима TARGET (восстановление на новом сервере) """ import os import json import re import sys from datetime import datetime from core.color import header, subheader, success, warn, error as cerror, info, step, prompt, confirm, divider from core import state from core.runner import run, exists _BACKUP_DIR_BASE = "/opt/migrate-backups" _RESTORE_DIR = "/opt/migrate-restore" def run_target_mode(): state.set_stage("INIT", mode="target") from core.fsm import FSM fsm = FSM(mode="target") fsm.resume_from("INIT") def do_preflight(): step(1, "PREFLIGHT: Оценка нового сервера") state.set_stage("TARGET_PREFLIGHT") # Ubuntu version try: out = run("lsb_release -ds", check=False).stdout.strip() except RuntimeError: out = "" if not out and exists("hostnamectl"): try: out = run("hostnamectl | grep 'Operating System'", check=False).stdout.strip() except RuntimeError: out = "" info(f"OS: {out or 'не определено'}") version_match = re.search(r'(\d+\.\d+)', out) or re.search(r'Ubuntu\s+(\d+\.\d+)', out, re.I) ubuntu_version = version_match.group(1) if version_match else None if not ubuntu_version: warn("Не удалось определить версию Ubuntu. Продолжаем на свой риск.") # root или sudo uid = os.getuid() is_root = (uid == 0) if is_root: info("Запущено от root") else: info(f"Запущено от UID={uid}. Проверяем sudo ...") try: # Команда должна использовать run, а не subprocess r = run("sudo -n true", check=False) if r.returncode == 0: info("sudo доступно без пароля") else: warn("sudo может запросить пароль") except Exception: warn("sudo может запросить пароль") state.set_stage("TARGET_PREFLIGHT_OK", ubuntu_version=ubuntu_version, is_root=is_root) def do_install(): step(2, "УСТАНОВКА ПО (Docker, Compose, nginx)") st = state.load_state() ubuntu_version = st.get("ubuntu_version") is_root = st.get("is_root", False) sudo = "" if is_root else "sudo" # Docker if not exists("docker"): info("Docker не найден — устанавливаем ...") _install_docker(ubuntu_version, sudo) else: info("Docker уже установлен") # docker compose plugin if not exists("docker-compose"): r = run("docker compose version", check=False) if r.returncode != 0: info("Docker Compose plugin не найден — устанавливаем ...") _install_compose_plugin(sudo) else: info("Docker Compose plugin найден") # nginx if not exists("nginx"): if confirm("Установить nginx", default="y"): _install_nginx(sudo) else: info("nginx уже установлен") state.set_stage("TARGET_INSTALL") def _install_docker(ubuntu_version, sudo): """Официальная установка Docker из apt repo.""" cmd = f"""set -e {sudo} apt-get update {sudo} apt-get install -y ca-certificates curl gnupg lsb-release {sudo} install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | {sudo} gpg --dearmor -o /etc/apt/keyrings/docker.gpg chmod a+r /etc/apt/keyrings/docker.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | {sudo} tee /etc/apt/sources.list.d/docker.list > /dev/null {sudo} apt-get update {sudo} apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin """ run(cmd, shell=True, check=False) def _install_compose_plugin(sudo): run(f"{sudo} apt-get install -y docker-compose-plugin", check=False) def _install_nginx(sudo): run(f"{sudo} apt-get install -y nginx", check=False) def do_backup_existing(): step(3, "BACKUP СУЩЕСТВУЮЩЕГО") st = state.load_state() is_root = st.get("is_root", False) sudo = "" if is_root else "sudo" stamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup_dir = os.path.join(_BACKUP_DIR_BASE, f"pre_restore_{stamp}") run(f"{sudo} mkdir -p {backup_dir}", check=False) # backup nginx if os.path.isdir("/etc/nginx"): run(f"{sudo} tar czf {backup_dir}/nginx_backup.tar.gz -C / etc/nginx", check=False) info("Backup /etc/nginx создан") state.set_stage("TARGET_BACKUP", backup_dir=backup_dir) def do_restore(): step(4, "ВОССТАНОВЛЕНИЕ") st = state.load_state() is_root = st.get("is_root", False) sudo = "" if is_root else "sudo" # Определяем директорию с архивом remote_dir = st.get("target_remote_dir", "/tmp/docker-migrate-incoming") if not os.path.isdir(remote_dir): remote_dir = prompt("Укажите папку с распакованным архивом (manifest + файлы)") manifest_file = None for f in os.listdir(remote_dir): if f.endswith("_manifest.json"): manifest_file = os.path.join(remote_dir, f) break if not manifest_file: raise RuntimeError("manifest.json не найден в папке архива") manifest = json.load(open(manifest_file, "r", encoding="utf-8")) svc_name = manifest["service"]["name"] # Создаём рабочую директорию restore restore_work = os.path.join(_RESTORE_DIR, svc_name, datetime.now().strftime("%Y%m%d_%H%M%S")) run(f"{sudo} mkdir -p {restore_work}", check=False) state.set_stage("TARGET_RESTORE", restore_work=restore_work, manifest_path=manifest_file) # Копируем файлы откуда разложено (из tmp remote_dir) _restore_docker_files(manifest, remote_dir, sudo) _restore_nginx(manifest, remote_dir, sudo) _restore_systemd(manifest, remote_dir, sudo) _restore_cron(manifest, sudo) _restore_sidecar_configs(manifest, remote_dir, sudo) _restore_network_notes(manifest, restore_work) success("Файлы восстановлены") def _restore_docker_files(manifest, remote_dir, sudo): cf = manifest["docker"].get("compose_file") if cf: dest = cf if dest.startswith("/"): run(f"{sudo} mkdir -p {os.path.dirname(dest)}", check=False) src = os.path.join(remote_dir, cf.lstrip("/")) if os.path.isfile(src): run(f"{sudo} cp {src} {dest}", check=False) info(f"Восстановлен compose: {dest}") else: warn(f"Compose в архиве не найден по пути: {src}") ef = manifest["docker"].get("env_file") if ef: dest = ef if dest.startswith("/"): run(f"{sudo} mkdir -p {os.path.dirname(dest)}", check=False) src = os.path.join(remote_dir, ef.lstrip("/")) if os.path.isfile(src): run(f"{sudo} cp {src} {dest}", check=False) info(f"Восстановлен .env: {dest}") else: warn(f".env в архиве не найден по пути: {src}") # Bind mounts for m in manifest["docker"].get("mounts", []): if m["type"] == "bind": dest = m["destination"] src_host = m["source"] if dest.startswith("/"): run(f"{sudo} mkdir -p {os.path.dirname(dest)}", check=False) src = os.path.join(remote_dir, src_host.lstrip("/")) if os.path.isfile(src): run(f"{sudo} cp {src} {dest}", check=False) info(f"Восстановлен mount: {dest}") elif os.path.isdir(src): run(f"{sudo} mkdir -p {dest}", check=False) run(f"{sudo} cp -a {src}/* {dest}/", check=False) info(f"Восстановлен dir mount: {dest}") else: warn(f"Bind mount в архиве не найден: {src}") # Named volumes (разворачиваем из tar.gz в remote_dir) for m in manifest["docker"].get("mounts", []): if m["type"] == "volume": vol_name = m["source"] found = False for f in os.listdir(remote_dir): if f.startswith("vol_") and vol_name in f and f.endswith(".tar.gz"): info(f"Разворачиваем Docker volume: {vol_name} из {f}") run(f"docker volume create {vol_name}", check=False) run(f"docker run --rm -v {vol_name}:/data -v {remote_dir}:/in alpine sh -c 'cd /data \u0026\u0026 tar xzf /in/{f}'", check=False) found = True break if not found: warn(f"Volume {vol_name} не найден в архиве — будет создан пустым") def _restore_nginx(manifest, remote_dir, sudo): for n in manifest.get("nginx", []): f = n.get("file") if not f: continue if f.startswith("/"): run(f"{sudo} mkdir -p {os.path.dirname(f)}", check=False) src = os.path.join(remote_dir, f.lstrip("/")) if os.path.isfile(src): run(f"{sudo} cp {src} {f}", check=False) info(f"Восстановлен nginx-config: {f}") else: warn(f"nginx-config в архиве не найден: {src}") # SSL cert = n.get("ssl_certificate") key = n.get("ssl_certificate_key") for sslf in (cert, key): if sslf: if sslf.startswith("/"): run(f"{sudo} mkdir -p {os.path.dirname(sslf)}", check=False) ssrc = os.path.join(remote_dir, sslf.lstrip("/")) if os.path.isfile(ssrc): run(f"{sudo} cp {ssrc} {sslf}", check=False) info(f"Восстановлен SSL: {sslf}") else: warn(f"SSL в архиве не найден: {ssrc}") def _restore_systemd(manifest, remote_dir, sudo): for u in manifest.get("systemd_units", []): src_path = u.get("path") if not src_path: continue if src_path.startswith("/"): run(f"{sudo} mkdir -p {os.path.dirname(src_path)}", check=False) src = os.path.join(remote_dir, src_path.lstrip("/")) if os.path.isfile(src): run(f"{sudo} cp {src} {src_path}", check=False) run(f"{sudo} systemctl daemon-reload", check=False) run(f"{sudo} systemctl enable {os.path.basename(src_path)}", check=False) info(f"Восстановлен systemd unit: {src_path}") else: warn(f"systemd unit в архиве не найден: {src}") def _restore_cron(manifest, sudo): crons = manifest.get("cron_jobs", []) if crons: warn("Найдены cron-задания. Добавьте их вручную:") for c in crons: print(f" {c.get('line') or c.get('content', '?')}") def _restore_sidecar_configs(manifest, remote_dir, sudo): for s in manifest.get("sidecars", []): det = s.get("details", {}) for f in det.get("files", []): if not f.startswith("/"): continue if f.startswith("/"): run(f"{sudo} mkdir -p {os.path.dirname(f)}", check=False) src = os.path.join(remote_dir, f.lstrip("/")) if os.path.isfile(src): run(f"{sudo} cp {src} {f}", check=False) info(f"Восстановлен sidecar-файл: {f}") else: warn(f"sidecar-файл в архиве не найден: {src}") def _restore_network_notes(manifest, restore_work): net = manifest.get("host_network", {}) note_file = os.path.join(restore_work, "HOST_NETWORK_NOTES.txt") with open(note_file, "w", encoding="utf-8") as f: f.write("HOST NETWORK SNAPSHOT FROM SOURCE SERVER\n") f.write("==========================================\n\n") f.write("IP Routes:\n") for r in net.get("ip_routes", []): f.write(f" {r}\n") f.write("\nIP Rules:\n") for r in net.get("ip_rules", []): f.write(f" {r}\n") f.write("\nIptables filter:\n") for r in net.get("iptables", {}).get("filter", []): f.write(f" {r}\n") info(f"Сетевые заметки сохранены: {note_file}") def do_verify(): step(5, "ВЕРИФИКАЦИЯ") st = state.load_state() is_root = st.get("is_root", False) sudo = "" if is_root else "sudo" manifest_path = st.get("manifest_path") if not manifest_path: raise RuntimeError("Manifest не найден") manifest = json.load(open(manifest_path, "r", encoding="utf-8")) # Nginx config check has_nginx = bool(manifest.get("nginx")) if has_nginx: info("Проверяем nginx ...") r = run(f"{sudo} nginx -t", check=False) if r.returncode != 0: cerror("Nginx config test НЕ ПРОШЁЛ") print(f"\n{r.stdout}") print(f"\n{r.stderr}") state.set_error( step="nginx_config_check", stdout=r.stdout, stderr=r.stderr, suggestion="Проверьте SSL-сертификаты, пути include, и конфликты listen. После исправления запустите: docker-migrate --resume" ) raise RuntimeError("nginx -t failed") success("nginx -t: OK") run(f"{sudo} systemctl reload nginx", check=False) # Docker compose up cf = manifest["docker"].get("compose_file") if cf: cf_path = cf if os.path.isfile(cf_path): info("Запускаем docker compose up ...") compose_dir = os.path.dirname(cf_path) r = run(f"cd {compose_dir} && {sudo} docker compose up -d", check=False) if r.returncode != 0: print(f"\n{r.stdout}") print(f"\n{r.stderr}") state.set_error( step="docker_compose_up", stdout=r.stdout, stderr=r.stderr, suggestion="Проверьте compose-файл, доступность image, volumes. После исправления запустите: docker-migrate --resume" ) raise RuntimeError("docker compose up failed") success("Docker compose up выполнен") else: warn(f"Compose-файл из манифеста не найден на target: {cf_path}") elif manifest["docker"].get("container_name"): info("Compose не найден, запуск по docker run не реализован. Используйте docker run вручную.") # Sidecar units start for u in manifest.get("systemd_units", []): uname = u.get("name") if uname: run(f"{sudo} systemctl start {uname}", check=False) info(f"Запущен unit: {uname}") # Логи — ищем контейнер по имени, а не по ID (ID с другого сервера) cname = manifest["docker"].get("container_name") if cname: try: out = run(f"docker ps -q -f name={cname}", check=False) if out.stdout.strip(): info("Последние логи контейнера:") log_out = run(f"docker logs --tail 30 {cname}", check=False) print(log_out.stdout) else: warn(f"Контейнер '{cname}' пока не запущен, логи недоступны") except Exception as e: warn(f"Не удалось получить логи: {e}") success("Верификация завершена. Сервис должен работать.") state.set_stage("DONE")