Files
docker-migrate/target/target.py

422 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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):
# Рекурсивный поиск manifest.json (может быть в подпапках после tar xzf)
for root, _, files in os.walk(remote_dir):
for f in files:
if f.endswith("_manifest.json"):
manifest_file = os.path.join(root, f)
break
if manifest_file:
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")