2026-10-29 14:39:39 +03:00 | | | '''Проверка Git и сборка сервиса для существующих экземпляров.'''
|
| | |
|
2026-10-31 14:22:28 +03:00 | | | __version__ = '0.3.0'
|
2026-10-29 14:39:39 +03:00 | | |
|
| | | import hashlib
|
| | | import os
|
| | | import shutil
|
| | | import subprocess
|
| | | import time
|
| | | import uuid
|
| | | from pathlib import Path
|
| | |
|
2026-10-31 07:26:44 +03:00 | | | from config import instance_directory
|
2026-10-31 14:22:28 +03:00 | | | from deploy_service import (SERVICE_EX5_REL, SERVICES_INI_REL, compile_service,
|
| | | service_build_paths, services_ini_update, write_services_ini)
|
2026-10-29 14:39:39 +03:00 | | |
|
| | |
|
| | | 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):
|
2026-10-31 06:48:02 +03:00 | | | 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 '')
|
2026-10-29 14:39:39 +03:00 | | | 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 = []
|
2026-10-31 07:26:44 +03:00 | | | for folder in data['terminals']:
|
2026-10-31 14:22:28 +03:00 | | | 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:
|
2026-10-29 14:39:39 +03:00 | | | pending.append(folder)
|
2026-10-31 06:48:02 +03:00 | | | return {'available': available, 'error': error, 'needs_compile': needs_compile,
|
2026-10-29 14:39:39 +03:00 | | | '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):
|
2026-10-31 06:48:02 +03:00 | | | editor, mq5, source = service_build_paths(self.project, data)
|
| | | if not mq5.is_file():
|
2026-10-29 14:39:39 +03:00 | | | raise RuntimeError('Не найден исходник ServiceStateWriter.mq5.')
|
2026-10-31 06:48:02 +03:00 | | | if not compile_service(editor, mq5, source, force=True):
|
2026-10-29 14:39:39 +03:00 | | | raise RuntimeError('Сборка сервиса не подтверждена. Проверьте MetaEditor; '
|
| | | 'старый EX5 не распространялся.')
|
| | | if not source.is_file() or not source.stat().st_size:
|
| | | raise RuntimeError('MetaEditor не создал непустой EX5. Распространение отменено.')
|
| | | copied, failed, restart = [], [], []
|
2026-10-31 07:14:06 +03:00 | | | errors = {}
|
2026-10-31 07:26:44 +03:00 | | | for folder in data['terminals']:
|
| | | directory = instance_directory(data['mt5_folder'], folder).resolve()
|
2026-10-29 14:39:39 +03:00 | | | destination = directory / SERVICE_EX5_REL
|
| | | temporary = None
|
2026-10-31 07:14:06 +03:00 | | | stage = 'Проверка установки терминала'
|
2026-10-29 14:39:39 +03:00 | | | try:
|
2026-10-31 07:14:06 +03:00 | | | executable = directory / data['mt5_exe']
|
| | | if not executable.is_file():
|
| | | raise FileNotFoundError(f'Не найден исполняемый файл терминала: {executable}')
|
2026-10-29 14:39:39 +03:00 | | | if destination.resolve() != source.resolve():
|
2026-10-31 07:14:06 +03:00 | | | stage = 'Создание папки сервиса'
|
2026-10-29 14:39:39 +03:00 | | | destination.parent.mkdir(parents=True, exist_ok=True)
|
| | | temporary = destination.with_name(destination.name + '.' + uuid.uuid4().hex + '.tmp')
|
2026-10-31 07:14:06 +03:00 | | | stage = 'Копирование EX5 во временный файл'
|
2026-10-29 14:39:39 +03:00 | | | shutil.copyfile(source, temporary)
|
2026-10-31 07:14:06 +03:00 | | | stage = 'Замена EX5 в папке терминала'
|
2026-10-29 14:39:39 +03:00 | | | os.replace(temporary, destination)
|
2026-10-31 14:22:28 +03:00 | | | 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)
|
2026-10-29 14:39:39 +03:00 | | | copied.append(folder)
|
| | | if running(folder):
|
| | | restart.append(folder)
|
2026-10-31 14:22:28 +03:00 | | | except (OSError, ValueError) as error:
|
2026-10-29 14:39:39 +03:00 | | | failed.append(folder)
|
2026-10-31 07:14:06 +03:00 | | | errors[folder] = f'{stage}: {error}. Путь сервиса: {destination}'
|
| | | print(f'Ошибка развёртывания {folder}: {errors[folder]}', flush=True)
|
2026-10-29 14:39:39 +03:00 | | | finally:
|
| | | if temporary:
|
2026-10-31 07:14:06 +03:00 | | | 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}
|