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()
|