418 lines
17 KiB
Python
418 lines
17 KiB
Python
# -*- 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.reset_state(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")
|
||
|
||
# Если директория пуста — спрашиваем путь к tar.gz
|
||
if not os.path.isdir(remote_dir) or not os.listdir(remote_dir):
|
||
archive_prompt = prompt("Архив не найден в стандартной папке. Укажите путь к .tar.gz архиву (или Enter для указания папки)")
|
||
if archive_prompt and os.path.isfile(archive_prompt):
|
||
# Распаковываем tar.gz во временную папку
|
||
extract_dir = os.path.join(remote_dir, "extracted")
|
||
run(f"{sudo} mkdir -p {extract_dir}", check=False)
|
||
run(f"{sudo} tar xzf '{archive_prompt}' -C {extract_dir}", check=False)
|
||
remote_dir = extract_dir
|
||
info(f"Архив распакован в: {remote_dir}")
|
||
elif archive_prompt and os.path.isdir(archive_prompt):
|
||
remote_dir = archive_prompt
|
||
else:
|
||
remote_dir = prompt("Укажите папку с распакованным архивом (manifest + файлы)") or "/tmp/docker-migrate-incoming"
|
||
if not os.path.isdir(remote_dir):
|
||
raise RuntimeError(f"Папка не найдена: {remote_dir}")
|
||
|
||
manifest_file = None
|
||
if os.path.isdir(remote_dir):
|
||
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")
|