fix: install.sh pipe-mode, add .gitignore, robust error handling, resume state check

This commit is contained in:
2026-05-22 20:08:41 +04:00
parent ddb2ae9501
commit 13a1583df1
17 changed files with 210 additions and 76 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*.egg-info/
.migrate-state.json
.DS_Store
*.log
.vscode/
.idea/

Binary file not shown.

View File

@@ -97,14 +97,37 @@ def pretty_dict(data, indent=0):
print(f"{prefix}{bold(k)}: {v}")
def prompt(text):
return input(f"{yellow('')} {text} ").strip()
def prompt(text, default=None):
"""Интерактивный ввод с обработкой EOF/pipe"""
try:
return input(f"{yellow('')} {text} ").strip()
except EOFError:
if default is not None:
print(f" (pipe detected, используем default={default})")
return default
print(f"{red('')} Невозможно читать ввод (stdin закрыт). Перезапустите вне pipe.")
sys.exit(1)
except KeyboardInterrupt:
print(f"\n{yellow('')} Прервано пользователем (Ctrl+C)")
sys.exit(130)
def confirm(text, default="y"):
"""Да/нет с обработкой EOF. В pipe — возвращает default."""
yn = "Y/n" if default.lower() == "y" else "y/N"
while True:
r = input(f"{yellow('')} {text} [{yn}] ").strip().lower()
try:
r = input(f"{yellow('')} {text} [{yn}] ").strip().lower()
except EOFError:
if default.lower() == "y":
print(f" (pipe detected, используем default={default})")
return True
else:
print(f" (pipe detected, используем default={default})")
return False
except KeyboardInterrupt:
print(f"\n{yellow('')} Прервано пользователем (Ctrl+C)")
sys.exit(130)
if not r:
r = default
if r in ("y", "yes", "д", "да"):
@@ -120,17 +143,20 @@ def divider():
def banner():
print(cyan(r"""
╔══════════════════════════════════════════════════════════╗
║ Docker Service Migration Tool
║ Универсальный мастер переноса Docker-сервиса
║ Docker Service Migration Tool ║
║ Универсальный мастер переноса Docker-сервиса ║
╚══════════════════════════════════════════════════════════╝
"""))
def menu():
def menu(has_resume=False):
print(f"\n{bold('Выберите режим:')}")
print(f" {cyan('1')} {white('Подготовка к переносу (Source)')}{gray('На текущем сервере')}")
print(f" {cyan('2')} {white('Восстановление (Target)')}{gray('На новом сервере')}")
print(f" {cyan('3')} {white('Продолжить (Resume)')}{gray('После исправления ошибки')}")
if has_resume:
print(f" {cyan('3')} {white('Продолжить (Resume)')}{gray('После исправления ошибки')}")
else:
print(f" {gray('3')} {gray('Продолжить (Resume)')}{gray('(нет сохранённого состояния)')}")
print(f" {cyan('4')} {white('Статус и логи (Status/Logs)')}{gray('Посмотреть состояние')}")
print(f" {cyan('0')} {white('Выход')}")
print()

View File

@@ -7,12 +7,11 @@ import sys
import os
import argparse
# Добавляем корень проекта в PATH
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
from core.color import banner, menu, prompt, info, error, warn
from core.color import banner, menu, prompt, info, error as cerror, warn
from core import state
@@ -55,16 +54,42 @@ def main():
# Интерактивный режим
banner()
while True:
menu()
choice = prompt("Ваш выбор").strip()
st = state.load_state()
has_resume = bool(st.get("mode") and st.get("stage") and st.get("stage") != "INIT")
menu(has_resume=has_resume)
try:
choice = prompt("Ваш выбор", default="0").strip()
except SystemExit:
break
if choice == "1":
from source.source import run_source_mode
run_source_mode()
try:
run_source_mode()
except KeyboardInterrupt:
warn("Прервано")
except Exception as e:
cerror(f"Ошибка: {e}")
sys.exit(1)
break
elif choice == "2":
from target.target import run_target_mode
run_target_mode()
try:
run_target_mode()
except KeyboardInterrupt:
warn("Прервано")
except Exception as e:
cerror(f"Ошибка: {e}")
sys.exit(1)
break
elif choice == "3":
_do_resume()
if not has_resume:
warn("Нет сохранённого состояния для продолжения")
continue
try:
_do_resume()
except KeyboardInterrupt:
warn("Прервано")
break
elif choice == "4":
_do_status_logs()
elif choice == "0":
@@ -83,7 +108,10 @@ def _do_resume():
info("Нет сохранённого состояния для продолжения")
return
fsm = FSM(mode=mode)
fsm.resume_from(stage)
try:
fsm.resume_from(stage)
except Exception as e:
cerror(f"Ошибка при resume: {e}")
def _do_status_logs():
@@ -93,7 +121,10 @@ def _do_status_logs():
print(" 1 Показать статус")
print(" 2 Показать лог последней ошибки")
print(" 0 Назад")
c = prompt("Выбор").strip()
try:
c = prompt("Выбор", default="0").strip()
except SystemExit:
break
if c == "1":
stmod.show_status()
elif c == "2":

View File

@@ -1,13 +1,21 @@
#!/usr/bin/env bash
# install.sh — Docker Migrate Tool Installer
# Запуск: wget -qO- https://your-gitea/raw/main/install.sh | bash
# curl -fsSL https://your-gitea/raw/main/install.sh | bash
# Запуск: wget -qO- https://giteas.stitch505.su/Stitch505/docker-migrate/raw/main/install.sh | bash
# Потом запускать: docker-migrate
#
# Чтобы сразу запустить после установки (не через pipe):
# curl -fsSL https://.../install.sh -o install.sh && bash install.sh --run
#
set -e
REPO_URL="https://giteas.stitch505.su/Stitch505/docker-migrate"
BRANCH="main"
INSTALL_DIR="/opt/docker-migrate"
RUN_IMMEDIATELY=false
if [ "$1" = "--run" ]; then
RUN_IMMEDIATELY=true
fi
log() { echo "[migrate-install] $*"; }
@@ -43,7 +51,7 @@ fi
# Установка
if [ -d "$INSTALL_DIR" ]; then
log "Директория $INSTALL_DIR уже существует. Удаляем ..."
log "Директория $INSTALL_DIR уже существует. Перезаписываем ..."
rm -rf "$INSTALL_DIR"
fi
mkdir -p "$INSTALL_DIR"
@@ -57,7 +65,6 @@ case "$METHOD" in
log "Скачиваем через curl ..."
tmpzip=$(mktemp /tmp/migrate-XXXXX.zip)
curl -fsSL "${REPO_URL}/archive/refs/heads/${BRANCH}.zip" -o "$tmpzip" || {
# Fallback для gitea raw zip
curl -fsSL "${REPO_URL}/archive/${BRANCH}.zip" -o "$tmpzip"
}
python3 -c "import zipfile; zipfile.ZipFile('$tmpzip').extractall('/tmp')"
@@ -81,6 +88,21 @@ log "Установлено в $INSTALL_DIR"
# Создаём symlink в /usr/local/bin
ln -sf "$INSTALL_DIR/migrate" /usr/local/bin/docker-migrate 2>/dev/null || true
log "Запускаем docker-migrate ..."
cd "$INSTALL_DIR"
exec python3 migrate "$@"
if [ "$RUN_IMMEDIATELY" = true ]; then
log "Запускаем docker-migrate ..."
cd "$INSTALL_DIR"
exec python3 migrate "$@"
else
echo ""
echo "=========================================="
echo "Установка завершена!"
echo ""
echo "Запустите интерактивный режим:"
echo " docker-migrate"
echo ""
echo "Или напрямую:"
echo " docker-migrate --mode=source # подготовка к переносу"
echo " docker-migrate --mode=target # восстановление на новом сервере"
echo " docker-migrate --resume # продолжить после ошибки"
echo "=========================================="
fi

View File

@@ -1,12 +1,14 @@
# -*- coding: utf-8 -*-
"""
source.py — Orchestrator режима SOURCE (подготовка к переносу на старом сервере)
Строгая защита: каждый шаг в try/except, state сохраняется при ошибке.
"""
import os
import json
import tarfile
import time
import sys
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
@@ -23,77 +25,119 @@ def run_source_mode():
state.set_stage("INIT", mode="source")
from core.fsm import FSM
fsm = FSM(mode="source")
fsm.resume_from("INIT")
try:
fsm.resume_from("INIT")
except Exception as e:
cerror(f"Source-режим прерван: {e}")
sys.exit(1)
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"]
try:
docker_data = discover_docker(service_name)
except RuntimeError:
# Если exact не найден, discover_docker уже предложил список
raise
except Exception as e:
raise RuntimeError(f"Ошибка поиска Docker: {e}")
# Собираем подсказки для поиска: порты и домены
cid = docker_data.get("container_id")
if not cid:
raise RuntimeError("Не удалось определить ID контейнера")
# Подсказки для nginx
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))
# Также из Env контейнера
env_list = docker_data.get("env", [])
for e in env_list:
if "DOMAIN" in e.upper() or "HOST" in e.upper():
try:
val = e.split("=", 1)[1]
if val and "." in val:
domain_hints.append(val)
except Exception:
pass
# 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
# Sidecar discovery (не fatal если не получилось)
sidecars = []
try:
sidecars = find_sidecar_processes(cid, ports_list)
for s in sidecars:
if s.get("host_pid"):
try:
details = get_process_details(s["host_pid"])
s["details"] = details
except Exception:
warn(f"Не удалось получить детали процесса {s.get('host_pid')}")
if sidecars:
success(f"Найдено sidecar/loopback зависимостей: {len(sidecars)}")
except Exception as e:
warn(f"Sidecar discovery не удался: {e}. Продолжаем без sidecar.")
# Nginx
nginx_data = discover_nginx(service_ports=ports_list, service_domain_hints=domain_hints)
nginx_data = []
try:
nginx_data = discover_nginx(service_ports=ports_list, service_domain_hints=domain_hints)
if nginx_data:
success(f"Найдено связанных nginx конфигов: {len(nginx_data)}")
except Exception as e:
warn(f"Nginx discovery не удался: {e}. Продолжаем без nginx.")
# Systemd units (nginx + sidecars)
# Systemd
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",
})
systemd_units = []
try:
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",
})
if systemd_units:
success(f"Найдено systemd units: {len(systemd_units)}")
except Exception as e:
warn(f"Systemd discovery не удался: {e}")
# Cron
hints = [docker_data.get("container_name", "")] + [s.get("host_process", "") for s in sidecars]
cron_jobs = gather_cron_jobs(user_hint=hints)
cron_jobs = []
try:
cron_jobs = gather_cron_jobs(user_hint=hints)
if cron_jobs:
success(f"Найдено cron заданий: {len(cron_jobs)}")
except Exception as e:
warn(f"Cron discovery не удался: {e}")
# Host network
host_network = gather_host_network_info()
host_network = {}
try:
host_network = gather_host_network_info()
except Exception as e:
warn(f"Сетевая информация не собрана: {e}")
# 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,
@@ -104,7 +148,6 @@ def do_discovery():
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")
@@ -112,7 +155,6 @@ def do_discovery():
state.set_stage("SOURCE_MANIFEST_REVIEW", manifest_path=manifest_path)
info(f"Этап discovery завершён. Manifest: {manifest_path}")
# Автоматически переходим к review
do_manifest_review()
@@ -156,19 +198,20 @@ def do_pack():
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") # относительный путь
info(f"Архивируем Docker volume: {vol_name} ...")
try:
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")
success(f"Volume {vol_name} заархивирован")
except Exception as e:
warn(f"Не удалось архивировать volume {vol_name}: {e}")
# nginx configs
# nginx configs + SSL
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):
@@ -176,16 +219,14 @@ def do_pack():
if key and os.path.isfile(key):
files_to_pack.add(key)
# sidecar configs (open files процессов)
# sidecar configs
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()
@@ -194,7 +235,7 @@ def do_pack():
except Exception:
pass
# systemd units (nginx и т.п.)
# systemd units
for u in manifest.get("systemd_units", []):
p = u.get("path")
if p and os.path.isfile(p):
@@ -208,9 +249,12 @@ def do_pack():
# 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)
try:
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)
except Exception as e:
warn(f"Не удалось сохранить snapshot сети: {e}")
# manifest itself
files_to_pack.add(manifest_path)
@@ -220,7 +264,6 @@ def do_pack():
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}")
@@ -247,7 +290,6 @@ def do_stop_service():
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:
@@ -271,7 +313,12 @@ def do_transfer_offer():
return
host = prompt("IP или домен нового сервера")
if not host:
warn("IP/домен не указан. Перенос пропущен.")
return
user = prompt("SSH user (root или обычный пользователь)")
if not user:
user = "root"
port = prompt("SSH порт (Enter=22)") or "22"
use_key = confirm("Использовать SSH-ключ (иначе — пароль)", default="y")
if not use_key: