Add owner-only update controls, a read-only SSH checker and independent systemd deployment services. Build before maintenance; back up code and database; restore on failed deployment. Preserve runtime configuration and gate business writes during the switch.
128 lines
4.8 KiB
Python
128 lines
4.8 KiB
Python
#!/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
|
|
from pathlib import Path
|
|
|
|
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)
|