''' File: deploy_service.py Description: Разворачивает сервис ServiceStateWriter по всем терминалам из config.json - обновляет исходный код через git, компилирует его через MetaEditor (ME) и раскладывает config/services.ini. ''' __version__ = '0.4.0' import argparse import os import re import shutil import subprocess import sys import uuid from pathlib import Path from config import ConfigStore, instance_directory from mt5_control import MT5_Control SCRIPT_DIR = Path(__file__).resolve().parent SHARED_REL = Path('MQL5/Shared Projects/mt5-manager') SERVICE_NAME = 'ServiceStateWriter' SERVICE_MQ5 = f'{SERVICE_NAME}.mq5' SERVICE_EX5_REL = Path('MQL5/Services/Shared Projects/mt5-manager') / f'{SERVICE_NAME}.ex5' SERVICES_INI_REL = Path('static/services.ini') def services_ini_update(terminal_dir: Path, template: Path, database: str) -> tuple[Path, str, bool]: '''Готовит запись нашего сервиса, сохраняя остальные сервисы и их параметры.''' target = terminal_dir / 'config/services.ini' try: current = target.read_text(encoding='utf-16') if target.is_file() and target.stat().st_size else '' sample = template.read_text(encoding='utf-16') except UnicodeError as error: raise ValueError('services.ini должен быть в UTF-16; файл не изменён.') from error blocks = list(re.finditer(r'.*?', current, re.I | re.S)) if (len(re.findall(r'', current, re.I)) != len(blocks) or len(re.findall(r'', current, re.I)) != len(blocks)): raise ValueError('Некорректные секции в services.ini; файл не изменён.') def field(text, key): found = re.search(rf'(?mi)^[ \t]*{re.escape(key)}[ \t]*=(.*)$', text) return found.group(1).strip() if found else '' def set_field(text, key, value, marker): pattern = rf'(?mi)^[ \t]*{re.escape(key)}[ \t]*=.*$' if re.search(pattern, text): return re.sub(pattern, lambda _: f'{key}={value}', text) return re.sub(re.escape(marker), lambda match: f'{key}={value}\n' + match.group(), text, count=1, flags=re.I) expected_path = str(SERVICE_EX5_REL.relative_to('MQL5')).replace('/', '\\') owned = [block for block in blocks if (field(block.group(), 'name').casefold() == SERVICE_NAME.casefold() or field(block.group(), 'path').replace('/', '\\').casefold() == expected_path.casefold())] if len(owned) > 1: raise ValueError('В services.ini несколько записей ServiceStateWriter; устраните дублирование.') entry = owned[0].group() if owned else sample.strip() if not re.fullmatch(r'.*?', entry, re.I | re.S): raise ValueError('Некорректный образец services.ini.') for key, value in (('name', SERVICE_NAME), ('path', expected_path), ('enabled', '1')): entry = set_field(entry, key, value, '') if (len(re.findall(r'', entry, re.I)) != len(re.findall(r'', entry, re.I)) or len(re.findall(r'', entry, re.I)) > 1): raise ValueError('Некорректная секция inputs в services.ini; файл не изменён.') if not re.search(r'.*?', entry, re.I | re.S): entry = re.sub(r'', '\n\n', entry, count=1, flags=re.I) database_name = Path(database).name if not database_name or any(character in database_name for character in '\r\n\x00'): raise ValueError('Некорректное имя базы для services.ini.') entry = set_field(entry, 'InpDatabase', database_name, '') if owned: block = owned[0] updated = current[:block.start()] + entry + current[block.end():] else: updated = current.rstrip() + '\n' + entry + '\n' return target, updated, updated != current def write_services_ini(target: Path, text: str) -> None: target.parent.mkdir(parents=True, exist_ok=True) temporary = target.with_name(target.name + '.' + uuid.uuid4().hex + '.tmp') try: temporary.write_text(text, encoding='utf-16') os.replace(temporary, target) finally: temporary.unlink(missing_ok=True) def reference_terminal(project: Path, configured: str = '') -> Path | None: '''Явная установка MT5 или установка с проектом в MQL5/Shared Projects.''' if configured: return Path(configured).resolve() project = Path(project).resolve() if (project.parent.name.casefold() == 'shared projects' and project.parent.parent.name.casefold() == 'mql5'): return project.parent.parent.parent return None def service_build_paths(project: Path, data: dict) -> tuple[Path, Path, Path]: '''Компилятор, исходник и результат сборки без привязки проекта к MT5.''' project = Path(project).resolve() uploaded_editor = Path(data.get('terminal_template', project / 'terminal-template')) / 'MetaEditor64.exe' reference = reference_terminal(project, data.get('mt5_installation', '')) editor = (uploaded_editor if uploaded_editor.is_file() or reference is None else reference / 'MetaEditor64.exe') source = project / SERVICE_MQ5 # Для Shared Projects MetaEditor переносит сервис в MQL5/Services. # В отдельном репозитории EX5 создаётся рядом с исходником. enclosing = reference_terminal(project) output = (enclosing / 'MQL5/Services/Shared Projects' / project.name / f'{SERVICE_NAME}.ex5' if enclosing else source.with_suffix('.ex5')) return editor, source, output def run(args: list | str, cwd: Path | None = None, timeout: int | None = None) -> subprocess.CompletedProcess: return subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=timeout) def git_output(args: list, cwd: Path) -> str: return run(['git', *args], cwd=cwd).stdout.strip() def current_repo_info() -> tuple[str, Path, str]: '''Определяет ветку, корень и URL репозитория по текущему рабочему дереву, из которого запущен сам скрипт - это и есть источник для развёртывания. ''' branch = git_output(['rev-parse', '--abbrev-ref', 'HEAD'], SCRIPT_DIR) repo_root = Path(git_output(['rev-parse', '--show-toplevel'], SCRIPT_DIR)) remote_url = git_output(['config', '--get', 'remote.origin.url'], SCRIPT_DIR) return branch, repo_root, remote_url def update_source(shared_dir: Path, repo_root: Path, repo_url: str, branch: str) -> bool: '''Обновляет исходный код сервиса в shared_dir до указанной ветки. Возвращает False, если что-то пошло не так и терминал стоит пропустить. Исходник в repo_root не трогаем - это и есть текущее рабочее дерево. ''' if shared_dir.resolve() == repo_root.resolve(): print(f' {shared_dir}: это текущий репозиторий, git не трогаем') return True if not (shared_dir / '.git').exists(): print(f' Клонирую {repo_url} (ветка {branch})') shared_dir.parent.mkdir(parents=True, exist_ok=True) result = run(['git', 'clone', '--branch', branch, repo_url, str(shared_dir)]) if result.returncode != 0: print(f' ОШИБКА клонирования: {result.stderr.strip()}') return False return True # Незакоммиченные изменения не трогаем - мало ли, зачем они там оказались if git_output(['status', '--porcelain'], shared_dir): print(f' {shared_dir}: есть незакоммиченные изменения, git pull пропущен') return True run(['git', 'fetch', 'origin'], shared_dir) checkout = run(['git', 'checkout', branch], shared_dir) if checkout.returncode != 0: print(f' ОШИБКА checkout {branch}: {checkout.stderr.strip()}') return False pull = run(['git', 'pull', '--ff-only', 'origin', branch], shared_dir) if pull.returncode != 0: print(f' ОШИБКА pull: {pull.stderr.strip()}') return False commit = git_output(['rev-parse', '--short', 'HEAD'], shared_dir) print(f' Обновлено до {branch} ({commit})') return True def read_log(log_path: Path) -> str: if not log_path.exists(): return '' for encoding in ('utf-16', 'utf-8', 'cp1251'): try: return log_path.read_text(encoding=encoding) except (UnicodeError, UnicodeDecodeError): continue return log_path.read_text(errors='replace') def compile_service(metaeditor: Path, mq5_path: Path, ex5_path: Path, *, force: bool = False) -> bool: '''Собирает сервис и проверяет обновление EX5, а не только код возврата ME.''' log_path = mq5_path.parent / 'compile.log' if not metaeditor.exists(): print(f'ОШИБКА: не найден {metaeditor}') return False if not mq5_path.exists(): print(f'ОШИБКА: не найден {mq5_path}') return False before = ex5_path.stat().st_mtime_ns if ex5_path.is_file() else None # Если .ex5 уже новее исходника (например, его собрали вручную в # MetaEditor по F7), повторная компиляция не нужна if (not force and before is not None and ex5_path.stat().st_size and before >= mq5_path.stat().st_mtime_ns): print(f'Компиляция пропущена, {ex5_path.name} уже новее исходника') return True try: # ME требует кавычки вокруг значения после двоеточия. Обычный # list2cmdline для всего аргумента с пробелами приводит к пустому запуску. command = (subprocess.list2cmdline([str(metaeditor)]) + f' /portable /compile:"{mq5_path}" /log:"{log_path}"') run(command, timeout=120) except (OSError, subprocess.TimeoutExpired): print('ОШИБКА: MetaEditor не запустился или компиляция превысила 120 секунд.') return False log_text = read_log(log_path) log_path.unlink(missing_ok=True) after = ex5_path.stat().st_mtime_ns if ex5_path.is_file() else None if after is None or after == before or not ex5_path.stat().st_size: print('ОШИБКА: MetaEditor завершился, но .ex5 не обновился.') print('Откройте ' f'{mq5_path} в MetaEditor вручную и нажмите F7, затем ' 'запустите скрипт заново (шаг компиляции пропустится сам,' ' раз .ex5 уже свежий).') if log_text.strip(): print(' ' + log_text.replace('\n', '\n ').strip()) return False print(f'Скомпилировано: {ex5_path}') return True def compile_reference(reference_dir: Path, *, force: bool = False) -> bool: '''Совместимость со сборкой из стандартной папки Shared Projects.''' return compile_service(reference_dir / 'MetaEditor64.exe', reference_dir / SHARED_REL / SERVICE_MQ5, reference_dir / SERVICE_EX5_REL, force=force) def deploy_ex5(terminal_dir: Path, ex5_path: Path) -> bool: dst = terminal_dir / SERVICE_EX5_REL dst.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(ex5_path, dst) print(' .ex5 скопирован') return True def deploy_services_ini(terminal_dir: Path, src: Path, database: str, *, running: bool = False) -> bool: try: target, text, changed = services_ini_update(terminal_dir, src, database) if changed: if running: raise ValueError('Остановите терминал и повторите развёртывание для обновления services.ini.') write_services_ini(target, text) except (OSError, ValueError) as error: print(f' ОШИБКА services.ini: {error}') return False print(' services.ini: автозапуск настроен') return True def main(): branch_default, repo_root, repo_url_default = current_repo_info() parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--config-file', default='config.json', help='Путь к файлу конфигурации менеджера') parser.add_argument('--branch', default=branch_default, help=f'Ветка для развёртывания (сейчас: {branch_default})') parser.add_argument('--repo-url', default=repo_url_default, help='URL репозитория для клонирования на новых терминалах') args = parser.parse_args() data = ConfigStore(args.config_file).data terminal_dirs = {name: instance_directory(data['mt5_folder'], name) for name in data['terminals']} print('=== Обновление исходного кода ===') ok = True for name, terminal_dir in terminal_dirs.items(): print(f'--- {name} ---') if not terminal_dir.exists(): print(f' ОШИБКА: папка терминала не найдена: {terminal_dir}') ok = False continue if not update_source(terminal_dir / SHARED_REL, repo_root, args.repo_url, args.branch): ok = False print('\n=== Компиляция ===') editor, source, ex5_path = service_build_paths(repo_root, data) compiled = compile_service(editor, source, ex5_path) ok = ok and compiled print('\n=== Раскладка .ex5 и services.ini ===') services_ini_src = repo_root / SERVICES_INI_REL control = MT5_Control(data['terminals'], data['mt5_folder'], data['database'], data['mt5_exe']) running_now = {name for name, info in control.load_instances().items() if info.get('pid')} for name, terminal_dir in terminal_dirs.items(): if not terminal_dir.exists(): continue print(f'--- {name} ---') if compiled and (terminal_dir / SERVICE_EX5_REL).resolve() != ex5_path.resolve(): if not deploy_ex5(terminal_dir, ex5_path): ok = False if not deploy_services_ini(terminal_dir, services_ini_src, data['database'], running=name in running_now): ok = False # Напоминание, какие терминалы сейчас запущены - у них в памяти всё ещё # старый .ex5, свежий файл на диске сам по себе не перезагрузится running = [n for n, i in control.load_instances().items() if i.get('pid')] print() if not ok: print('Готово с ошибками, см. вывод выше.') if running: print('Запущены сейчас (нужен перезапуск, чтобы подхватить новый .ex5): ' + ', '.join(running)) if ok and not running: print('Готово.') sys.exit(0 if ok else 1) if __name__ == '__main__': main()