#!/usr/bin/env python3 """Systemd entry point; never launched as a child of the web process for deployment.""" from __future__ import annotations import argparse import fcntl import json import re import subprocess import sys import repository from deployment import Deployment from state import BUSY, config, directory, now, progress, read, transaction def launch(unit: str) -> None: result = subprocess.run(['systemctl', 'start', '--no-block', unit], capture_output=True, text=True, timeout=10) if result.returncode: raise RuntimeError('无法启动更新服务,请检查 systemd 配置。') def request(cfg: dict, revision: str, actor: str) -> dict: if (directory(cfg) / 'maintenance').exists(): raise ValueError('上次更新尚未恢复,请先执行服务器恢复命令。') if actor not in cfg['owner_ids']: raise PermissionError('仅部署管理员可以更新。') if not re.fullmatch(r'[0-9a-f]{40}', revision): raise ValueError('无效的版本号。') with transaction(cfg) as state: if state['update']['status'] in BUSY: raise ValueError('已有更新正在执行。') if state.get('checking'): raise ValueError('正在检查版本,请稍后再试。') if state.get('check_error') or (state.get('latest') or {}).get('revision') != revision: raise ValueError('版本信息已变化,请重新检查更新。') if state['current']['revision'] == revision: raise ValueError('已经是最新版本。') state['update'] = {'status': 'queued', 'message': '更新已排队。', 'target_revision': revision, 'actor': actor, 'started_at': now(), 'error': None} try: launch('replypier-update.service') except Exception as exc: progress(cfg, 'failed', str(exc), finished_at=now()) raise return {'accepted': True} def request_check(cfg: dict) -> dict: with transaction(cfg) as state: if state['update']['status'] in BUSY: raise ValueError('更新正在进行,请稍后检查。') if state.get('checking'): return {'accepted': True} state['checking'] = True try: launch('replypier-update-check.service') except Exception: with transaction(cfg) as state: state['checking'] = False raise return {'accepted': True} def check(cfg: dict) -> None: try: with transaction(cfg) as state: state['checking'] = True latest = repository.fetch(cfg) current = read(cfg)['current']['revision'] repository.ensure_forward(cfg, current, latest) with transaction(cfg) as state: state.update(latest=repository.commit(cfg, latest), current=repository.commit(cfg, current), checked_at=now(), check_error=None) except Exception as exc: with transaction(cfg) as state: state.update(checked_at=now(), check_error=str(exc)) raise finally: with transaction(cfg) as state: state['checking'] = False def main() -> None: parser = argparse.ArgumentParser() parser.add_argument('action', choices=['check', 'request-check', 'request', 'apply', 'recover', 'recover-boot', 'finish-check']) parser.add_argument('--revision', default='') parser.add_argument('--actor', default='') args = parser.parse_args() cfg = config() if args.action == 'finish-check': with transaction(cfg) as state: state['checking'] = False return if args.action == 'request': result = request(cfg, args.revision, args.actor) elif args.action == 'request-check': result = request_check(cfg) else: with (directory(cfg) / 'operation.lock').open('a') as lock: # Timer checks do not queue behind an in-flight deployment. try: fcntl.flock(lock, fcntl.LOCK_EX | (fcntl.LOCK_NB if args.action == 'check' else 0)) except BlockingIOError: return if args.action == 'check': if read(cfg)['update']['status'] in BUSY: return check(cfg) elif args.action in {'recover', 'recover-boot'}: Deployment(cfg).recover(boot=args.action == 'recover-boot') else: state = read(cfg) if state['update']['status'] != 'queued': raise ValueError('没有待执行的更新。') Deployment(cfg).apply(state['update']['target_revision']) result = {'ok': True} print(json.dumps(result, ensure_ascii=False)) if __name__ == '__main__': try: main() except Exception as exc: print(json.dumps({'error': str(exc)}, ensure_ascii=False)) sys.exit(1)