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
View File
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*.egg-info/
.migrate-state.json
.DS_Store
*.log
.vscode/
.idea/
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+28 -2
View File
@@ -97,14 +97,37 @@ def pretty_dict(data, indent=0):
print(f"{prefix}{bold(k)}: {v}") print(f"{prefix}{bold(k)}: {v}")
def prompt(text): def prompt(text, default=None):
"""Интерактивный ввод с обработкой EOF/pipe"""
try:
return input(f"{yellow('')} {text} ").strip() 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"): def confirm(text, default="y"):
"""Да/нет с обработкой EOF. В pipe — возвращает default."""
yn = "Y/n" if default.lower() == "y" else "y/N" yn = "Y/n" if default.lower() == "y" else "y/N"
while True: while True:
try:
r = input(f"{yellow('')} {text} [{yn}] ").strip().lower() 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: if not r:
r = default r = default
if r in ("y", "yes", "д", "да"): if r in ("y", "yes", "д", "да"):
@@ -126,11 +149,14 @@ def banner():
""")) """))
def menu(): def menu(has_resume=False):
print(f"\n{bold('Выберите режим:')}") print(f"\n{bold('Выберите режим:')}")
print(f" {cyan('1')} {white('Подготовка к переносу (Source)')}{gray('На текущем сервере')}") print(f" {cyan('1')} {white('Подготовка к переносу (Source)')}{gray('На текущем сервере')}")
print(f" {cyan('2')} {white('Восстановление (Target)')}{gray('На новом сервере')}") print(f" {cyan('2')} {white('Восстановление (Target)')}{gray('На новом сервере')}")
if has_resume:
print(f" {cyan('3')} {white('Продолжить (Resume)')}{gray('После исправления ошибки')}") 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('4')} {white('Статус и логи (Status/Logs)')}{gray('Посмотреть состояние')}")
print(f" {cyan('0')} {white('Выход')}") print(f" {cyan('0')} {white('Выход')}")
print() print()
+36 -5
View File
@@ -7,12 +7,11 @@ import sys
import os import os
import argparse import argparse
# Добавляем корень проекта в PATH
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PROJECT_ROOT not in sys.path: if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT) 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 from core import state
@@ -55,16 +54,42 @@ def main():
# Интерактивный режим # Интерактивный режим
banner() banner()
while True: while True:
menu() st = state.load_state()
choice = prompt("Ваш выбор").strip() 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": if choice == "1":
from source.source import run_source_mode from source.source import run_source_mode
try:
run_source_mode() run_source_mode()
except KeyboardInterrupt:
warn("Прервано")
except Exception as e:
cerror(f"Ошибка: {e}")
sys.exit(1)
break
elif choice == "2": elif choice == "2":
from target.target import run_target_mode from target.target import run_target_mode
try:
run_target_mode() run_target_mode()
except KeyboardInterrupt:
warn("Прервано")
except Exception as e:
cerror(f"Ошибка: {e}")
sys.exit(1)
break
elif choice == "3": elif choice == "3":
if not has_resume:
warn("Нет сохранённого состояния для продолжения")
continue
try:
_do_resume() _do_resume()
except KeyboardInterrupt:
warn("Прервано")
break
elif choice == "4": elif choice == "4":
_do_status_logs() _do_status_logs()
elif choice == "0": elif choice == "0":
@@ -83,7 +108,10 @@ def _do_resume():
info("Нет сохранённого состояния для продолжения") info("Нет сохранённого состояния для продолжения")
return return
fsm = FSM(mode=mode) fsm = FSM(mode=mode)
try:
fsm.resume_from(stage) fsm.resume_from(stage)
except Exception as e:
cerror(f"Ошибка при resume: {e}")
def _do_status_logs(): def _do_status_logs():
@@ -93,7 +121,10 @@ def _do_status_logs():
print(" 1 Показать статус") print(" 1 Показать статус")
print(" 2 Показать лог последней ошибки") print(" 2 Показать лог последней ошибки")
print(" 0 Назад") print(" 0 Назад")
c = prompt("Выбор").strip() try:
c = prompt("Выбор", default="0").strip()
except SystemExit:
break
if c == "1": if c == "1":
stmod.show_status() stmod.show_status()
elif c == "2": elif c == "2":
Binary file not shown.
Binary file not shown.
Binary file not shown.
+30 -8
View File
@@ -1,13 +1,21 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# install.sh — Docker Migrate Tool Installer # install.sh — Docker Migrate Tool Installer
# Запуск: wget -qO- https://your-gitea/raw/main/install.sh | bash # Запуск: wget -qO- https://giteas.stitch505.su/Stitch505/docker-migrate/raw/main/install.sh | bash
# curl -fsSL https://your-gitea/raw/main/install.sh | bash # Потом запускать: docker-migrate
#
# Чтобы сразу запустить после установки (не через pipe):
# curl -fsSL https://.../install.sh -o install.sh && bash install.sh --run
#
set -e set -e
REPO_URL="https://giteas.stitch505.su/Stitch505/docker-migrate" REPO_URL="https://giteas.stitch505.su/Stitch505/docker-migrate"
BRANCH="main" BRANCH="main"
INSTALL_DIR="/opt/docker-migrate" INSTALL_DIR="/opt/docker-migrate"
RUN_IMMEDIATELY=false
if [ "$1" = "--run" ]; then
RUN_IMMEDIATELY=true
fi
log() { echo "[migrate-install] $*"; } log() { echo "[migrate-install] $*"; }
@@ -43,7 +51,7 @@ fi
# Установка # Установка
if [ -d "$INSTALL_DIR" ]; then if [ -d "$INSTALL_DIR" ]; then
log "Директория $INSTALL_DIR уже существует. Удаляем ..." log "Директория $INSTALL_DIR уже существует. Перезаписываем ..."
rm -rf "$INSTALL_DIR" rm -rf "$INSTALL_DIR"
fi fi
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
@@ -57,7 +65,6 @@ case "$METHOD" in
log "Скачиваем через curl ..." log "Скачиваем через curl ..."
tmpzip=$(mktemp /tmp/migrate-XXXXX.zip) tmpzip=$(mktemp /tmp/migrate-XXXXX.zip)
curl -fsSL "${REPO_URL}/archive/refs/heads/${BRANCH}.zip" -o "$tmpzip" || { curl -fsSL "${REPO_URL}/archive/refs/heads/${BRANCH}.zip" -o "$tmpzip" || {
# Fallback для gitea raw zip
curl -fsSL "${REPO_URL}/archive/${BRANCH}.zip" -o "$tmpzip" curl -fsSL "${REPO_URL}/archive/${BRANCH}.zip" -o "$tmpzip"
} }
python3 -c "import zipfile; zipfile.ZipFile('$tmpzip').extractall('/tmp')" python3 -c "import zipfile; zipfile.ZipFile('$tmpzip').extractall('/tmp')"
@@ -81,6 +88,21 @@ log "Установлено в $INSTALL_DIR"
# Создаём symlink в /usr/local/bin # Создаём symlink в /usr/local/bin
ln -sf "$INSTALL_DIR/migrate" /usr/local/bin/docker-migrate 2>/dev/null || true ln -sf "$INSTALL_DIR/migrate" /usr/local/bin/docker-migrate 2>/dev/null || true
log "Запускаем docker-migrate ..." if [ "$RUN_IMMEDIATELY" = true ]; then
cd "$INSTALL_DIR" log "Запускаем docker-migrate ..."
exec python3 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
Binary file not shown.
Binary file not shown.
+77 -30
View File
@@ -1,12 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
source.py — Orchestrator режима SOURCE (подготовка к переносу на старом сервере) source.py — Orchestrator режима SOURCE (подготовка к переносу на старом сервере)
Строгая защита: каждый шаг в try/except, state сохраняется при ошибке.
""" """
import os import os
import json import json
import tarfile import tarfile
import time import time
import sys
from datetime import datetime from datetime import datetime
from core.color import header, subheader, success, warn, error as cerror, info, step, prompt, confirm, divider, log_cmd from core.color import header, subheader, success, warn, error as cerror, info, step, prompt, confirm, divider, log_cmd
from core import state from core import state
@@ -23,55 +25,88 @@ def run_source_mode():
state.set_stage("INIT", mode="source") state.set_stage("INIT", mode="source")
from core.fsm import FSM from core.fsm import FSM
fsm = FSM(mode="source") fsm = FSM(mode="source")
try:
fsm.resume_from("INIT") fsm.resume_from("INIT")
except Exception as e:
cerror(f"Source-режим прерван: {e}")
sys.exit(1)
def do_discovery(): def do_discovery():
step(1, "АВТО-ПОИСК ЗАВИСИМОСТЕЙ") step(1, "АВТО-ПОИСК ЗАВИСИМОСТЕЙ")
state.set_stage("SOURCE_DISCOVER") state.set_stage("SOURCE_DISCOVER")
# Запрашиваем имя сервиса (не нужно выбирать что искать — скрипт сам)
service_name = prompt("Введите имя Docker-сервиса или контейнера (например, marzban)") service_name = prompt("Введите имя Docker-сервиса или контейнера (например, marzban)")
if not service_name: if not service_name:
raise RuntimeError("Имя сервиса не указано") raise RuntimeError("Имя сервиса не указано")
# Docker discovery try:
docker_data = discover_docker(service_name) docker_data = discover_docker(service_name)
cid = docker_data["container_id"] 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(docker_data.get("ports", {}).keys())
ports_list = [] ports_list = []
for p in ports: for p in ports:
# p вида "0.0.0.0:443" или "127.0.0.1:8000"
try: try:
port_num = p.split(":")[-1] port_num = p.split(":")[-1]
ports_list.append(int(port_num)) ports_list.append(int(port_num))
except Exception: except Exception:
pass pass
# Домены из labels/env
domain_hints = [] domain_hints = []
env_dict = {}
labels = docker_data.get("labels", {}) labels = docker_data.get("labels", {})
for k, v in labels.items(): for k, v in labels.items():
if "DOMAIN" in k.upper() or "HOST" in k.upper(): if "DOMAIN" in k.upper() or "HOST" in k.upper():
domain_hints.append(str(v)) 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 # Sidecar discovery (не fatal если не получилось)
sidecars = []
try:
sidecars = find_sidecar_processes(cid, ports_list) sidecars = find_sidecar_processes(cid, ports_list)
# Детали процессов sidecar (exe, files, unit)
for s in sidecars: for s in sidecars:
if s.get("host_pid"): if s.get("host_pid"):
try:
details = get_process_details(s["host_pid"]) details = get_process_details(s["host_pid"])
s["details"] = details 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
nginx_data = []
try:
nginx_data = discover_nginx(service_ports=ports_list, service_domain_hints=domain_hints) 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")] sidecar_procs = [{"pid": s["host_pid"], "process": s["host_process"]} for s in sidecars if s.get("host_pid")]
systemd_units = []
try:
systemd_units = find_systemd_units_related(sidecar_procs) systemd_units = find_systemd_units_related(sidecar_procs)
nginx_units = get_nginx_systemd_unit() nginx_units = get_nginx_systemd_unit()
for nu in nginx_units: for nu in nginx_units:
@@ -80,20 +115,29 @@ def do_discovery():
"path": nu["path"], "path": nu["path"],
"related_to": "nginx", "related_to": "nginx",
}) })
if systemd_units:
success(f"Найдено systemd units: {len(systemd_units)}")
except Exception as e:
warn(f"Systemd discovery не удался: {e}")
# Cron # Cron
hints = [docker_data.get("container_name", "")] + [s.get("host_process", "") for s in sidecars] hints = [docker_data.get("container_name", "")] + [s.get("host_process", "") for s in sidecars]
cron_jobs = []
try:
cron_jobs = gather_cron_jobs(user_hint=hints) 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
host_network = {}
try:
host_network = gather_host_network_info() host_network = gather_host_network_info()
except Exception as e:
warn(f"Сетевая информация не собрана: {e}")
# Compose-dependent files (sidecar configs near compose dir)
extra_hints = [] extra_hints = []
compose_dir = None
if docker_data.get("compose_file"):
compose_dir = os.path.dirname(docker_data["compose_file"])
manifest = build_manifest( manifest = build_manifest(
docker_data=docker_data, docker_data=docker_data,
nginx_data=nginx_data, nginx_data=nginx_data,
@@ -104,7 +148,6 @@ def do_discovery():
extra_hints=extra_hints, extra_hints=extra_hints,
) )
# Путь для manifest
os.makedirs(_ARCHIVE_DIR, exist_ok=True) os.makedirs(_ARCHIVE_DIR, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S") stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
manifest_path = os.path.join(_ARCHIVE_DIR, f"{service_name}_{stamp}_manifest.json") 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) state.set_stage("SOURCE_MANIFEST_REVIEW", manifest_path=manifest_path)
info(f"Этап discovery завершён. Manifest: {manifest_path}") info(f"Этап discovery завершён. Manifest: {manifest_path}")
# Автоматически переходим к review
do_manifest_review() do_manifest_review()
@@ -156,19 +198,20 @@ def do_pack():
if os.path.isdir(src) or os.path.isfile(src): if os.path.isdir(src) or os.path.isfile(src):
files_to_pack.add(src) files_to_pack.add(src)
elif m["type"] == "volume": elif m["type"] == "volume":
# Docker named volume: сохраняем через docker run --rm -v vol:/data tar
vol_name = m["source"] vol_name = m["source"]
tmpvol = os.path.join(_ARCHIVE_DIR, f"vol_{vol_name}_{stamp}.tar") info(f"Архивируем Docker volume: {vol_name} ...")
info(f"Архивируем Docker volume: {vol_name}{tmpvol}") 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) 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") # относительный путь 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", []): for n in manifest.get("nginx", []):
f = n.get("file") f = n.get("file")
if f and os.path.isfile(f): if f and os.path.isfile(f):
files_to_pack.add(f) files_to_pack.add(f)
# SSL сертификаты
cert = n.get("ssl_certificate") cert = n.get("ssl_certificate")
key = n.get("ssl_certificate_key") key = n.get("ssl_certificate_key")
if cert and os.path.isfile(cert): if cert and os.path.isfile(cert):
@@ -176,16 +219,14 @@ def do_pack():
if key and os.path.isfile(key): if key and os.path.isfile(key):
files_to_pack.add(key) files_to_pack.add(key)
# sidecar configs (open files процессов) # sidecar configs
for s in manifest.get("sidecars", []): for s in manifest.get("sidecars", []):
details = s.get("details", {}) details = s.get("details", {})
for f in details.get("files", []): for f in details.get("files", []):
if os.path.isfile(f): if os.path.isfile(f):
files_to_pack.add(f) files_to_pack.add(f)
# systemd unit
unit = details.get("unit") unit = details.get("unit")
if unit: if unit:
# найдём файл unit через systemctl
try: try:
uout = run(f"systemctl show {unit} -p FragmentPath --value", check=False) uout = run(f"systemctl show {unit} -p FragmentPath --value", check=False)
path = uout.stdout.strip() path = uout.stdout.strip()
@@ -194,7 +235,7 @@ def do_pack():
except Exception: except Exception:
pass pass
# systemd units (nginx и т.п.) # systemd units
for u in manifest.get("systemd_units", []): for u in manifest.get("systemd_units", []):
p = u.get("path") p = u.get("path")
if p and os.path.isfile(p): if p and os.path.isfile(p):
@@ -208,9 +249,12 @@ def do_pack():
# host_network snapshot # host_network snapshot
netfile = os.path.join(_ARCHIVE_DIR, f"{svc_name}_{stamp}_network.json") netfile = os.path.join(_ARCHIVE_DIR, f"{svc_name}_{stamp}_network.json")
try:
with open(netfile, "w", encoding="utf-8") as f: with open(netfile, "w", encoding="utf-8") as f:
json.dump(manifest.get("host_network", {}), f, indent=2, ensure_ascii=False) json.dump(manifest.get("host_network", {}), f, indent=2, ensure_ascii=False)
files_to_pack.add(netfile) files_to_pack.add(netfile)
except Exception as e:
warn(f"Не удалось сохранить snapshot сети: {e}")
# manifest itself # manifest itself
files_to_pack.add(manifest_path) files_to_pack.add(manifest_path)
@@ -220,7 +264,6 @@ def do_pack():
with tarfile.open(archive_path, "w:gz") as tar: with tarfile.open(archive_path, "w:gz") as tar:
for fp in files_to_pack: for fp in files_to_pack:
if os.path.exists(fp): if os.path.exists(fp):
# Убираем ведущий /, чтобы tar создавал относительные пути
arcname = fp.lstrip("/") arcname = fp.lstrip("/")
tar.add(fp, arcname=arcname) tar.add(fp, arcname=arcname)
info(f" + {arcname}") info(f" + {arcname}")
@@ -247,7 +290,6 @@ def do_stop_service():
success(f"Контейнер {cid[:12]} остановлен") success(f"Контейнер {cid[:12]} остановлен")
except Exception as e: except Exception as e:
warn(f"Не удалось остановить контейнер: {e}") warn(f"Не удалось остановить контейнер: {e}")
# Остановить sidecar unit-ы
for u in manifest.get("systemd_units", []): for u in manifest.get("systemd_units", []):
uname = u.get("name") uname = u.get("name")
if uname: if uname:
@@ -271,7 +313,12 @@ def do_transfer_offer():
return return
host = prompt("IP или домен нового сервера") host = prompt("IP или домен нового сервера")
if not host:
warn("IP/домен не указан. Перенос пропущен.")
return
user = prompt("SSH user (root или обычный пользователь)") user = prompt("SSH user (root или обычный пользователь)")
if not user:
user = "root"
port = prompt("SSH порт (Enter=22)") or "22" port = prompt("SSH порт (Enter=22)") or "22"
use_key = confirm("Использовать SSH-ключ (иначе — пароль)", default="y") use_key = confirm("Использовать SSH-ключ (иначе — пароль)", default="y")
if not use_key: if not use_key:
Binary file not shown.
Binary file not shown.