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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+136
View File
@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
"""
color.py — ANSI цвета и форматирование вывода (на русском)
"""
import os
import sys
_COLORS_ENABLED = True
if os.environ.get("NO_COLOR") or not sys.stdout.isatty():
_COLORS_ENABLED = False
_CODES = {
"reset": "\033[0m",
"bold": "\033[1m",
"red": "\033[31m",
"green": "\033[32m",
"yellow": "\033[33m",
"blue": "\033[34m",
"magenta": "\033[35m",
"cyan": "\033[36m",
"white": "\033[37m",
"gray": "\033[90m",
"bg_red": "\033[41m",
}
def _c(code, text):
if not _COLORS_ENABLED:
return text
return f"{_CODES[code]}{text}{_CODES['reset']}"
def bold(text): return _c("bold", text)
def red(text): return _c("red", text)
def green(text): return _c("green", text)
def yellow(text): return _c("yellow", text)
def blue(text): return _c("blue", text)
def magenta(text): return _c("magenta", text)
def cyan(text): return _c("cyan", text)
def white(text): return _c("white", text)
def gray(text): return _c("gray", text)
def bg_red(text): return _c("bg_red", text)
def header(text, width=60):
line = "" * width
print(f"\n{cyan(line)}")
print(f"{bold(cyan(' ' + text))}")
print(f"{cyan(line)}\n")
def subheader(text):
print(f"\n{magenta('')} {bold(white(text))}")
def step(number, title):
badge = cyan(f"[{number}]")
print(f"\n{badge} {bold(title)}")
print(gray("" * 50))
def success(text):
print(f"{green('')} {text}")
def warn(text):
print(f"{yellow('')} {text}")
def error(text):
print(f"{red('')} {text}")
def info(text):
print(f"{blue('')} {text}")
def log_cmd(cmd):
print(f"{gray('$')} {gray(cmd)}")
def pretty_dict(data, indent=0):
for k, v in data.items():
prefix = " " * indent
if isinstance(v, dict):
print(f"{prefix}{bold(k)}:")
pretty_dict(v, indent + 1)
elif isinstance(v, list):
print(f"{prefix}{bold(k)}:")
for item in v:
if isinstance(item, dict):
pretty_dict(item, indent + 1)
else:
print(f"{prefix} - {item}")
else:
print(f"{prefix}{bold(k)}: {v}")
def prompt(text):
return input(f"{yellow('')} {text} ").strip()
def confirm(text, default="y"):
yn = "Y/n" if default.lower() == "y" else "y/N"
while True:
r = input(f"{yellow('')} {text} [{yn}] ").strip().lower()
if not r:
r = default
if r in ("y", "yes", "д", "да"):
return True
if r in ("n", "no", "н", "нет"):
return False
def divider():
print(gray("" * 60))
def banner():
print(cyan(r"""
╔══════════════════════════════════════════════════════════╗
║ Docker Service Migration Tool ║
║ Универсальный мастер переноса Docker-сервиса ║
╚══════════════════════════════════════════════════════════╝
"""))
def menu():
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('После исправления ошибки')}")
print(f" {cyan('4')} {white('Статус и логи (Status/Logs)')}{gray('Посмотреть состояние')}")
print(f" {cyan('0')} {white('Выход')}")
print()
+108
View File
@@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
"""
fsm.py — Конечный автомат (Finite State Machine) для миграции.
Каждый mode (source/target) имеет свой pipeline steps.
"""
from core import state
from core.color import header, subheader, success, error as cerror, warn, info, step
class FSM:
SOURCE_STEPS = [
"INIT",
"SOURCE_DISCOVER",
"SOURCE_MANIFEST_REVIEW",
"SOURCE_PACK",
"SOURCE_STOP_SERVICE",
"TRANSFER",
"DONE",
]
TARGET_STEPS = [
"INIT",
"TARGET_PREFLIGHT",
"TARGET_INSTALL",
"TARGET_BACKUP",
"TARGET_RESTORE",
"TARGET_VERIFY",
"DONE",
]
def __init__(self, mode="source"):
self.mode = mode
self.steps = self.SOURCE_STEPS if mode == "source" else self.TARGET_STEPS
def _index(self, stage):
try:
return self.steps.index(stage)
except ValueError:
return -1
def resume_from(self, stage):
idx = self._index(stage)
if idx < 0:
warn(f"Неизвестный stage '{stage}', начинаем с начала")
idx = 0
for s in self.steps[idx:]:
if state.is_completed(s):
info(f"Шаг {s} уже выполнен, пропускаем")
continue
self._run_step(s)
def _run_step(self, step_name):
header(f"ШАГ: {step_name}")
try:
if self.mode == "source":
self._source_step(step_name)
else:
self._target_step(step_name)
state.mark_completed(step_name)
state.set_stage(step_name)
except Exception as e:
msg = str(e)
cerror(f"Ошибка на шаге {step_name}: {msg}")
state.set_error(step_name, "", msg, suggestion="Исправьте проблему и запустите: ./migrate --resume")
raise
def _source_step(self, name):
if name == "INIT":
info("Инициализация source-режима")
elif name == "SOURCE_DISCOVER":
from source.source import do_discovery
do_discovery()
elif name == "SOURCE_MANIFEST_REVIEW":
from source.source import do_manifest_review
do_manifest_review()
elif name == "SOURCE_PACK":
from source.source import do_pack
do_pack()
elif name == "SOURCE_STOP_SERVICE":
from source.source import do_stop_service
do_stop_service()
elif name == "TRANSFER":
from transfer.transfer import do_transfer
do_transfer()
elif name == "DONE":
success("Source-режим завершён")
def _target_step(self, name):
if name == "INIT":
info("Инициализация target-режима")
elif name == "TARGET_PREFLIGHT":
from target.target import do_preflight
do_preflight()
elif name == "TARGET_INSTALL":
from target.target import do_install
do_install()
elif name == "TARGET_BACKUP":
from target.target import do_backup_existing
do_backup_existing()
elif name == "TARGET_RESTORE":
from target.target import do_restore
do_restore()
elif name == "TARGET_VERIFY":
from target.target import do_verify
do_verify()
elif name == "DONE":
success("Target-режим завершён")
+106
View File
@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""
main.py — Точка входа. Меню выбора режима.
"""
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 import state
def main():
parser = argparse.ArgumentParser(description="Docker Service Migration Tool")
parser.add_argument("--mode", choices=["source", "target"], help="Режим работы")
parser.add_argument("--resume", action="store_true", help="Продолжить после ошибки")
parser.add_argument("--status", action="store_true", help="Показать статус")
parser.add_argument("--logs", action="store_true", help="Показать логи ошибки")
parser.add_argument("--remote-dir", help="Директория архива на target")
parser.add_argument("--no-color", action="store_true", help="Отключить цвета")
args = parser.parse_args()
if args.no_color:
os.environ["NO_COLOR"] = "1"
if args.status:
state.show_status()
sys.exit(0)
if args.logs:
state.show_logs()
sys.exit(0)
if args.resume:
_do_resume()
sys.exit(0)
if args.mode == "source":
from source.source import run_source_mode
run_source_mode()
sys.exit(0)
elif args.mode == "target":
if args.remote_dir:
state.set_stage("INIT", mode="target", target_remote_dir=args.remote_dir)
from target.target import run_target_mode
run_target_mode()
sys.exit(0)
# Интерактивный режим
banner()
while True:
menu()
choice = prompt("Ваш выбор").strip()
if choice == "1":
from source.source import run_source_mode
run_source_mode()
elif choice == "2":
from target.target import run_target_mode
run_target_mode()
elif choice == "3":
_do_resume()
elif choice == "4":
_do_status_logs()
elif choice == "0":
info("Выход")
sys.exit(0)
else:
warn("Неверный выбор")
def _do_resume():
from core.fsm import FSM
st = state.load_state()
stage = st.get("stage")
mode = st.get("mode")
if not mode or stage == "INIT":
info("Нет сохранённого состояния для продолжения")
return
fsm = FSM(mode=mode)
fsm.resume_from(stage)
def _do_status_logs():
from core import state as stmod
while True:
print("\n")
print(" 1 Показать статус")
print(" 2 Показать лог последней ошибки")
print(" 0 Назад")
c = prompt("Выбор").strip()
if c == "1":
stmod.show_status()
elif c == "2":
stmod.show_logs()
elif c == "0":
break
if __name__ == "__main__":
main()
+73
View File
@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
"""
runner.py — запуск shell-команд с логированием и перехватом ошибок
"""
import subprocess
import shlex
import os
from core.color import log_cmd, error, info
def run(cmd, cwd=None, env=None, check=True, capture=True, shell=True, timeout=300):
"""
Выполняет команду. При ошибке (check=True) бросает RuntimeError с stdout+stderr.
"""
if not shell and isinstance(cmd, str):
cmd = shlex.split(cmd)
if isinstance(cmd, list):
display = " ".join(shlex.quote(str(x)) for x in cmd)
else:
display = cmd
log_cmd(display)
merged_env = os.environ.copy()
if env:
merged_env.update(env)
try:
result = subprocess.run(
cmd,
cwd=cwd,
env=merged_env,
shell=shell,
capture_output=capture,
text=True,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
raise RuntimeError(f"Команда зависла (timeout {timeout}s): {display}")
if check and result.returncode != 0:
raise RuntimeError(
f"Команда завершилась с кодом {result.returncode}: {display}\n"
f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
)
return result
def run_quiet(cmd, **kwargs):
"""Выполняет без вывода команды, только результат"""
return run(cmd, capture=True, **kwargs)
def run_json(cmd, **kwargs):
"""Выполняет и парсит JSON вывод"""
import json
r = run(cmd, **kwargs)
if not r.stdout.strip():
return {}
try:
return json.loads(r.stdout)
except json.JSONDecodeError:
raise RuntimeError(f"Не удалось распарсить JSON из: {cmd}\nВывод:\n{r.stdout}")
def exists(cmd_list):
"""Проверяет наличие команды в PATH"""
import shutil
return bool(shutil.which(cmd_list[0]) if isinstance(cmd_list, list) else shutil.which(cmd_list))
+156
View File
@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
"""
state.py — Управление состоянием (FSM) и resume
Состояние хранится в JSON файле рядом со скриптом (vars/lib)
"""
import os
import json
import sys
import time
from datetime import datetime
STATE_FILE = None
def _state_path():
global STATE_FILE
if STATE_FILE:
return STATE_FILE
# Ищем рядом со скриптом или в /var/lib/migrate
candidates = [
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".migrate-state.json"),
os.path.join(os.path.expanduser("~"), ".migrate", "state.json"),
"/var/lib/migrate/state.json",
]
for c in candidates:
d = os.path.dirname(c)
if os.path.isdir(d):
STATE_FILE = c
return c
try:
os.makedirs(d, exist_ok=True)
STATE_FILE = c
return c
except PermissionError:
continue
fallback = os.path.join(os.getcwd(), ".migrate-state.json")
STATE_FILE = fallback
return fallback
def load_state():
sp = _state_path()
if os.path.isfile(sp):
try:
with open(sp, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError:
pass
return _default_state()
def _default_state():
return {
"stage": "INIT",
"mode": None,
"manifest_path": None,
"archive_path": None,
"target_host": None,
"target_user": None,
"target_port": 22,
"last_error": None,
"completed_steps": [],
"paused": False,
"created_at": datetime.now().isoformat(),
}
def save_state(state):
sp = _state_path()
state["updated_at"] = datetime.now().isoformat()
with open(sp, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2, ensure_ascii=False)
def set_stage(stage, **kwargs):
state = load_state()
state["stage"] = stage
for k, v in kwargs.items():
state[k] = v
save_state(state)
def mark_completed(step):
state = load_state()
if step not in state["completed_steps"]:
state["completed_steps"].append(step)
save_state(state)
def is_completed(step):
return step in load_state().get("completed_steps", [])
def set_error(step, stdout, stderr, suggestion=""):
state = load_state()
state["paused"] = True
state["last_error"] = {
"step": step,
"timestamp": datetime.now().isoformat(),
"stdout": stdout,
"stderr": stderr,
"suggestion": suggestion,
}
save_state(state)
def clear_error():
state = load_state()
state["paused"] = False
state["last_error"] = None
save_state(state)
def show_status():
from core.color import info, warn, error as cerror, bold, divider
state = load_state()
div = divider
print("\n")
div()
info(f"Текущий stage: {bold(state.get('stage', 'INIT'))}")
info(f"Режим: {bold(state.get('mode') or 'не выбран')}")
info(f"Completed steps: {', '.join(state.get('completed_steps', [])) or '(none)'}")
if state.get("paused"):
warn("Скрипт на паузе из-за ошибки")
if state.get("manifest_path"):
info(f"Manifest: {state['manifest_path']}")
if state.get("archive_path"):
info(f"Archive: {state['archive_path']}")
if state.get("last_error"):
err = state["last_error"]
cerror(f"Последняя ошибка на шаге: {bold(err['step'])}")
print(f" Время: {err['timestamp']}")
if err.get("suggestion"):
print(f" Рекомендация: {err['suggestion']}")
div()
def show_logs():
state = load_state()
err = state.get("last_error")
if not err:
print("Нет сохранённых ошибок")
return
from core.color import error as cerror, bold
cerror(f"=== Лог ошибки (шаг: {bold(err['step'])}) ===")
print(f"Время: {err['timestamp']}\n")
if err.get("stdout"):
print("STDOUT:")
print(err["stdout"])
if err.get("stderr"):
print("\nSTDERR:")
print(err["stderr"])
if err.get("suggestion"):
print(f"\nРекомендация:")
print(err["suggestion"])