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

135
README.md Normal file
View File

@@ -0,0 +1,135 @@
# docker-migrate
Универсальный интерактивный инструмент миграции Docker-сервисов между Ubuntu-серверами.
## Что делает
Этот скрипт переносит **не только Docker-контейнер**, а **всё окружение сервиса**:
- Docker Compose, .env, volumes, bind mounts
- Nginx конфиги, SSL-сертификаты
- Systemd unit-файлы
- Cron-задания
- **Sidecar-процессы** (WARP, wireproxy, SOCKS-прокси, и т.д.) — найденные через loopback-соединения
- Сетевую конфигурацию (iptables, routes, sysctl)
## Быстрый старт (одна команда из Gitea)
```bash
wget -qO- https://giteas.stitch505.su/Stitch505/docker-migrate/raw/main/install.sh | bash
```
Или через curl:
```bash
curl -fsSL https://giteas.stitch505.su/Stitch505/docker-migrate/raw/main/install.sh | bash
```
`install.sh` сам:
1. Проверит Python3 (при необходимости — `apt-get install -y python3`)
2. Скачает репозиторий (git clone / curl zip / wget zip) в `/opt/docker-migrate`
3. Поставит symlink `/usr/local/bin/docker-migrate`
4. Сразу запустит `migrate`
Repository: https://giteas.stitch505.su/Stitch505/docker-migrate
## Как пользоваться
На старом сервере (source):
```bash
docker-migrate
# или: python3 /opt/docker-migrate/migrate
# Выбираем: 1 — Подготовка к переносу (Source)
```
Скрипт:
1. Запрашивает имя Docker-сервиса/контейнера
2. Автоматически найдет всё окружение
3. Покажет манифест (что будет перенесено)
4. Попросит подтверждения
5. Соберёт tar.gz архив
6. Предложит остановить сервис
7. Предложит передать на новый сервер
На новом сервере (target):
```bash
docker-migrate --mode=target --remote-dir=/tmp/docker-migrate-incoming
```
Или интерактивно:
```bash
docker-migrate
# Выбираем: 2 — Восстановление (Target)
```
Скрипт:
1. Проверит Ubuntu и права
2. Установит Docker, Compose, nginx (если нужно)
3. Сделает backup существующего
4. Восстановит файлы
5. Запустит сервис и проверит логи
## Resume (если ошибка)
```bash
python3 migrate --resume
```
Или:
```bash
python3 migrate
# Выбираем: 3 — Продолжить (Resume)
```
## Статус и логи
```bash
python3 migrate --status
python3 migrate --logs
```
## Архитектура
```
docker-migrate/
├── migrate # bootstrap (python3)
├── migrate.sh # bash wrapper (опционально)
├── core/
│ ├── main.py # точка входа, меню, CLI
│ ├── fsm.py # state machine (resume)
│ ├── state.py # JSON state.json
│ ├── color.py # цветной вывод и UI
│ └── runner.py # shell exec с логами
├── discover/
│ ├── docker.py # docker inspect, compose, mounts
│ ├── nginx.py # nginx -T parser
│ └── network.py # loopback, sidecar, routes, iptables
├── manifest/
│ └── manifest.py # build/review JSON manifest
├── source/
│ └── source.py # source-режим (pack, stop, transfer)
├── transfer/
│ └── transfer.py # scp/rsync adaptive transfer
└── target/
└── target.py # target-режим (install, restore, verify)
```
## Зависимости
- `python3 >= 3.8`
- `docker` (на source)
- `ssh` / `scp` / `rsync` (для transfer)
- `sudo` или root (для target)
## Принципы
- **Модульность**, не монолит
- **Универсальность**, не заточен под один сервис
- **Автопоиск** вместо ручного выбора
- **Полный манифест** вместо тупого копирования
- **Resume** вместо "начни сначала"

0
core/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

136
core/color.py Normal file
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
core/fsm.py Normal file
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
core/main.py Normal file
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
core/runner.py Normal file
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
core/state.py Normal file
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"])

0
discover/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

225
discover/docker.py Normal file
View File

@@ -0,0 +1,225 @@
# -*- coding: utf-8 -*-
"""
docker.py — Discovery Docker контейнеров, compose, volumes, сети
"""
import json
import os
import re
from core.runner import run, run_json, exists
from core.color import info, warn, success, log_cmd
def find_container(name_or_id):
"""
Ищет контейнер по имени (частичное совпадение) или ID.
Возвращает tuple (container_id, container_name, inspect_dict).
"""
if not exists("docker"):
raise RuntimeError("Docker не найден на сервере")
# Сначала exact id или name
try:
out = run_json(f"docker inspect --type=container {name_or_id}")
if out and len(out) > 0:
c = out[0]
return c["Id"], c["Name"].lstrip("/"), c
except RuntimeError:
pass
# Частичный поиск по имени
ps_out = run("docker ps -a --format json", check=False)
lines = [ln for ln in ps_out.stdout.strip().splitlines() if ln.strip()]
matches = []
for ln in lines:
try:
item = json.loads(ln)
names = item.get("Names", "")
image = item.get("Image", "")
cid = item.get("ID", "")
if name_or_id.lower() in names.lower() or name_or_id.lower() in image.lower():
matches.append((cid, names))
except json.JSONDecodeError:
continue
if not matches:
raise RuntimeError(f"Контейнер '{name_or_id}' не найден среди docker ps -a")
if len(matches) == 1:
cid, cname = matches[0]
out = run_json(f"docker inspect {cid}")
c = out[0]
return c["Id"], c["Name"].lstrip("/"), c
else:
from core.color import prompt
print("Найдено несколько контейнеров:")
for i, (cid, cname) in enumerate(matches, 1):
print(f" {i}. {cname} ({cid[:12]})")
sel = prompt("Выберите номер")
idx = int(sel) - 1
cid, cname = matches[idx]
out = run_json(f"docker inspect {cid}")
c = out[0]
return c["Id"], c["Name"].lstrip("/"), c
def get_compose_file_from_container(inspect):
"""
Пытается найти compose-файл по Labels или по рабочей директории контейнера.
"""
labels = inspect.get("Config", {}).get("Labels", {}) or {}
# Compose labels: com.docker.compose.project.working_dir
working_dir = labels.get("com.docker.compose.project.working_dir")
compose_file = labels.get("com.docker.compose.project.config_files")
if compose_file and os.path.isfile(compose_file):
return compose_file
# Fallback: ищем docker-compose.yml / compose.yml рядом с working_dir
if working_dir and os.path.isdir(working_dir):
for fname in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
fp = os.path.join(working_dir, fname)
if os.path.isfile(fp):
return fp
# Fallback по Env (PWD если контейнер запущен через compose)
env = inspect.get("Config", {}).get("Env", [])
pwd = None
for e in env:
if e.startswith("PWD="):
pwd = e.split("=", 1)[1]
break
if pwd and os.path.isdir(pwd):
for fname in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
fp = os.path.join(pwd, fname)
if os.path.isfile(fp):
return fp
return None
def get_env_file(compose_path, inspect):
"""
Ищет .env рядом с compose-файлом или в working_dir.
"""
candidates = []
if compose_path:
candidates.append(os.path.join(os.path.dirname(compose_path), ".env"))
labels = inspect.get("Config", {}).get("Labels", {}) or {}
wd = labels.get("com.docker.compose.project.working_dir")
if wd:
candidates.append(os.path.join(wd, ".env"))
env_list = inspect.get("Config", {}).get("Env", [])
pwd = None
for e in env_list:
if e.startswith("PWD="):
pwd = e.split("=", 1)[1]
break
if pwd:
candidates.append(os.path.join(pwd, ".env"))
for c in candidates:
if os.path.isfile(c):
return c
return None
def get_mounts_and_volumes(inspect):
"""
Возвращает список dict: {type: bind|volume|tmpfs, source, destination, mode}
"""
mounts = inspect.get("Mounts", []) or []
result = []
for m in mounts:
result.append({
"type": m.get("Type", "unknown"),
"source": m.get("Source", ""),
"destination": m.get("Destination", ""),
"mode": m.get("Mode", ""),
})
return result
def get_ports(inspect):
"""
Возвращает словарь host_port -> container_port/proto.
"""
hostconfig = inspect.get("HostConfig", {})
port_bindings = hostconfig.get("PortBindings", {}) or {}
result = {}
for container_port_proto, bindings in port_bindings.items():
if bindings:
for b in bindings:
hp = b.get("HostPort")
ha = b.get("HostIp", "0.0.0.0")
result[f"{ha}:{hp}"] = container_port_proto
return result
def get_networks(inspect):
nets = inspect.get("NetworkSettings", {}).get("Networks", {}) or {}
return list(nets.keys())
def get_host_config_flags(inspect):
hc = inspect.get("HostConfig", {})
return {
"network_mode": hc.get("NetworkMode"),
"privileged": hc.get("Privileged"),
"cap_add": hc.get("CapAdd", []),
"devices": hc.get("Devices", []),
"restart_policy": hc.get("RestartPolicy", {}),
"pid_mode": hc.get("PidMode"),
"ipc_mode": hc.get("IpcMode"),
}
def get_image(inspect):
return inspect.get("Config", {}).get("Image", inspect.get("Image", ""))
def discover_docker(name_or_id):
"""
Главная функция. Возвращает dict с полной информацией docker-части.
"""
info(f"Ищем Docker контейнер: {name_or_id} ...")
cid, cname, inspect = find_container(name_or_id)
success(f"Найден контейнер: {cname} ({cid[:12]})")
compose_file = get_compose_file_from_container(inspect)
env_file = get_env_file(compose_file, inspect) if compose_file else None
mounts = get_mounts_and_volumes(inspect)
ports = get_ports(inspect)
networks = get_networks(inspect)
hostcfg = get_host_config_flags(inspect)
image = get_image(inspect)
data = {
"container_id": cid,
"container_name": cname,
"image": image,
"status": inspect.get("State", {}).get("Status", "unknown"),
"compose_file": compose_file,
"env_file": env_file,
"mounts": mounts,
"ports": ports,
"networks": networks,
"host_config": hostcfg,
"labels": inspect.get("Config", {}).get("Labels", {}),
}
if compose_file:
success(f"Найден compose-файл: {compose_file}")
else:
warn("Compose-файл не найден автоматически (возможно, запуск через docker run)")
if env_file:
success(f"Найден .env: {env_file}")
return data
def get_container_pid(cid):
"""Возвращает PID контейнера на хосте (для nsenter)"""
try:
out = run(f"docker inspect -f '{{{{.State.Pid}}}}' {cid}", check=False)
return int(out.stdout.strip())
except Exception:
return None

246
discover/network.py Normal file
View File

@@ -0,0 +1,246 @@
# -*- coding: utf-8 -*-
"""
network.py — Discovery сети хоста, loopback-proxy, sidecar, routes, iptables, sysctl
"""
import os
import re
import json
import socket
import ipaddress
from core.runner import run, exists
from core.color import info, warn, success, log_cmd
def get_container_host_connections(cid):
"""
Смотрит внутри контейнера (через nsenter -t <pid> -n) установленные/слушающие соединения.
Возвращает список: {proto, local, remote, estado}
"""
from discover.docker import get_container_pid
pid = get_container_pid(cid)
if not pid:
return []
# Проверим ss внутри netns контейнера
out = run(f"nsenter -t {pid} -n ss -tlnp", check=False)
# и established тоже
out2 = run(f"nsenter -t {pid} -n ss -tnp state established", check=False)
results = []
for src in (out, out2):
for ln in src.stdout.splitlines():
parts = ln.split()
if len(parts) < 4:
continue
# Формат: State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
try:
proto = "tcp"
local = parts[3]
remote = parts[4] if len(parts) > 4 else None
state = parts[0]
results.append({"proto": proto, "local": local, "remote": remote, "state": state})
except Exception:
continue
return results
def find_listeners_on_host(port):
"""На хосте ищет, кто слушает указанный TCP порт"""
listeners = []
out = run(f"ss -tlnp 'sport = :{port}'", check=False)
for ln in out.stdout.splitlines():
# На некоторых системах столбец users содержит pid/process
# Пример: LISTEN 0 128 127.0.0.1:40000 0.0.0.0:* users:(("warp-svc",pid=1234,fd=5))
m = re.search(r'users:\(\("([^"]+)"', ln)
if m:
proc = m.group(1)
listeners.append({"process": proc, "line": ln.strip()})
else:
# fallback: ищем pid через lsof
lof = run(f"lsof -i TCP:{port} -sTCP:LISTEN -t", check=False)
if lof.stdout.strip():
for pid in lof.stdout.strip().split():
try:
cmdline = open(f"/proc/{pid}/comm", "r").read().strip()
listeners.append({"pid": pid, "process": cmdline, "line": ln.strip()})
except Exception:
pass
return listeners
def find_sidecar_processes(cid, container_ports):
"""
Ищет sidecar-процессы на хосте, к которым контейнер стучится через loopback.
"""
info("Ищем loopback-соединения контейнера (sidecar / proxy / WARP) ...")
conns = get_container_host_connections(cid)
sidecars = []
for c in conns:
local = c.get("local", "")
if "127.0.0.1" in local or "localhost" in local or "::1" in local:
# Извлекаем порт
m = re.search(r':(\d+)$', local)
if m:
port = int(m.group(1))
listeners = find_listeners_on_host(port)
if listeners:
for l in listeners:
info(f" Контейнер подключается к {local} → процесс на хосте: {l.get('process', '?')} (pid={l.get('pid', '?')})")
sidecars.append({
"type": "loopback_listener",
"container_port_target": port,
"host_process": l.get("process"),
"host_pid": l.get("pid"),
"method": "ss_lsof",
})
if sidecars:
success(f"Найдено sidecar/loopback зависимостей: {len(sidecars)}")
return sidecars
def get_process_details(pid):
"""Собирает детали о процессе: exe, cmdline, cwd, open files, systemd unit"""
try:
base = f"/proc/{pid}"
exe = os.readlink(f"{base}/exe") if os.path.islink(f"{base}/exe") else None
cmdline = open(f"{base}/cmdline", "rb").read().replace(b'\x00', b' ').decode("utf-8", "ignore").strip()
cwd = os.readlink(f"{base}/cwd") if os.path.islink(f"{base}/cwd") else None
# open files
fds = os.listdir(f"{base}/fd")
files = []
for fd in fds:
try:
p = os.readlink(f"{base}/fd/{fd}")
if p.startswith("/") and not p.startswith("/dev/") and not p.startswith("/proc/"):
files.append(p)
except Exception:
pass
# systemd unit
unit = None
try:
cgroup = open(f"{base}/cgroup", "r").read()
for ln in cgroup.splitlines():
if ".service" in ln:
parts = ln.split(":")
if len(parts) >= 3:
# 0::/system.slice/nginx.service
m = re.search(r'/([^/]+\.service)$', parts[-1])
if m:
unit = m.group(1)
except Exception:
pass
return {"exe": exe, "cmdline": cmdline, "cwd": cwd, "files": list(set(files)), "unit": unit}
except Exception as e:
return {"error": str(e)}
def gather_host_network_info():
"""
Собирает текущее состояние сети хоста для manifest.
"""
info("Собираем сетевую конфигурацию хоста ...")
data = {}
# routes
if exists("ip"):
r = run("ip route show", check=False)
data["ip_routes"] = r.stdout.strip().splitlines()
r2 = run("ip rule show", check=False)
data["ip_rules"] = r2.stdout.strip().splitlines()
# interfaces
if exists("ip"):
r = run("ip addr show", check=False)
data["ip_addr"] = r.stdout.strip().splitlines()
# iptables
data["iptables"] = {}
for table in ("filter", "nat", "mangle", "raw"):
r = run(f"iptables -t {table} -S", check=False)
data["iptables"][table] = r.stdout.strip().splitlines()
# nftables
if exists("nft"):
r = run("nft list ruleset", check=False)
data["nftables"] = r.stdout.strip().splitlines()
# sysctl отличия от дефолта (берём всё, потом diff-анализ можно делать вручную)
r = run("sysctl -a", check=False)
data["sysctl"] = r.stdout.strip().splitlines()
return data
def find_systemd_units_related(procs):
"""
Ищет systemd unit-файлы для процессов (sidecar и других).
"""
units = []
seen = set()
for p in procs:
# p может быть dict с pid
pid = p.get("pid")
if not pid:
continue
# systemd unit через systemctl status
try:
out = run(f"systemctl status {pid}", check=False)
# строка вида: ... Loaded: loaded (/lib/systemd/system/xxx.service; ...)
for ln in out.stdout.splitlines():
if "Loaded:" in ln and ".service" in ln:
m = re.search(r'loaded\s+\(([^)]+)\)', ln)
if m:
path = m.group(1).split(";")[0].strip()
name = os.path.basename(path)
if name not in seen:
seen.add(name)
units.append({"name": name, "path": path, "related_to": p.get("process", "?")})
except Exception:
pass
return units
def gather_cron_jobs(user_hint=None):
"""
Ищет cron-задания, связанные с сервисом (по имени или пути).
Если user_hint — список подсказок (название сервиса, путь), ищем по ним.
"""
jobs = []
# crontab -l для текущего пользователя и root
for user in (os.getlogin(), "root"):
try:
out = run(f"crontab -u {user} -l", check=False)
for ln in out.stdout.splitlines():
if ln.strip().startswith("#"):
continue
if user_hint:
for hint in user_hint:
if hint.lower() in ln.lower():
jobs.append({"user": user, "line": ln.strip()})
break
else:
jobs.append({"user": user, "line": ln.strip()})
except Exception:
pass
# /etc/cron.d, /etc/cron.hourly, etc.
cron_dirs = ["/etc/cron.d", "/etc/cron.hourly", "/etc/cron.daily", "/etc/cron.weekly", "/etc/cron.monthly"]
for d in cron_dirs:
if not os.path.isdir(d):
continue
for f in os.listdir(d):
fp = os.path.join(d, f)
try:
content = open(fp, "r", encoding="utf-8", errors="ignore").read()
if user_hint:
for hint in user_hint:
if hint.lower() in content.lower():
jobs.append({"file": fp, "content": content[:500]})
break
else:
jobs.append({"file": fp, "content": content[:500]})
except Exception:
pass
return jobs

221
discover/nginx.py Normal file
View File

@@ -0,0 +1,221 @@
# -*- coding: utf-8 -*-
"""
nginx.py — Discovery nginx-конфигов через nginx -T, lsof, ss
"""
import os
import re
import json
from core.runner import run, run_json, exists
from core.color import info, warn, success, log_cmd
def find_nginx_processes():
"""Ищет запущенные nginx master/worker процессы"""
procs = []
if exists("pgrep"):
out = run("pgrep -a nginx", check=False)
for ln in out.stdout.strip().splitlines():
parts = ln.strip().split(None, 1)
if parts:
pid = parts[0]
cmd = parts[1] if len(parts) > 1 else ""
procs.append({"pid": pid, "cmd": cmd})
return procs
def get_nginx_full_config():
"""
Получает раскрытый конфиг через nginx -T.
Если nginx не запущен/невалиден — ищем через find в /etc/nginx.
"""
if not exists("nginx"):
warn("nginx не установлен")
return None
# Пробуем nginx -T
result = run("nginx -T 2>&1", check=False)
if result.returncode == 0:
return result.stdout
warn("nginx -T не удалось (возможно, конфиг невалиден). Fallback на ручной поиск конфигов.")
return None
def find_nginx_conf_files():
"""Находит все .conf и конфиги nginx для ручного анализа"""
candidates = ["/etc/nginx", "/usr/local/etc/nginx", "/opt/local/etc/nginx"]
files = []
for base in candidates:
if os.path.isdir(base):
for root, _, fnames in os.walk(base):
for f in fnames:
if f.endswith(".conf") or f.endswith(".inc"):
files.append(os.path.join(root, f))
return files
def parse_nginx_config_dump(raw_text, service_ports, service_domain_hints):
"""
Парсит выхлоп nginx -T (раскрытый конфиг).
Ищет server{} блоки, связанные с сервисом по:
- proxy_pass на localhost/127.0.0.1 + наш порт
- server_name совпадает с доменом из env
Возвращает список: {"file": ..., "server_name": ..., "proxy_pass": ..., "ssl_cert": ..., "ssl_key": ...}
"""
# nginx -T выводит каждый файл с комментарием вида:
# # configuration file /etc/nginx/conf.d/site.conf:
file_comment_re = re.compile(r"#\s*configuration file\s+(.+?):")
blocks = []
current_file = None
current_block_lines = []
lines = raw_text.splitlines()
for ln in lines:
m = file_comment_re.match(ln)
if m:
current_file = m.group(1)
continue
current_block_lines.append(ln)
# Простая регулярка: ищем server{ ... } — ловим всё между server { и }
# Это не 100%, но для раскрытого конфига достаточно.
text = raw_text
servers = []
# Ищем server-блоки более грубо: поиск от server{ до следующей закрывающей } на нулевом уровне
i = 0
while i < len(text):
idx = text.find("server {", i)
if idx == -1:
break
# найдем закрывающую }
start = idx
depth = 0
j = start
while j < len(text):
if text[j] == '{':
depth += 1
elif text[j] == '}':
depth -= 1
if depth == 0:
break
j += 1
block = text[start:j+1]
# определим файл: ищем ближайший file comment до start
file_match = None
for m in file_comment_re.finditer(text[:start]):
file_match = m.group(1)
servers.append({"file": file_match, "block": block})
i = j + 1
related = []
# port hints (например, 8000, 443)
# domain hints (например, example.com)
for s in servers:
block = s["block"]
score = 0
proxy_pass_match = re.search(r"proxy_pass\s+(\S+)", block)
sn_match = re.search(r"server_name\s+([^;]+)", block)
ssl_cert_match = re.search(r"ssl_certificate\s+([^;]+)", block)
ssl_key_match = re.search(r"ssl_certificate_key\s+([^;]+)", block)
proxy_pass = proxy_pass_match.group(1) if proxy_pass_match else None
server_name = sn_match.group(1).strip() if sn_match else None
ssl_cert = ssl_cert_match.group(1).strip() if ssl_cert_match else None
ssl_key = ssl_key_match.group(1).strip() if ssl_key_match else None
# Если proxy_pass указывает на 127.0.0.1 или localhost и порт — проверяем
if proxy_pass:
for hp in service_ports:
if str(hp) in proxy_pass:
score += 100
if "127.0.0.1" in proxy_pass or "localhost" in proxy_pass:
score += 20
# server_name совпадает с доменом
if server_name:
for hint in service_domain_hints:
if hint in server_name:
score += 80
# wildcard *.domain
if hint.startswith("*."):
domain = hint.lstrip("*.")
if domain in server_name:
score += 80
if score > 0:
related.append({
"file": s["file"],
"server_name": server_name,
"proxy_pass": proxy_pass,
"ssl_certificate": ssl_cert,
"ssl_certificate_key": ssl_key,
"raw_block": block,
"score": score,
})
return related
def discover_nginx(service_ports, service_domain_hints):
"""
Главная функция discovery nginx.
service_ports: список портов из Docker (например, [8000, 443])
service_domain_hints: список доменов из .env или labels.
Возвращает list dict.
"""
info("Ищем связанные nginx-конфиги ...")
procs = find_nginx_processes()
if procs:
success(f"Найден nginx: {len(procs)} процесс(ов)")
else:
warn("nginx-просессы не найдены")
raw = get_nginx_full_config()
if raw:
related = parse_nginx_config_dump(raw, service_ports, service_domain_hints)
if related:
success(f"Найдено связанных nginx server-блоков: {len(related)}")
return related
else:
# fallback: поиск файлов и grep proxy_pass/server_name
files = find_nginx_conf_files()
related = []
for f in files:
try:
content = open(f, "r", encoding="utf-8", errors="ignore").read()
except Exception:
continue
score = 0
for hp in service_ports:
if f":{hp}" in content or f"127.0.0.1:{hp}" in content:
score += 100
for d in service_domain_hints:
if d in content:
score += 80
if score > 0:
related.append({
"file": f,
"score": score,
"method": "fallback_grep",
})
return related
def get_nginx_systemd_unit():
"""Ищет unit-файл nginx"""
units = []
for unit in ("nginx.service", "nginx"):
out = run(f"systemctl is-active {unit}", check=False)
if out.returncode == 0 or out.stdout.strip() in ("active", "inactive"):
# Найдём файл unit
try:
uout = run(f"systemctl show {unit} -p FragmentPath --value", check=False)
path = uout.stdout.strip()
if path:
units.append({"unit": unit, "path": path})
except Exception:
pass
return units

86
install.sh Normal file
View File

@@ -0,0 +1,86 @@
#!/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
set -e
REPO_URL="https://giteas.stitch505.su/Stitch505/docker-migrate"
BRANCH="main"
INSTALL_DIR="/opt/docker-migrate"
log() { echo "[migrate-install] $*"; }
# Проверка Python3
if ! command -v python3 &>/dev/null; then
log "Python3 не найден. Пытаемся установить ..."
if command -v apt-get &>/dev/null; then
apt-get update -qq
apt-get install -y -qq python3 python3-pip
elif command -v yum &>/dev/null; then
yum install -y python3
else
echo "[FATAL] Не удалось найти/install Python3. Установите вручную."
exit 1
fi
fi
# Проверка версии Python
pyver=$(python3 -c 'import sys; print(".".join(map(str,sys.version_info[:2])))')
log "Python3 версия: $pyver"
# Проверка wget/curl/git
if command -v git &>/dev/null; then
METHOD=git
elif command -v curl &>/dev/null; then
METHOD=curl
elif command -v wget &>/dev/null; then
METHOD=wget
else
echo "[FATAL] Нужен git, curl или wget"
exit 1
fi
# Установка
if [ -d "$INSTALL_DIR" ]; then
log "Директория $INSTALL_DIR уже существует. Удаляем ..."
rm -rf "$INSTALL_DIR"
fi
mkdir -p "$INSTALL_DIR"
case "$METHOD" in
git)
log "Клонируем через git ..."
git clone --depth=1 --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
;;
curl)
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')"
mv /tmp/docker-migrate-*/* "$INSTALL_DIR/"
rm -f "$tmpzip"
;;
wget)
log "Скачиваем через wget ..."
tmpzip=$(mktemp /tmp/migrate-XXXXX.zip)
wget -q "${REPO_URL}/archive/refs/heads/${BRANCH}.zip" -O "$tmpzip" || {
wget -q "${REPO_URL}/archive/${BRANCH}.zip" -O "$tmpzip"
}
python3 -c "import zipfile; zipfile.ZipFile('$tmpzip').extractall('/tmp')"
mv /tmp/docker-migrate-*/* "$INSTALL_DIR/"
rm -f "$tmpzip"
;;
esac
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 "$@"

0
manifest/__init__.py Normal file
View File

Binary file not shown.

127
manifest/manifest.py Normal file
View File

@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
"""
manifest.py — Сборка и управление манифестом переноса
"""
import json
import os
from datetime import datetime
from core.color import info, warn, success, subheader, divider
def build_manifest(docker_data, nginx_data, sidecars, host_network, systemd_units, cron_jobs, extra_hints):
"""
Собирает единый manifest dict.
"""
manifest = {
"meta": {
"created": datetime.now().isoformat(),
"hostname": os.popen("hostname").read().strip(),
"version": "1.0",
},
"service": {
"name": docker_data.get("container_name"),
"image": docker_data.get("image"),
"status": docker_data.get("status"),
},
"docker": {
"container_id": docker_data.get("container_id"),
"compose_file": docker_data.get("compose_file"),
"env_file": docker_data.get("env_file"),
"mounts": docker_data.get("mounts", []),
"ports": docker_data.get("ports", {}),
"networks": docker_data.get("networks", []),
"host_config": docker_data.get("host_config", {}),
"labels": docker_data.get("labels", {}),
},
"nginx": nginx_data,
"sidecars": sidecars,
"host_network": host_network,
"systemd_units": systemd_units,
"cron_jobs": cron_jobs,
"extra_hints": extra_hints or [],
}
return manifest
def save_manifest(manifest, path):
with open(path, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
success(f"Manifest сохранён: {path}")
def load_manifest(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def review_manifest(manifest):
"""
Показывает пользователю обзор манифеста перед подтверждением.
"""
subheader("ОБЗОР МАНИФЕСТА (что будет перенесено)")
divider()
svc = manifest.get("service", {})
print(f" Сервис: {svc.get('name', '?')}")
print(f" Образ: {svc.get('image', '?')}")
docker = manifest.get("docker", {})
print(f"\n Compose: {docker.get('compose_file') or '(не найден)'}")
print(f" .env: {docker.get('env_file') or '(не найден)'}")
mounts = docker.get("mounts", [])
print(f"\n Mounts/volumes ({len(mounts)}):")
for m in mounts:
print(f" - [{m.get('type')}] {m.get('source')}{m.get('destination')} ({m.get('mode')})")
ports = docker.get("ports", {})
if ports:
print(f"\n Опубликованные порты:")
for host, container in ports.items():
print(f" {host}{container}")
nets = docker.get("networks", [])
print(f"\n Docker сети: {', '.join(nets) or ''}")
hc = docker.get("host_config", {})
if hc.get("network_mode"):
print(f" network_mode: {hc['network_mode']}")
if hc.get("privileged"):
print(f" privileged: {hc['privileged']}")
if hc.get("cap_add"):
print(f" cap_add: {hc['cap_add']}")
nginx = manifest.get("nginx", [])
print(f"\n Nginx связи ({len(nginx)}):")
for n in nginx:
print(f" - {n.get('file') or n.get('method', '?')} | server_name={n.get('server_name')} | proxy_pass={n.get('proxy_pass')}")
if n.get("ssl_certificate"):
print(f" SSL cert: {n['ssl_certificate']}")
print(f" SSL key: {n['ssl_certificate_key']}")
sidecars = manifest.get("sidecars", [])
if sidecars:
print(f"\n Sidecar / loopback зависимости ({len(sidecars)}):")
for s in sidecars:
print(f" - {s.get('type')}: {s.get('host_process', '?')} (pid={s.get('host_pid', '?')}, port={s.get('container_port_target', '?')})")
units = manifest.get("systemd_units", [])
if units:
print(f"\n Systemd units ({len(units)}):")
for u in units:
print(f" - {u.get('name')} ({u.get('path')})")
crons = manifest.get("cron_jobs", [])
if crons:
print(f"\n Cron ({len(crons)}):")
for c in crons[:3]:
print(f" - {c.get('file') or c.get('user', '?')}: {c.get('line', c.get('content', '?'))[:80]}")
hostnet = manifest.get("host_network", {})
if hostnet.get("ip_routes"):
print(f"\n Маршруты: {len(hostnet['ip_routes'])} записей")
if hostnet.get("ip_rules"):
print(f" Policy rules: {len(hostnet['ip_rules'])} записей")
divider()

54
migrate Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
migrate — Docker Service Migration Tool
Bootstrap: проверяет Python3 и запускает core/main.py
"""
import os
import sys
import subprocess
import shutil
import json
import textwrap
REQUIRED_PYTHON = (3, 8)
def check_python():
if sys.version_info < REQUIRED_PYTHON:
print("[FATAL] Нужен Python >= 3.8")
sys.exit(1)
def find_project_root():
script_dir = os.path.dirname(os.path.abspath(__file__))
possible = [script_dir, os.path.join(script_dir, "docker-migrate")]
for p in possible:
core = os.path.join(p, "core", "main.py")
if os.path.isfile(core):
return p
return None
def main():
check_python()
root = find_project_root()
if not root:
print("[FATAL] Не найдена папка проекта (core/main.py)")
sys.exit(1)
core_path = os.path.join(root)
sys.path.insert(0, core_path)
try:
from core.main import main as core_main
except ImportError as e:
print(f"[FATAL] Не удалось загрузить ядро: {e}")
sys.exit(1)
core_main()
if __name__ == "__main__":
main()

4
migrate.sh Normal file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
# Wrapper для запуска migrate.py через python3
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "${SCRIPT_DIR}/migrate" "$@"

0
source/__init__.py Normal file
View File

Binary file not shown.

282
source/source.py Normal file
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()

0
target/__init__.py Normal file
View File

Binary file not shown.

382
target/target.py Normal file
View File

@@ -0,0 +1,382 @@
# -*- coding: utf-8 -*-
"""
target.py — Orchestrator режима TARGET (восстановление на новом сервере)
"""
import os
import json
import re
import sys
from datetime import datetime
from core.color import header, subheader, success, warn, error as cerror, info, step, prompt, confirm, divider
from core import state
from core.runner import run, exists
_BACKUP_DIR_BASE = "/opt/migrate-backups"
_RESTORE_DIR = "/opt/migrate-restore"
def run_target_mode():
state.set_stage("INIT", mode="target")
from core.fsm import FSM
fsm = FSM(mode="target")
fsm.resume_from("INIT")
def do_preflight():
step(1, "PREFLIGHT: Оценка нового сервера")
state.set_stage("TARGET_PREFLIGHT")
# Ubuntu version
out = run("lsb_release -ds", check=False).stdout.strip()
info(f"OS: {out}")
version_match = re.search(r'(\d+\.\d+)', out)
ubuntu_version = version_match.group(1) if version_match else None
if not ubuntu_version:
warn("Не удалось определить версию Ubuntu. Продолжаем на свой риск.")
# root или sudo
uid = os.getuid()
is_root = (uid == 0)
if is_root:
info("Запущено от root")
else:
info(f"Запущено от UID={uid}. Проверяем sudo ...")
try:
# Команда должна использовать run, а не subprocess
r = run("sudo -n true", check=False)
if r.returncode == 0:
info("sudo доступно без пароля")
else:
warn("sudo может запросить пароль")
except Exception:
warn("sudo может запросить пароль")
state.set_stage("TARGET_PREFLIGHT_OK", ubuntu_version=ubuntu_version, is_root=is_root)
def do_install():
step(2, "УСТАНОВКА ПО (Docker, Compose, nginx)")
st = state.load_state()
ubuntu_version = st.get("ubuntu_version")
is_root = st.get("is_root", False)
sudo = "" if is_root else "sudo"
# Docker
if not exists("docker"):
info("Docker не найден — устанавливаем ...")
_install_docker(ubuntu_version, sudo)
else:
info("Docker уже установлен")
# docker compose plugin
if not exists("docker-compose"):
r = run("docker compose version", check=False)
if r.returncode != 0:
info("Docker Compose plugin не найден — устанавливаем ...")
_install_compose_plugin(sudo)
else:
info("Docker Compose plugin найден")
# nginx
if not exists("nginx"):
if confirm("Установить nginx", default="y"):
_install_nginx(sudo)
else:
info("nginx уже установлен")
state.set_stage("TARGET_INSTALL")
def _install_docker(ubuntu_version, sudo):
"""Официальная установка Docker из apt repo."""
cmd = f"""set -e
{sudo} apt-get update
{sudo} apt-get install -y ca-certificates curl gnupg lsb-release
{sudo} install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | {sudo} gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | {sudo} tee /etc/apt/sources.list.d/docker.list > /dev/null
{sudo} apt-get update
{sudo} apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
"""
run(cmd, shell=True, check=False)
def _install_compose_plugin(sudo):
run(f"{sudo} apt-get install -y docker-compose-plugin", check=False)
def _install_nginx(sudo):
run(f"{sudo} apt-get install -y nginx", check=False)
def do_backup_existing():
step(3, "BACKUP СУЩЕСТВУЮЩЕГО")
st = state.load_state()
is_root = st.get("is_root", False)
sudo = "" if is_root else "sudo"
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_dir = os.path.join(_BACKUP_DIR_BASE, f"pre_restore_{stamp}")
run(f"{sudo} mkdir -p {backup_dir}", check=False)
# backup nginx
if os.path.isdir("/etc/nginx"):
run(f"{sudo} tar czf {backup_dir}/nginx_backup.tar.gz -C / etc/nginx", check=False)
info("Backup /etc/nginx создан")
state.set_stage("TARGET_BACKUP", backup_dir=backup_dir)
def do_restore():
step(4, "ВОССТАНОВЛЕНИЕ")
st = state.load_state()
is_root = st.get("is_root", False)
sudo = "" if is_root else "sudo"
# Определяем директорию с архивом
remote_dir = st.get("target_remote_dir", "/tmp/docker-migrate-incoming")
if not os.path.isdir(remote_dir):
remote_dir = prompt("Укажите папку с распакованным архивом (manifest + файлы)")
manifest_file = None
for f in os.listdir(remote_dir):
if f.endswith("_manifest.json"):
manifest_file = os.path.join(remote_dir, f)
break
if not manifest_file:
raise RuntimeError("manifest.json не найден в папке архива")
manifest = json.load(open(manifest_file, "r", encoding="utf-8"))
svc_name = manifest["service"]["name"]
# Создаём рабочую директорию restore
restore_work = os.path.join(_RESTORE_DIR, svc_name, datetime.now().strftime("%Y%m%d_%H%M%S"))
run(f"{sudo} mkdir -p {restore_work}", check=False)
state.set_stage("TARGET_RESTORE", restore_work=restore_work, manifest_path=manifest_file)
# Копируем файлы откуда разложено (из tmp remote_dir)
_restore_docker_files(manifest, remote_dir, sudo)
_restore_nginx(manifest, remote_dir, sudo)
_restore_systemd(manifest, remote_dir, sudo)
_restore_cron(manifest, sudo)
_restore_sidecar_configs(manifest, remote_dir, sudo)
_restore_network_notes(manifest, restore_work)
success("Файлы восстановлены")
def _restore_docker_files(manifest, remote_dir, sudo):
cf = manifest["docker"].get("compose_file")
if cf:
dest = cf
if dest.startswith("/"):
run(f"{sudo} mkdir -p {os.path.dirname(dest)}", check=False)
src = os.path.join(remote_dir, cf.lstrip("/"))
if os.path.isfile(src):
run(f"{sudo} cp {src} {dest}", check=False)
info(f"Восстановлен compose: {dest}")
else:
warn(f"Compose в архиве не найден по пути: {src}")
ef = manifest["docker"].get("env_file")
if ef:
dest = ef
if dest.startswith("/"):
run(f"{sudo} mkdir -p {os.path.dirname(dest)}", check=False)
src = os.path.join(remote_dir, ef.lstrip("/"))
if os.path.isfile(src):
run(f"{sudo} cp {src} {dest}", check=False)
info(f"Восстановлен .env: {dest}")
else:
warn(f".env в архиве не найден по пути: {src}")
# Bind mounts
for m in manifest["docker"].get("mounts", []):
if m["type"] == "bind":
dest = m["destination"]
src_host = m["source"]
if dest.startswith("/"):
run(f"{sudo} mkdir -p {os.path.dirname(dest)}", check=False)
src = os.path.join(remote_dir, src_host.lstrip("/"))
if os.path.isfile(src):
run(f"{sudo} cp {src} {dest}", check=False)
info(f"Восстановлен mount: {dest}")
elif os.path.isdir(src):
run(f"{sudo} mkdir -p {dest}", check=False)
run(f"{sudo} cp -a {src}/* {dest}/", check=False)
info(f"Восстановлен dir mount: {dest}")
else:
warn(f"Bind mount в архиве не найден: {src}")
# Named volumes (разворачиваем из tar.gz в remote_dir)
for m in manifest["docker"].get("mounts", []):
if m["type"] == "volume":
vol_name = m["source"]
found = False
for f in os.listdir(remote_dir):
if f.startswith("vol_") and vol_name in f and f.endswith(".tar.gz"):
info(f"Разворачиваем Docker volume: {vol_name} из {f}")
run(f"docker volume create {vol_name}", check=False)
run(f"docker run --rm -v {vol_name}:/data -v {remote_dir}:/in alpine sh -c 'cd /data \u0026\u0026 tar xzf /in/{f}'", check=False)
found = True
break
if not found:
warn(f"Volume {vol_name} не найден в архиве — будет создан пустым")
def _restore_nginx(manifest, remote_dir, sudo):
for n in manifest.get("nginx", []):
f = n.get("file")
if not f:
continue
if f.startswith("/"):
run(f"{sudo} mkdir -p {os.path.dirname(f)}", check=False)
src = os.path.join(remote_dir, f.lstrip("/"))
if os.path.isfile(src):
run(f"{sudo} cp {src} {f}", check=False)
info(f"Восстановлен nginx-config: {f}")
else:
warn(f"nginx-config в архиве не найден: {src}")
# SSL
cert = n.get("ssl_certificate")
key = n.get("ssl_certificate_key")
for sslf in (cert, key):
if sslf:
if sslf.startswith("/"):
run(f"{sudo} mkdir -p {os.path.dirname(sslf)}", check=False)
ssrc = os.path.join(remote_dir, sslf.lstrip("/"))
if os.path.isfile(ssrc):
run(f"{sudo} cp {ssrc} {sslf}", check=False)
info(f"Восстановлен SSL: {sslf}")
else:
warn(f"SSL в архиве не найден: {ssrc}")
def _restore_systemd(manifest, remote_dir, sudo):
for u in manifest.get("systemd_units", []):
src_path = u.get("path")
if not src_path:
continue
if src_path.startswith("/"):
run(f"{sudo} mkdir -p {os.path.dirname(src_path)}", check=False)
src = os.path.join(remote_dir, src_path.lstrip("/"))
if os.path.isfile(src):
run(f"{sudo} cp {src} {src_path}", check=False)
run(f"{sudo} systemctl daemon-reload", check=False)
run(f"{sudo} systemctl enable {os.path.basename(src_path)}", check=False)
info(f"Восстановлен systemd unit: {src_path}")
else:
warn(f"systemd unit в архиве не найден: {src}")
def _restore_cron(manifest, sudo):
crons = manifest.get("cron_jobs", [])
if crons:
warn("Найдены cron-задания. Добавьте их вручную:")
for c in crons:
print(f" {c.get('line') or c.get('content', '?')}")
def _restore_sidecar_configs(manifest, remote_dir, sudo):
for s in manifest.get("sidecars", []):
det = s.get("details", {})
for f in det.get("files", []):
if not f.startswith("/"):
continue
if f.startswith("/"):
run(f"{sudo} mkdir -p {os.path.dirname(f)}", check=False)
src = os.path.join(remote_dir, f.lstrip("/"))
if os.path.isfile(src):
run(f"{sudo} cp {src} {f}", check=False)
info(f"Восстановлен sidecar-файл: {f}")
else:
warn(f"sidecar-файл в архиве не найден: {src}")
def _restore_network_notes(manifest, restore_work):
net = manifest.get("host_network", {})
note_file = os.path.join(restore_work, "HOST_NETWORK_NOTES.txt")
with open(note_file, "w", encoding="utf-8") as f:
f.write("HOST NETWORK SNAPSHOT FROM SOURCE SERVER\n")
f.write("==========================================\n\n")
f.write("IP Routes:\n")
for r in net.get("ip_routes", []):
f.write(f" {r}\n")
f.write("\nIP Rules:\n")
for r in net.get("ip_rules", []):
f.write(f" {r}\n")
f.write("\nIptables filter:\n")
for r in net.get("iptables", {}).get("filter", []):
f.write(f" {r}\n")
info(f"Сетевые заметки сохранены: {note_file}")
def do_verify():
step(5, "ВЕРИФИКАЦИЯ")
st = state.load_state()
is_root = st.get("is_root", False)
sudo = "" if is_root else "sudo"
manifest_path = st.get("manifest_path")
if not manifest_path:
raise RuntimeError("Manifest не найден")
manifest = json.load(open(manifest_path, "r", encoding="utf-8"))
# Nginx config check
has_nginx = bool(manifest.get("nginx"))
if has_nginx:
info("Проверяем nginx ...")
r = run(f"{sudo} nginx -t", check=False)
if r.returncode != 0:
cerror("Nginx config test НЕ ПРОШЁЛ")
print(f"\n{r.stdout}")
print(f"\n{r.stderr}")
state.set_error(
step="nginx_config_check",
stdout=r.stdout,
stderr=r.stderr,
suggestion="Проверьте SSL-сертификаты, пути include, и конфликты listen. После исправления запустите: ./migrate --resume"
)
raise RuntimeError("nginx -t failed")
success("nginx -t: OK")
run(f"{sudo} systemctl reload nginx", check=False)
# Docker compose up
cf = manifest["docker"].get("compose_file")
if cf and os.path.isfile(cf):
info("Запускаем docker compose up ...")
compose_dir = os.path.dirname(cf)
r = run(f"cd {compose_dir} \u0026\u0026 {sudo} docker compose up -d", check=False)
if r.returncode != 0:
print(f"\n{r.stdout}")
print(f"\n{r.stderr}")
state.set_error(
step="docker_compose_up",
stdout=r.stdout,
stderr=r.stderr,
suggestion="Проверьте compose-файл, доступность image, volumes. После исправления запустите: ./migrate --resume"
)
raise RuntimeError("docker compose up failed")
success("Docker compose up выполнен")
elif manifest["docker"].get("container_name"):
info("Compose не найден, пробуем docker run по параметрам контейнера ...")
# Sidecar units start
for u in manifest.get("systemd_units", []):
uname = u.get("name")
if uname:
run(f"{sudo} systemctl start {uname}", check=False)
info(f"Запущен unit: {uname}")
# Логи
cid = manifest["docker"].get("container_id")
if cid:
info("Последние логи контейнера:")
log_out = run(f"docker logs --tail 30 {cid[:12]}", check=False)
print(log_out.stdout)
success("Верификация завершена. Сервис должен работать.")
state.set_stage("DONE")

0
transfer/__init__.py Normal file
View File

Binary file not shown.

90
transfer/transfer.py Normal file
View File

@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
"""
transfer.py — Адаптивный перенос архива (scp/rsync/fallback)
"""
import os
import json
from core.color import info, success, warn, error as cerror, prompt, confirm, step
from core import state
from core.runner import run, exists
def do_transfer():
st = state.load_state()
archive_path = st.get("archive_path")
if not archive_path or not os.path.isfile(archive_path):
raise RuntimeError("Архив не найден. Сначала выполните сборку (Source → Pack).")
host = st.get("target_host")
user = st.get("target_user")
port = st.get("target_port", 22)
step(5, "ПЕРЕНОС АРХИВА")
info(f"Target: {user}@{host}:{port}")
# Определяем размер
size = os.path.getsize(archive_path)
size_mb = size / 1024 / 1024
info(f"Размер архива: {size_mb:.2f} MB")
# Определяем путь назначения
remote_dir = "/tmp/docker-migrate-incoming"
remote_path = os.path.join(remote_dir, os.path.basename(archive_path))
# Копируем bootstrap (migrate + core/) чтобы target мог запуститься
# Найдём корень проекта
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
tar_script = os.path.join(project_root, "tar_project.sh")
# Выбираем метод
if size_mb < 50 and exists("scp"):
method = "scp"
elif size_mb >= 50 and exists("rsync"):
method = "rsync"
else:
if exists("rsync"):
method = "rsync"
elif exists("scp"):
method = "scp"
else:
raise RuntimeError("Ни rsync, ни scp не найдены. Установите один из них.")
info(f"Выбран метод переноса: {method}")
ssh_opts = f"-p {port}"
if exists("ssh"):
# Проверим доступность
info("Проверяем SSH-соединение ...")
check = run(f"ssh {ssh_opts} -o ConnectTimeout=10 -o BatchMode=yes {user}@{host} 'echo ok'", check=False)
if check.returncode != 0:
warn("SSH (batch mode) не прошёл — возможно, нужен пароль.")
else:
success("SSH доступ подтверждён")
if method == "scp":
info("Копируем архив через scp ...")
run(f"scp {ssh_opts} {archive_path} {user}@{host}:{remote_path}", check=False)
else:
info("Копируем архив через rsync ...")
run(f"rsync -avz --progress -e 'ssh {ssh_opts}' {archive_path} {user}@{host}:{remote_path}", check=False)
success(f"Архив передан на {host}:{remote_path}")
# Распаковка на target во временную директорию
info("Распаковываем архив на target ...")
run(f"ssh {ssh_opts} {user}@{host} 'mkdir -p {remote_dir} \u0026\u0026 tar xzf {remote_path} -C {remote_dir}'", check=False)
# Сохраняем remote_dir в state для target
state.set_stage("TRANSFER_DONE", target_remote_dir=remote_dir)
# Предлагаем сразу запустить target-режим удалённо
if confirm("Сразу запустить восстановление на новом сервере (remote target mode)", default="y"):
info("Запускаем target-mode удалённо ...")
# Передаём скрипт на target
project_local = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
run(f"scp {ssh_opts} -r {project_local} {user}@{host}:/opt/docker-migrate-tool", check=False)
# Запуск target как remote python
run(f"ssh {ssh_opts} {user}@{host} 'cd /opt/docker-migrate-tool && python3 core/main.py --mode=target --remote-dir={remote_dir}'", check=False)
else:
success(f"Архив передан. Запустите на target: python3 core/main.py --mode=target --remote-dir={remote_dir}")