mt5-manager/deploy_service.py
Yuriy Bykov 4a55946f8a Add deploy_service.py to automate service redeployment
Wraps the manual steps we kept redoing after every schema/service
change: sync each terminal's Shared Projects copy via git, compile the
service, and drop config/services.ini into place.

Per-terminal headless MetaEditor compilation proved unreliable in
testing - it can exit 0 without producing a log or touching the .ex5,
seemingly needing an interactive desktop session. The script compiles
once against the repo's own working tree instead and copies the
resulting .ex5 to the other terminals, verifying success by checking
the file's mtime actually changed rather than trusting the exit code.
2026-07-27 15:22:37 +03:00

233 lines
10 KiB
Python

'''
File: deploy_service.py
Description: Разворачивает сервис ServiceStateWriter по всем терминалам из
config.json - обновляет исходный код через git, компилирует его через
MetaEditor (ME) и раскладывает config/services.ini.
'''
__version__ = '0.1.0'
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
from config import Config
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 run(args: list, cwd: Path | None = None) -> subprocess.CompletedProcess:
return subprocess.run(args, cwd=cwd, capture_output=True, text=True)
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_reference(reference_dir: Path) -> bool:
'''Компилирует сервис один раз в reference_dir - терминале, в дереве
которого физически лежит исходник (repo_root). Дальше получившийся
.ex5 просто копируется по остальным терминалам.
Компиляция отдельно на каждом терминале через CLI ME оказалась
ненадёжной на практике: команда иногда завершается кодом 0, не создавая
ни лога, ни нового .ex5 - похоже, headless-запуску не хватает
интерактивной сессии рабочего стола. Поэтому здесь всего один вызов ME
и явная проверка по времени изменения файла, а не только по коду
завершения.
'''
metaeditor = reference_dir / 'MetaEditor64.exe'
mq5_path = reference_dir / SHARED_REL / SERVICE_MQ5
ex5_path = reference_dir / SERVICE_EX5_REL
log_path = reference_dir / SHARED_REL / '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 if ex5_path.exists() else None
run([str(metaeditor), f'/compile:{mq5_path}', f'/log:{log_path}'])
log_text = read_log(log_path)
log_path.unlink(missing_ok=True)
after = ex5_path.stat().st_mtime if ex5_path.exists() else None
if after is None or after == before:
print('ОШИБКА: MetaEditor завершился, но .ex5 не обновился.')
print('Headless-компиляция в этом окружении ненадёжна - откройте '
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 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) -> bool:
if not src.exists():
print(f' ОШИБКА: не найден {src}')
return False
dst_dir = terminal_dir / 'config'
dst_dir.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dst_dir / 'services.ini')
print(' services.ini разложен в config')
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()
os.environ['MT5_MANAGER_CONFIG_FILE'] = args.config_file
config = Config(_env_file=None)
terminal_dirs = {name: Path(config.mt5_folder) / name for name in config.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=== Компиляция ===')
reference_dir = repo_root.parents[len(SHARED_REL.parts) - 1]
if not (reference_dir / 'MetaEditor64.exe').exists():
print(f'ОШИБКА: {reference_dir} не похож на папку терминала (нет MetaEditor64.exe), '
'компилировать негде')
sys.exit(1)
compiled = compile_reference(reference_dir)
ok = ok and compiled
print('\n=== Раскладка .ex5 и services.ini ===')
ex5_path = reference_dir / SERVICE_EX5_REL
services_ini_src = repo_root / SERVICES_INI_REL
for name, terminal_dir in terminal_dirs.items():
if not terminal_dir.exists():
continue
print(f'--- {name} ---')
if compiled and terminal_dir.resolve() != reference_dir.resolve():
if not deploy_ex5(terminal_dir, ex5_path):
ok = False
if not deploy_services_ini(terminal_dir, services_ini_src):
ok = False
# Напоминание, какие терминалы сейчас запущены - у них в памяти всё ещё
# старый .ex5, свежий файл на диске сам по себе не перезагрузится
control = MT5_Control(config.terminals, config.mt5_folder, config.database, config.mt5_exe)
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()