'''Проверка Git и сборка сервиса для существующих экземпляров.''' __version__ = '0.3.0' import hashlib import os import shutil import subprocess import time import uuid from pathlib import Path from config import instance_directory from deploy_service import (SERVICE_EX5_REL, SERVICES_INI_REL, compile_service, service_build_paths, services_ini_update, write_services_ini) class ProjectUpdates: def __init__(self, project): self.project = Path(project).resolve() self.checked_at = None def git(self, *args): try: result = subprocess.run(['git', *args], cwd=self.project, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=120) except (OSError, subprocess.TimeoutExpired): raise RuntimeError('Git недоступен или операция превысила время ожидания.') from None if result.returncode: # stderr может содержать адрес с реквизитами доступа к репозиторию. raise RuntimeError(f'Не удалось выполнить git {args[0]} (код {result.returncode}). ' 'Проверьте репозиторий и доступ к нему из командной строки.') return result.stdout.strip() def repository(self): root = Path(self.git('rev-parse', '--show-toplevel')).resolve() if root != self.project: raise RuntimeError('Папка проекта должна быть корнем отдельного Git-репозитория.') branch = self.git('symbolic-ref', '--short', 'HEAD') upstream = self.git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}') remote = self.git('config', f'branch.{branch}.remote') if remote == '.': raise RuntimeError('Для ветки нужен upstream в удалённом репозитории.') ahead, behind = map(int, self.git('rev-list', '--left-right', '--count', 'HEAD...@{upstream}').split()) return {'branch': branch, 'upstream': upstream, 'remote': remote, 'revision': self.git('rev-parse', '--short', 'HEAD'), 'dirty': bool(self.git('status', '--porcelain')), 'ahead': ahead, 'behind': behind, 'checked_at': self.checked_at} def check(self): info = self.repository() self.git('fetch', '--', info['remote']) self.checked_at = int(time.time()) return self.repository() def update(self): if os.getenv('MT5_MANAGER_RELOAD') == '1': raise RuntimeError('Перезапустите менеджер без --reload перед обновлением кода.') # Включаем неотслеживаемые файлы: не прячем работу пользователя в stash. if self.repository()['dirty']: raise RuntimeError('В проекте есть локальные изменения. Сохраните их в Git перед обновлением.') info = self.check() if info['ahead']: raise RuntimeError('В локальной ветке есть свои коммиты. Объедините изменения вручную.') if not info['behind']: return {'updated': False, 'revision': info['revision']} # Повторная проверка после сетевой операции защищает от обычной правки # файлов во время fetch; конфликты дополнительно проверит сам Git. if self.repository()['dirty']: raise RuntimeError('Во время проверки появились локальные изменения. Обновление отменено.') revision = self.git('rev-parse', '@{upstream}^{commit}') self.git('merge', '--ff-only', revision) return {'updated': True, 'revision': self.git('rev-parse', '--short', 'HEAD')} @staticmethod def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None def service_status(self, data): editor, source, ex5 = service_build_paths(self.project, data) available = source.is_file() and editor.is_file() error = ('Не найден исходник ServiceStateWriter.mq5.' if not source.is_file() else 'Для сборки загрузите MetaEditor64.exe в настройках или укажите папку установки MT5.' if not editor.is_file() else '') needs_compile = (not ex5.is_file() or not ex5.stat().st_size or (source.is_file() and source.stat().st_mtime > ex5.stat().st_mtime)) expected = self.digest(ex5) pending = [] for folder in data['terminals']: directory = instance_directory(data['mt5_folder'], folder) destination = directory / SERVICE_EX5_REL try: _, _, config_pending = services_ini_update(directory, self.project / SERVICES_INI_REL, data['database']) except (OSError, ValueError): config_pending = True if expected is None or self.digest(destination) != expected or config_pending: pending.append(folder) return {'available': available, 'error': error, 'needs_compile': needs_compile, 'pending': pending, 'terminals': len(data['terminals'])} def status(self, data): try: repository = self.repository() except RuntimeError as error: repository = {'error': str(error)} return {'git': repository, 'service': self.service_status(data), 'reload_enabled': os.getenv('MT5_MANAGER_RELOAD') == '1'} def deploy(self, data, running): editor, mq5, source = service_build_paths(self.project, data) if not mq5.is_file(): raise RuntimeError('Не найден исходник ServiceStateWriter.mq5.') if not compile_service(editor, mq5, source, force=True): raise RuntimeError('Сборка сервиса не подтверждена. Проверьте MetaEditor; ' 'старый EX5 не распространялся.') if not source.is_file() or not source.stat().st_size: raise RuntimeError('MetaEditor не создал непустой EX5. Распространение отменено.') copied, failed, restart = [], [], [] errors = {} for folder in data['terminals']: directory = instance_directory(data['mt5_folder'], folder).resolve() destination = directory / SERVICE_EX5_REL temporary = None stage = 'Проверка установки терминала' try: executable = directory / data['mt5_exe'] if not executable.is_file(): raise FileNotFoundError(f'Не найден исполняемый файл терминала: {executable}') if destination.resolve() != source.resolve(): stage = 'Создание папки сервиса' destination.parent.mkdir(parents=True, exist_ok=True) temporary = destination.with_name(destination.name + '.' + uuid.uuid4().hex + '.tmp') stage = 'Копирование EX5 во временный файл' shutil.copyfile(source, temporary) stage = 'Замена EX5 в папке терминала' os.replace(temporary, destination) stage = 'Настройка автозапуска сервиса' target, text, changed = services_ini_update(directory, self.project / SERVICES_INI_REL, data['database']) if changed: if running(folder): raise ValueError('Остановите терминал и повторите развёртывание для обновления services.ini.') write_services_ini(target, text) copied.append(folder) if running(folder): restart.append(folder) except (OSError, ValueError) as error: failed.append(folder) errors[folder] = f'{stage}: {error}. Путь сервиса: {destination}' print(f'Ошибка развёртывания {folder}: {errors[folder]}', flush=True) finally: if temporary: try: temporary.unlink(missing_ok=True) except OSError as error: # Не подменяем исходную ошибку ошибкой удаления временного файла. cleanup_error = f'Не удалось удалить временный файл {temporary}: {error}' if folder not in failed: failed.append(folder) errors[folder] = (errors.get(folder, '') + ' ' + cleanup_error).strip() print(cleanup_error, flush=True) return {'copied': copied, 'failed': failed, 'errors': errors, 'restart_required': restart}