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:
1 parent
ed8dc01ed9
commit
f3ad539998
28 files changed
+1346
-1
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user