init: docker-migrate universal migration tool
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+136
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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
@@ -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"])
|
||||
Reference in New Issue
Block a user