feat(updates): check Gitea automatically and deploy on owner confirmation

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.
This commit is contained in:
WangShen007 committed 2026-10-24 07:26:41 +00:00
1 parent 9b5463ecb3
commit e2bafd8bff
28 files changed
+1346 -1

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
#!/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)