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.
233 lines
10 KiB
Python
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()
|