diff --git a/Dockerfile_and_scripts.md b/Dockerfile_and_scripts.md index 52672f1..369ae3c 100644 --- a/Dockerfile_and_scripts.md +++ b/Dockerfile_and_scripts.md @@ -15,6 +15,10 @@ RUN echo "ClientAliveInterval 30" >> /etc/ssh/sshd_config ENV SSH_PORT=2229 RUN sed -i "0,/^#Port 22/s//Port ${SSH_PORT}/" /etc/ssh/sshd_config +# Утилита для синхронизации файлов проекта +COPY dsync.py /usr/local/bin/dsync +RUN chmod +x /usr/local/bin/dsync + # Создание необходимых директорий и запуск sshd RUN mkdir -p /run/sshd @@ -201,6 +205,10 @@ RUN python -c "import deepspeed; print(deepspeed.__version__)" WORKDIR /workspace +# Утилита для синхронизации файлов проекта +COPY dsync.py /usr/local/bin/dsync +RUN chmod +x /usr/local/bin/dsync + # Launch ssh server RUN mkdir -p /run/sshd CMD ["/usr/sbin/sshd", "-D"] @@ -285,6 +293,14 @@ docker exec -it deepspeed_container bash --- +## ☠️ kill.sh + +```bash +docker kill deepspeed_container +``` + +--- + > После создания этих скриптов, не забудь сделать их исполняемыми: ```bash diff --git a/README.md b/README.md index 77408fd..cbca598 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ## 🐳 Docker и скрипты -[Создаем Dockerfile и соответствующие скрипты для работы с образом](Dockerfile_and_scripts.md). +[Создаем Dockerfile и соответствующие скрипты для работы с образом](Dockerfile_and_scripts.md). Однако перед эти убеждаемся, что рядом с нашим Dockerfile'ом лежит `dsync.py` (Если нет, то копируем из папки `dsync`, также можем ознакомиться с [инструкцией](dsync/dsync.md)). --- diff --git a/dsync.md b/dsync.md new file mode 100644 index 0000000..426a72a --- /dev/null +++ b/dsync.md @@ -0,0 +1,43 @@ +Отличная идея — ты хочешь, чтобы `dsync.py` был **глобальным инструментом**, как `git`, а файлы конфигурации (`.dsyncconfig`, `.dsyncignore`) хранились **локально для каждого проекта**. +И тогда при вызове из любой директории он автоматически использует эти файлы, которые там лежат. + +--- + +## 🚀 Как это устроим + +### ✅ Структура + +``` +/usr/local/bin/dsync ← глобальный скрипт +/workspace/project1/ + ├── train.py + ├── .dsyncconfig + └── .dsyncignore + +/workspace/project2/ + ├── train2.py + ├── .dsyncconfig + └── .dsyncignore +``` + +--- + +## 🔥 Поведение + +* Ты можешь вызвать из любой папки: + + ```bash + python /opt/dsync/dsync.py init /workspace/project1 + python /opt/dsync/dsync.py add-node mlnode2_ds:/workspace/project1 + python /opt/dsync/dsync.py sync + ``` +* И скрипт **автоматически найдет в текущей директории (или выше) `.dsyncconfig`**, как это делает `git`. + + +--- + +## 📝 Полностью обновленный скрипт + +```python + +``` \ No newline at end of file diff --git a/dsync/dsync.md b/dsync/dsync.md new file mode 100644 index 0000000..d06e0d6 --- /dev/null +++ b/dsync/dsync.md @@ -0,0 +1,197 @@ +# dsync - Утилита для синхронизации директорий + +**dsync** - это инструмент командной строки для синхронизации локальных проектов с удаленными серверами через SSH и rsync. Позволяет легко управлять синхронизацией нескольких проектов с различными наборами серверов. + +## Структура проекта + +``` +/usr/local/bin/dsync ← глобальный скрипт + +/workspace/project1/ +├── train.py +├── .dsyncconfig +└── .dsyncignore + +/workspace/project2/ +├── train2.py +├── .dsyncconfig +└── .dsyncignore +``` + +## Основные возможности + +- 🚀 **Быстрая синхронизация** - использует rsync для эффективной передачи файлов +- 📊 **Проверка статуса** - показывает изменения перед синхронизацией +- 🔧 **Гибкая настройка** - каждый проект имеет свою конфигурацию +- 🚫 **Игнорирование файлов** - поддержка .dsyncignore для исключения файлов +- 🌐 **Множественные узлы** - синхронизация с несколькими серверами одновременно +- 🎨 **Цветной вывод** - понятная визуализация результатов +- ⚡ **Параллельная обработка** - проверка статуса всех узлов одновременно + +## Установка + +1. Скопируйте скрипт в глобальную директорию: +```bash +sudo cp dsync.py /usr/local/bin/dsync +sudo chmod +x /usr/local/bin/dsync +``` + +2. Убедитесь, что у вас настроены SSH-ключи для доступа к удаленным серверам без пароля ([Инструкция по настройке](../ssh_setup.md)). + +## Использование + +### Инициализация проекта + +```bash +cd /workspace/project1 +dsync init +``` + +Создает файлы: +- `.dsyncconfig` - конфигурация проекта +- `.dsyncignore` - паттерны для игнорирования файлов + +### Управление узлами + +```bash +# Добавить узлы для синхронизации +dsync add-node user@server1.com user@server2.com + +# Удалить узел +dsync del-node user@server1.com + +# Показать все узлы +dsync list-nodes +``` + +### Синхронизация + +```bash +# Проверить статус (что изменится) +dsync status + +# Выполнить синхронизацию +dsync sync +``` + +## Конфигурационные файлы + +### `.dsyncconfig` +```json +{ + "master_dir": "/workspace/project1", + "nodes": [ + "user@server1.com", + "user@server2.com" + ], + "ignore_file": ".dsyncignore" +} +``` + +### `.dsyncignore` +``` +# Системные файлы +.git/ +.gitignore +.dsyncconfig +.dsyncignore + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ + +# IDE +.vscode/ +.idea/ + +# Логи +*.log + +# Node.js +node_modules/ +.npm +``` + +## Примеры использования + +### Базовый workflow + +```bash +# 1. Инициализация нового проекта +cd /workspace/ml-project +dsync init + +# 2. Добавление серверов для обучения +dsync add-node gpu-server1.com gpu-server2.com + +# 3. Проверка что будет синхронизировано +dsync status + +# 4. Синхронизация +dsync sync +``` + +### Вывод команды status + +``` +Checking status for 2 node(s)... +-------------------------------------------------- +[OK] gpu-server1.com: Up to date + +[!] gpu-server2.com: 3 change(s) pending + NEW FILE: train.py + UPDATE FILE: config.json (content, timestamp) + DELETE: old_model.pkl + +-------------------------------------------------- +Summary: 2 active, 0 error(s), 3 total changes +Run 'dsync sync' to apply changes +``` + +### Вывод команды sync + +``` +Syncing to gpu-server1.com:/workspace/ml-project... +[OK] Successfully synced to gpu-server1.com + +Syncing to gpu-server2.com:/workspace/ml-project... +[OK] Successfully synced to gpu-server2.com + +Sync completed: 2/2 nodes successful +``` + +## Команды + +| Команда | Описание | +|---------|----------| +| `dsync init` | Инициализировать dsync в текущей директории | +| `dsync add-node [node2...]` | Добавить один или несколько узлов | +| `dsync del-node [node2...]` | Удалить один или несколько узлов | +| `dsync list-nodes` | Показать все настроенные узлы | +| `dsync status` | Показать статус синхронизации для всех узлов | +| `dsync sync` | Синхронизировать со всеми настроенными узлами | +| `dsync help` | Показать справку | + +## Особенности + +- **Удаление файлов**: Использует `rsync --delete` для удаления файлов на удаленных серверах +- **Тайм-ауты**: Автоматические тайм-ауты для избежания зависания +- **Параллельность**: Проверка статуса всех узлов происходит параллельно +- **Безопасность**: SSH-соединения с отключенной проверкой host key +- **Цветной вывод**: Зеленый для успеха, желтый для предупреждений, красный для ошибок + +## Требования + +- Python 3.6+ +- rsync +- SSH-доступ к удаленным серверам +- Настроенные SSH-ключи для автоматической авторизации + +## Примечания + +- Каждый проект должен иметь свою конфигурацию +- SSH-ключи должны быть настроены для работы без пароля ([Настройка](../ssh_setup.md)) +- Изменения в `.dsyncignore` влияют на последующие синхронизации \ No newline at end of file diff --git a/dsync/dsync.py b/dsync/dsync.py new file mode 100644 index 0000000..e77c21c --- /dev/null +++ b/dsync/dsync.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python3 + +import sys +import json +import subprocess +from pathlib import Path +import concurrent.futures + +# ANSI color codes +class Colors: + RED = '\033[31m' + GREEN = '\033[32m' + ORANGE = '\033[33m' + YELLOW = '\033[1;33m' + BLUE = '\033[34m' + MAGENTA = '\033[35m' + CYAN = '\033[36m' + WHITE = '\033[37m' + RESET = '\033[0m' + BOLD = '\033[1m' + +def colored(text, color): + """Apply color to text""" + return f"{color}{text}{Colors.RESET}" + +def find_file_up(filename: str, start_dir: Path = Path.cwd()) -> Path: + current_dir = start_dir + while current_dir != current_dir.parent: + candidate = current_dir / filename + if candidate.exists(): + return candidate + current_dir = current_dir.parent + print(colored(f"Error: {filename} not found in this directory or any parent.", Colors.RED)) + sys.exit(1) + +def load_config(): + config_path = find_file_up(".dsyncconfig") + with open(config_path) as f: + return json.load(f), config_path.parent + +def check_node_status(node, project_dir, ignore_file, master_dir): + """Check status for a single node""" + remote = f"{node}:{master_dir}" + + cmd = [ + "rsync", "-avz", "--dry-run", "--itemize-changes", "--delete", + f"--exclude-from={ignore_file}", + "-e", "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5", + f"{project_dir}/", remote + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + + if result.returncode == 0: + # Parse rsync output + lines = result.stdout.strip().split('\n') + changes = [] + + for line in lines: + if line.startswith('>f') or line.startswith('>d') or line.startswith('*deleting'): + changes.append(line) + elif line.startswith(' 5: + print(f" ... and {len(changes) - 5} more change(s)") + else: + print(colored(f"[X] {node}: {result.get('error', 'Unknown error')}", Colors.RED)) + + print() + + # Summary + active_nodes = sum(1 for r in results if r['status'] == 'ok') + error_nodes = sum(1 for r in results if r['status'] != 'ok') + + print("-" * 50) + if total_changes > 0: + print(colored(f"Summary: {active_nodes} active, {error_nodes} error(s), {total_changes} total changes", Colors.YELLOW)) + print(colored("Run 'dsync sync' to apply changes", Colors.YELLOW)) + else: + print(colored(f"Summary: {active_nodes} active, {error_nodes} error(s), {total_changes} total changes", Colors.GREEN)) + if error_nodes == 0: + print(colored("All nodes are up to date!", Colors.GREEN)) + +def parse_rsync_action(line): + """Parse rsync itemize-changes output into human-readable format""" + if line.startswith('*deleting'): + filename = line.split(' ', 1)[1].strip() + return f"DELETE: {filename}" + + if len(line) < 11: + return line.strip() + + # Parse rsync itemize format: YXcstpoguax filename + changes = line[0:11] + filename = line[11:].strip() + + if not filename: + return line.strip() + + # Check file type and direction + file_type = changes[0] + if file_type == '<': + direction = "SEND" + elif file_type == '>': + direction = "RECV" + elif file_type == 'c': + direction = "CREATE" + elif file_type == 'h': + direction = "HARDLINK" + elif file_type == '.': + direction = "UPDATE" + else: + direction = "CHANGE" + + # Parse item type + change_type = changes[1] + if change_type == 'f': + item_type = "file" + elif change_type == 'd': + item_type = "directory" + elif change_type == 'L': + item_type = "symlink" + elif change_type == 'D': + item_type = "device" + elif change_type == 'S': + item_type = "special" + else: + item_type = "item" + + # Parse specific changes + change_details = changes[2:11] + details = [] + + if change_details[0] == '+': + return f"NEW {item_type.upper()}: {filename}" + elif change_details[0] == 'c': + details.append("content") + + if change_details[1] == 's': + details.append("size") + if change_details[2] == 't': + details.append("timestamp") + if change_details[3] == 'p': + details.append("permissions") + if change_details[4] == 'o': + details.append("owner") + if change_details[5] == 'g': + details.append("group") + if change_details[6] == 'u': + details.append("unused") + if change_details[7] == 'a': + details.append("ACL") + if change_details[8] == 'x': + details.append("extended attributes") + + # Build result with direction and item type + if details: + detail_str = ", ".join(details) + return f"{direction} {item_type.upper()}: {filename} ({detail_str})" + else: + return f"{direction} {item_type.upper()}: {filename}" + +def sync(): + config, project_dir = load_config() + ignore_file = project_dir / config.get("ignore_file", ".dsyncignore") + master_dir = config.get("master_dir", str(project_dir)) + + # Ensure ignore file exists + if not ignore_file.exists(): + ignore_file.write_text("# Add patterns to ignore during sync\n") + + success_count = 0 + failed_nodes = [] + + for node in config["nodes"]: + remote = f"{node}:{master_dir}" + print(f"Syncing to {remote}...") + + cmd = [ + "rsync", "-avz", "--delete", + f"--exclude-from={ignore_file}", + "-e", "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10", + f"{project_dir}/", remote + ] + + try: + subprocess.run(cmd, check=True, capture_output=True, text=True) + print(colored(f"[OK] Successfully synced to {node}", Colors.GREEN)) + success_count += 1 + except subprocess.CalledProcessError as e: + print(colored(f"[X] Failed to sync to {node}: {e}", Colors.RED)) + failed_nodes.append(node) + + print() + if success_count == len(config['nodes']): + print(colored(f"Sync completed: {success_count}/{len(config['nodes'])} nodes successful", Colors.GREEN)) + else: + print(colored(f"Sync completed: {success_count}/{len(config['nodes'])} nodes successful", Colors.YELLOW)) + + if failed_nodes: + print(colored(f"Failed nodes: {', '.join(failed_nodes)}", Colors.RED)) + +def init(master_dir: Path = Path.cwd()): + config_file = master_dir / ".dsyncconfig" + ignore_file = master_dir / ".dsyncignore" + + if config_file.exists(): + print(colored(f"dsync already initialized in {master_dir}", Colors.YELLOW)) + return + + config = { + "master_dir": str(master_dir), + "nodes": [], + "ignore_file": ".dsyncignore" + } + + config_file.write_text(json.dumps(config, indent=2)) + + if not ignore_file.exists(): + ignore_content = """# dsync ignore patterns +# Add patterns to ignore during sync +.git/ +.gitignore +.dsyncconfig +.dsyncignore +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.vscode/ +.idea/ +*.log +node_modules/ +.npm +.cache/ +""" + ignore_file.write_text(ignore_content) + + print(colored(f"Initialized dsync config in {master_dir}", Colors.GREEN)) + +def add_nodes(*nodes): + if not nodes: + print(colored("No nodes provided", Colors.YELLOW)) + return + + config, project_dir = load_config() + added_nodes = [] + + for node in nodes: + if node not in config["nodes"]: + config["nodes"].append(node) + added_nodes.append(node) + else: + print(colored(f"Node {node} already exists", Colors.YELLOW)) + + if added_nodes: + (project_dir / ".dsyncconfig").write_text(json.dumps(config, indent=2)) + print(colored(f"Added nodes: {', '.join(added_nodes)}", Colors.GREEN)) + else: + print(colored("No new nodes added", Colors.YELLOW)) + +def del_nodes(*nodes): + if not nodes: + print(colored("No nodes provided", Colors.YELLOW)) + return + + config, project_dir = load_config() + removed_nodes = [] + + for node in nodes: + if node in config["nodes"]: + config["nodes"].remove(node) + removed_nodes.append(node) + else: + print(colored(f"Node {node} not found", Colors.YELLOW)) + + if removed_nodes: + (project_dir / ".dsyncconfig").write_text(json.dumps(config, indent=2)) + print(colored(f"Removed nodes: {', '.join(removed_nodes)}", Colors.GREEN)) + else: + print(colored("No nodes removed", Colors.YELLOW)) + +def list_nodes(): + config, _ = load_config() + nodes = config.get("nodes", []) + if nodes: + print(colored("Configured nodes:", Colors.CYAN)) + for i, node in enumerate(nodes, 1): + print(f" {i}. {node}") + else: + print(colored("No nodes configured", Colors.YELLOW)) + +def show_help(): + help_text = """ +dsync.py - Directory Synchronization Tool + +Usage: + dsync.py init Initialize dsync in current directory + dsync.py add-node [node2...] Add one or more nodes + dsync.py del-node [node2...] Remove one or more nodes + dsync.py list-nodes List all configured nodes + dsync.py status Show sync status for all nodes + dsync.py sync Sync to all configured nodes + dsync.py help Show this help message + +Examples: + dsync.py init + dsync.py add-node user@server1.com user@server2.com + dsync.py del-node user@server1.com + dsync.py status + dsync.py sync + +Notes: + - Status command shows pending changes without syncing + - Sync uses rsync with --delete flag to handle file deletions + - SSH keys should be configured for passwordless access + - Edit .dsyncignore to exclude files from sync +""" + print(help_text) + +if __name__ == "__main__": + if len(sys.argv) < 2: + show_help() + sys.exit(1) + + cmd = sys.argv[1] + + if cmd == "init": + init() + elif cmd == "add-node": + if len(sys.argv) < 3: + print(colored("Usage: dsync.py add-node [node2...]", Colors.RED)) + sys.exit(1) + add_nodes(*sys.argv[2:]) + elif cmd == "del-node": + if len(sys.argv) < 3: + print(colored("Usage: dsync.py del-node [node2...]", Colors.RED)) + sys.exit(1) + del_nodes(*sys.argv[2:]) + elif cmd == "list-nodes": + list_nodes() + elif cmd == "status": + status() + elif cmd == "sync": + sync() + elif cmd == "help": + show_help() + else: + print(colored(f"Unknown command: {cmd}", Colors.RED)) + show_help() + sys.exit(1) \ No newline at end of file