2026-07-27 15:22:37 +03:00 | | | '''
|
| | | File: deploy_service.py
|
| | | Description: Разворачивает сервис ServiceStateWriter по всем терминалам из
|
| | | config.json - обновляет исходный код через git, компилирует его через
|
| | | MetaEditor (ME) и раскладывает config/services.ini.
|
| | | '''
|
| | |
|
2026-10-31 14:22:28 +03:00 | | | __version__ = '0.4.0'
|
2026-07-27 15:22:37 +03:00 | | |
|
| | | import argparse
|
2026-10-31 14:22:28 +03:00 | | | import os
|
| | | import re
|
2026-07-27 15:22:37 +03:00 | | | import shutil
|
| | | import subprocess
|
| | | import sys
|
2026-10-31 14:22:28 +03:00 | | | import uuid
|
2026-07-27 15:22:37 +03:00 | | | from pathlib import Path
|
| | |
|
2026-10-31 07:26:44 +03:00 | | | from config import ConfigStore, instance_directory
|
2026-07-27 15:22:37 +03:00 | | | 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')
|
| | |
|
| | |
|
2026-10-31 14:22:28 +03:00 | | | 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'<service>.*?</service>', current, re.I | re.S))
|
| | | if (len(re.findall(r'<service>', current, re.I)) != len(blocks)
|
| | | or len(re.findall(r'</service>', 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'<service>.*?</service>', 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, '</service>')
|
| | | if (len(re.findall(r'<inputs>', entry, re.I)) != len(re.findall(r'</inputs>', entry, re.I))
|
| | | or len(re.findall(r'<inputs>', entry, re.I)) > 1):
|
| | | raise ValueError('Некорректная секция inputs в services.ini; файл не изменён.')
|
| | | if not re.search(r'<inputs>.*?</inputs>', entry, re.I | re.S):
|
| | | entry = re.sub(r'</service>', '<inputs>\n</inputs>\n</service>', 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, '</inputs>')
|
| | | 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)
|
| | |
|
| | |
|
2026-10-31 06:48:02 +03:00 | | | 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:
|
2026-10-29 14:39:39 +03:00 | | | return subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
2026-07-27 15:22:37 +03:00 | | |
|
| | |
|
| | | 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')
|
| | |
|
| | |
|
2026-10-31 06:48:02 +03:00 | | | def compile_service(metaeditor: Path, mq5_path: Path, ex5_path: Path, *, force: bool = False) -> bool:
|
| | | '''Собирает сервис и проверяет обновление EX5, а не только код возврата ME.'''
|
| | | log_path = mq5_path.parent / 'compile.log'
|
2026-07-27 15:22:37 +03:00 | | |
|
| | | if not metaeditor.exists():
|
| | | print(f'ОШИБКА: не найден {metaeditor}')
|
| | | return False
|
| | | if not mq5_path.exists():
|
| | | print(f'ОШИБКА: не найден {mq5_path}')
|
| | | return False
|
| | |
|
2026-10-31 06:48:02 +03:00 | | | before = ex5_path.stat().st_mtime_ns if ex5_path.is_file() else None
|
2026-07-27 15:22:37 +03:00 | | |
|
2026-10-28 21:13:30 +03:00 | | | # Если .ex5 уже новее исходника (например, его собрали вручную в
|
| | | # MetaEditor по F7), повторная компиляция не нужна
|
2026-10-31 06:48:02 +03:00 | | | if (not force and before is not None and ex5_path.stat().st_size
|
| | | and before >= mq5_path.stat().st_mtime_ns):
|
2026-10-28 21:13:30 +03:00 | | | print(f'Компиляция пропущена, {ex5_path.name} уже новее исходника')
|
| | | return True
|
| | |
|
2026-10-29 14:39:39 +03:00 | | | try:
|
2026-10-31 06:48:02 +03:00 | | | # ME требует кавычки вокруг значения после двоеточия. Обычный
|
| | | # list2cmdline для всего аргумента с пробелами приводит к пустому запуску.
|
| | | command = (subprocess.list2cmdline([str(metaeditor)])
|
| | | + f' /portable /compile:"{mq5_path}" /log:"{log_path}"')
|
| | | run(command, timeout=120)
|
2026-10-29 14:39:39 +03:00 | | | except (OSError, subprocess.TimeoutExpired):
|
| | | print('ОШИБКА: MetaEditor не запустился или компиляция превысила 120 секунд.')
|
| | | return False
|
2026-07-27 15:22:37 +03:00 | | |
|
| | | log_text = read_log(log_path)
|
| | | log_path.unlink(missing_ok=True)
|
| | |
|
2026-10-31 06:48:02 +03:00 | | | 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:
|
2026-07-27 15:22:37 +03:00 | | | print('ОШИБКА: MetaEditor завершился, но .ex5 не обновился.')
|
2026-10-31 06:48:02 +03:00 | | | print('Откройте '
|
2026-07-27 15:22:37 +03:00 | | | 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
|
| | |
|
| | |
|
2026-10-31 06:48:02 +03:00 | | | 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)
|
| | |
|
| | |
|
2026-07-27 15:22:37 +03:00 | | | 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
|
| | |
|
| | |
|
2026-10-31 14:22:28 +03:00 | | | 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}')
|
2026-07-27 15:22:37 +03:00 | | | return False
|
2026-10-31 14:22:28 +03:00 | | | print(' services.ini: автозапуск настроен')
|
2026-07-27 15:22:37 +03:00 | | | 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()
|
| | |
|
2026-10-31 06:48:02 +03:00 | | | data = ConfigStore(args.config_file).data
|
2026-07-27 15:22:37 +03:00 | | |
|
2026-10-31 07:26:44 +03:00 | | | terminal_dirs = {name: instance_directory(data['mt5_folder'], name) for name in data['terminals']}
|
2026-07-27 15:22:37 +03:00 | | |
|
| | | 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=== Компиляция ===')
|
2026-10-31 06:48:02 +03:00 | | | editor, source, ex5_path = service_build_paths(repo_root, data)
|
| | | compiled = compile_service(editor, source, ex5_path)
|
2026-07-27 15:22:37 +03:00 | | | ok = ok and compiled
|
| | |
|
| | | print('\n=== Раскладка .ex5 и services.ini ===')
|
| | | services_ini_src = repo_root / SERVICES_INI_REL
|
2026-10-31 14:22:28 +03:00 | | | 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')}
|
2026-07-27 15:22:37 +03:00 | | | for name, terminal_dir in terminal_dirs.items():
|
| | | if not terminal_dir.exists():
|
| | | continue
|
| | | print(f'--- {name} ---')
|
2026-10-31 06:48:02 +03:00 | | | if compiled and (terminal_dir / SERVICE_EX5_REL).resolve() != ex5_path.resolve():
|
2026-07-27 15:22:37 +03:00 | | | if not deploy_ex5(terminal_dir, ex5_path):
|
| | | ok = False
|
2026-10-31 14:22:28 +03:00 | | | if not deploy_services_ini(terminal_dir, services_ini_src, data['database'], running=name in running_now):
|
2026-07-27 15:22:37 +03:00 | | | 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()
|