init: docker-migrate universal migration tool

This commit is contained in:
2026-05-22 19:58:09 +04:00
commit ddb2ae9501
34 changed files with 2431 additions and 0 deletions
View File
Binary file not shown.
+282
View File
@@ -0,0 +1,282 @@
# -*- coding: utf-8 -*-
"""
source.py — Orchestrator режима SOURCE (подготовка к переносу на старом сервере)
"""
import os
import json
import tarfile
import time
from datetime import datetime
from core.color import header, subheader, success, warn, error as cerror, info, step, prompt, confirm, divider, log_cmd
from core import state
from core.runner import run
from discover.docker import discover_docker, get_container_pid
from discover.nginx import discover_nginx, get_nginx_systemd_unit
from discover.network import find_sidecar_processes, gather_host_network_info, get_process_details, find_systemd_units_related, gather_cron_jobs
from manifest.manifest import build_manifest, save_manifest, review_manifest
_ARCHIVE_DIR = "/tmp/docker-migrate-archives"
def run_source_mode():
state.set_stage("INIT", mode="source")
from core.fsm import FSM
fsm = FSM(mode="source")
fsm.resume_from("INIT")
def do_discovery():
step(1, "АВТО-ПОИСК ЗАВИСИМОСТЕЙ")
state.set_stage("SOURCE_DISCOVER")
# Запрашиваем имя сервиса (не нужно выбирать что искать — скрипт сам)
service_name = prompt("Введите имя Docker-сервиса или контейнера (например, marzban)")
if not service_name:
raise RuntimeError("Имя сервиса не указано")
# Docker discovery
docker_data = discover_docker(service_name)
cid = docker_data["container_id"]
# Собираем подсказки для поиска: порты и домены
ports = list(docker_data.get("ports", {}).keys())
ports_list = []
for p in ports:
# p вида "0.0.0.0:443" или "127.0.0.1:8000"
try:
port_num = p.split(":")[-1]
ports_list.append(int(port_num))
except Exception:
pass
# Домены из labels/env
domain_hints = []
env_dict = {}
labels = docker_data.get("labels", {})
for k, v in labels.items():
if "DOMAIN" in k.upper() or "HOST" in k.upper():
domain_hints.append(str(v))
# Sidecar / loopback discovery
sidecars = find_sidecar_processes(cid, ports_list)
# Детали процессов sidecar (exe, files, unit)
for s in sidecars:
if s.get("host_pid"):
details = get_process_details(s["host_pid"])
s["details"] = details
# Nginx
nginx_data = discover_nginx(service_ports=ports_list, service_domain_hints=domain_hints)
# Systemd units (nginx + sidecars)
sidecar_procs = [{"pid": s["host_pid"], "process": s["host_process"]} for s in sidecars if s.get("host_pid")]
systemd_units = find_systemd_units_related(sidecar_procs)
nginx_units = get_nginx_systemd_unit()
for nu in nginx_units:
systemd_units.append({
"name": nu["unit"],
"path": nu["path"],
"related_to": "nginx",
})
# Cron
hints = [docker_data.get("container_name", "")] + [s.get("host_process", "") for s in sidecars]
cron_jobs = gather_cron_jobs(user_hint=hints)
# Host network
host_network = gather_host_network_info()
# Compose-dependent files (sidecar configs near compose dir)
extra_hints = []
compose_dir = None
if docker_data.get("compose_file"):
compose_dir = os.path.dirname(docker_data["compose_file"])
manifest = build_manifest(
docker_data=docker_data,
nginx_data=nginx_data,
sidecars=sidecars,
host_network=host_network,
systemd_units=systemd_units,
cron_jobs=cron_jobs,
extra_hints=extra_hints,
)
# Путь для manifest
os.makedirs(_ARCHIVE_DIR, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
manifest_path = os.path.join(_ARCHIVE_DIR, f"{service_name}_{stamp}_manifest.json")
save_manifest(manifest, manifest_path)
state.set_stage("SOURCE_MANIFEST_REVIEW", manifest_path=manifest_path)
info(f"Этап discovery завершён. Manifest: {manifest_path}")
# Автоматически переходим к review
do_manifest_review()
def do_manifest_review():
manifest_path = state.load_state().get("manifest_path")
if not manifest_path or not os.path.isfile(manifest_path):
raise RuntimeError("Manifest не найден. Сначала выполните discovery.")
from manifest.manifest import load_manifest
manifest = load_manifest(manifest_path)
review_manifest(manifest)
if not confirm("Подтвердить и продолжить сборку архива", default="y"):
raise RuntimeError("Прервано пользователем")
def do_pack():
step(2, "АРХИВИРОВАНИЕ")
st = state.load_state()
manifest_path = st.get("manifest_path")
manifest = json.load(open(manifest_path, "r", encoding="utf-8"))
svc_name = manifest["service"]["name"]
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
archive_name = f"{svc_name}_{stamp}_migrate.tar.gz"
archive_path = os.path.join(_ARCHIVE_DIR, archive_name)
files_to_pack = set()
# compose
cf = manifest["docker"].get("compose_file")
if cf and os.path.isfile(cf):
files_to_pack.add(cf)
# env
ef = manifest["docker"].get("env_file")
if ef and os.path.isfile(ef):
files_to_pack.add(ef)
# bind mounts
for m in manifest["docker"].get("mounts", []):
if m["type"] == "bind":
src = m["source"]
if os.path.isdir(src) or os.path.isfile(src):
files_to_pack.add(src)
elif m["type"] == "volume":
# Docker named volume: сохраняем через docker run --rm -v vol:/data tar
vol_name = m["source"]
tmpvol = os.path.join(_ARCHIVE_DIR, f"vol_{vol_name}_{stamp}.tar")
info(f"Архивируем Docker volume: {vol_name}{tmpvol}")
run(f"docker run --rm -v {vol_name}:/data -v {_ARCHIVE_DIR}:/out alpine tar czf /out/vol_{vol_name}_{stamp}.tar.gz -C /data .", check=False)
files_to_pack.add(f"vol_{vol_name}_{stamp}.tar.gz") # относительный путь
# nginx configs
for n in manifest.get("nginx", []):
f = n.get("file")
if f and os.path.isfile(f):
files_to_pack.add(f)
# SSL сертификаты
cert = n.get("ssl_certificate")
key = n.get("ssl_certificate_key")
if cert and os.path.isfile(cert):
files_to_pack.add(cert)
if key and os.path.isfile(key):
files_to_pack.add(key)
# sidecar configs (open files процессов)
for s in manifest.get("sidecars", []):
details = s.get("details", {})
for f in details.get("files", []):
if os.path.isfile(f):
files_to_pack.add(f)
# systemd unit
unit = details.get("unit")
if unit:
# найдём файл unit через systemctl
try:
uout = run(f"systemctl show {unit} -p FragmentPath --value", check=False)
path = uout.stdout.strip()
if path and os.path.isfile(path):
files_to_pack.add(path)
except Exception:
pass
# systemd units (nginx и т.п.)
for u in manifest.get("systemd_units", []):
p = u.get("path")
if p and os.path.isfile(p):
files_to_pack.add(p)
# cron files
for c in manifest.get("cron_jobs", []):
f = c.get("file")
if f and os.path.isfile(f):
files_to_pack.add(f)
# host_network snapshot
netfile = os.path.join(_ARCHIVE_DIR, f"{svc_name}_{stamp}_network.json")
with open(netfile, "w", encoding="utf-8") as f:
json.dump(manifest.get("host_network", {}), f, indent=2, ensure_ascii=False)
files_to_pack.add(netfile)
# manifest itself
files_to_pack.add(manifest_path)
# Build tar.gz — сохраняем пути относительно корня (без начального /)
info(f"Создаём архив: {archive_path}")
with tarfile.open(archive_path, "w:gz") as tar:
for fp in files_to_pack:
if os.path.exists(fp):
# Убираем ведущий /, чтобы tar создавал относительные пути
arcname = fp.lstrip("/")
tar.add(fp, arcname=arcname)
info(f" + {arcname}")
else:
warn(f" ! Не найден файл для архива: {fp}")
success(f"Архив создан: {archive_path}")
state.set_stage("SOURCE_PACK", archive_path=archive_path)
do_stop_service()
def do_stop_service():
step(3, "ОСТАНОВКА СЕРВИСА (опционально)")
st = state.load_state()
manifest_path = st.get("manifest_path")
manifest = json.load(open(manifest_path, "r", encoding="utf-8"))
svc_name = manifest["service"]["name"]
if confirm(f"Остановить Docker-сервис '{svc_name}' перед переносом", default="n"):
cid = manifest["docker"].get("container_id")
if cid:
try:
run(f"docker stop {cid} -t 30", check=False)
success(f"Контейнер {cid[:12]} остановлен")
except Exception as e:
warn(f"Не удалось остановить контейнер: {e}")
# Остановить sidecar unit-ы
for u in manifest.get("systemd_units", []):
uname = u.get("name")
if uname:
try:
run(f"systemctl stop {uname}", check=False)
info(f"Остановлен systemd unit: {uname}")
except Exception:
pass
else:
info("Пропуск остановки (сервис продолжает работать)")
state.set_stage("SOURCE_STOP")
do_transfer_offer()
def do_transfer_offer():
step(4, "ПЕРЕНОС НА НОВЫЙ СЕРВЕР (опционально)")
if not confirm("Перенести архив на новый сервер сейчас", default="y"):
success("Готово! Архив оставлен на текущем сервере.")
success(f"Manifest и архив лежат в: {_ARCHIVE_DIR}")
return
host = prompt("IP или домен нового сервера")
user = prompt("SSH user (root или обычный пользователь)")
port = prompt("SSH порт (Enter=22)") or "22"
use_key = confirm("Использовать SSH-ключ (иначе — пароль)", default="y")
if not use_key:
warn("Будет запрошен пароль. Убедитесь, что SSH доступ разрешён по паролю.")
state.set_stage("TRANSFER", target_host=host, target_user=user, target_port=int(port))
from transfer.transfer import do_transfer
do_transfer()