init: docker-migrate universal migration tool
This commit is contained in:
135
README.md
Normal file
135
README.md
Normal 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
0
core/__init__.py
Normal file
BIN
core/__pycache__/color.cpython-312.pyc
Normal file
BIN
core/__pycache__/color.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/fsm.cpython-312.pyc
Normal file
BIN
core/__pycache__/fsm.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/main.cpython-312.pyc
Normal file
BIN
core/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/runner.cpython-312.pyc
Normal file
BIN
core/__pycache__/runner.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/state.cpython-312.pyc
Normal file
BIN
core/__pycache__/state.cpython-312.pyc
Normal file
Binary file not shown.
136
core/color.py
Normal file
136
core/color.py
Normal 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
108
core/fsm.py
Normal 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
106
core/main.py
Normal 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
73
core/runner.py
Normal 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
156
core/state.py
Normal 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
0
discover/__init__.py
Normal file
BIN
discover/__pycache__/docker.cpython-312.pyc
Normal file
BIN
discover/__pycache__/docker.cpython-312.pyc
Normal file
Binary file not shown.
BIN
discover/__pycache__/network.cpython-312.pyc
Normal file
BIN
discover/__pycache__/network.cpython-312.pyc
Normal file
Binary file not shown.
BIN
discover/__pycache__/nginx.cpython-312.pyc
Normal file
BIN
discover/__pycache__/nginx.cpython-312.pyc
Normal file
Binary file not shown.
225
discover/docker.py
Normal file
225
discover/docker.py
Normal 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
246
discover/network.py
Normal 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
221
discover/nginx.py
Normal 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
86
install.sh
Normal 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
0
manifest/__init__.py
Normal file
BIN
manifest/__pycache__/manifest.cpython-312.pyc
Normal file
BIN
manifest/__pycache__/manifest.cpython-312.pyc
Normal file
Binary file not shown.
127
manifest/manifest.py
Normal file
127
manifest/manifest.py
Normal 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
54
migrate
Normal 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
4
migrate.sh
Normal 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
0
source/__init__.py
Normal file
BIN
source/__pycache__/source.cpython-312.pyc
Normal file
BIN
source/__pycache__/source.cpython-312.pyc
Normal file
Binary file not shown.
282
source/source.py
Normal file
282
source/source.py
Normal 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
0
target/__init__.py
Normal file
BIN
target/__pycache__/target.cpython-312.pyc
Normal file
BIN
target/__pycache__/target.cpython-312.pyc
Normal file
Binary file not shown.
382
target/target.py
Normal file
382
target/target.py
Normal 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
0
transfer/__init__.py
Normal file
BIN
transfer/__pycache__/transfer.cpython-312.pyc
Normal file
BIN
transfer/__pycache__/transfer.cpython-312.pyc
Normal file
Binary file not shown.
90
transfer/transfer.py
Normal file
90
transfer/transfer.py
Normal 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}")
|
||||
Reference in New Issue
Block a user