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)
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Prepare without downtime; back up, switch, verify and recover independently of FastAPI."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import repository
|
||||
from state import BUSY, directory, now, progress, read, transaction
|
||||
|
||||
|
||||
class Deployment:
|
||||
def __init__(self, cfg: dict[str, Any]):
|
||||
self.cfg = cfg
|
||||
self.root = Path(cfg['project_dir'])
|
||||
self.state_dir = directory(cfg)
|
||||
self.log = self.state_dir / 'update.log'
|
||||
|
||||
def run(self, args: list[str], *, cwd: Path | None = None,
|
||||
env: dict[str, str] | None = None, timeout: int = 1800,
|
||||
stdin: Any = None, stdout: Any = None) -> None:
|
||||
with self.log.open('ab') as log:
|
||||
result = subprocess.run(args, cwd=cwd, env={**os.environ, **(env or {})},
|
||||
stdin=stdin, stdout=stdout or log, stderr=log, timeout=timeout)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f'{args[0]} 执行失败(退出码 {result.returncode}),请查看服务器更新日志。')
|
||||
|
||||
def services(self, action: str) -> None:
|
||||
self.run(['systemctl', action, 'replypier-backend.service', 'replypier-knowledge.service'], timeout=90)
|
||||
|
||||
def healthy(self) -> None:
|
||||
for _ in range(45):
|
||||
try:
|
||||
with urllib.request.urlopen('http://127.0.0.1:8018/health', timeout=3) as response:
|
||||
if json.load(response).get('status') == 'healthy':
|
||||
return
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
time.sleep(2)
|
||||
raise RuntimeError('新版本后端未通过健康检查。')
|
||||
|
||||
def prepare(self, target: str) -> Path:
|
||||
stage = self.state_dir / 'releases' / f'{target}-{int(time.time())}'
|
||||
stage.mkdir(parents=True)
|
||||
archive = stage / 'source.tar'
|
||||
self.run(['git', '--git-dir', str(self.state_dir / 'repository.git'),
|
||||
'archive', '--format=tar', target, '-o', str(archive)])
|
||||
source = stage / 'source'
|
||||
source.mkdir()
|
||||
with tarfile.open(archive) as tar:
|
||||
tar.extractall(source, filter='data')
|
||||
archive.unlink()
|
||||
self.new_paths = repository.paths(self.cfg, target)
|
||||
if any(repository.protected(p) for p in self.new_paths):
|
||||
raise RuntimeError('仓库包含受保护的密钥或运行目录,已停止更新。')
|
||||
front = source / 'frontend'
|
||||
shutil.copy2(self.root / 'frontend/.env', front / '.env')
|
||||
progress(self.cfg, 'building', '正在安装前端依赖并构建,网站仍正常接待。')
|
||||
self.run(['npm', 'ci', '--no-audit', '--no-fund'], cwd=front)
|
||||
node_env = {'NODE_OPTIONS': '--max-old-space-size=1024'}
|
||||
vite = str(front / 'node_modules/.bin/vite')
|
||||
self.run([vite, 'build', '--outDir', str(stage / 'dashboard')], cwd=front, env=node_env)
|
||||
self.run([vite, 'build', '--config', 'vite.widget.config.ts', '--outDir', str(stage / 'assets')],
|
||||
cwd=front, env=node_env)
|
||||
if not (stage / 'assets/widget.css').exists():
|
||||
styles = list((stage / 'assets/unused').glob('*.css'))
|
||||
if len(styles) != 1:
|
||||
raise RuntimeError('网站组件的样式构建结果不完整。')
|
||||
shutil.copy2(styles[0], stage / 'assets/widget.css')
|
||||
loader = stage / 'dashboard/webclient/replypier.min.js'
|
||||
loader.parent.mkdir(exist_ok=True)
|
||||
self.run([str(front / 'node_modules/.bin/esbuild'), 'src/webclient/replypier.js',
|
||||
'--bundle', '--minify', '--format=iife', '--target=es2015', '--loader:.svg=text',
|
||||
'--define:process.env.NODE_ENV="production"',
|
||||
'--define:__REPLYPIER_API_URL__=' + json.dumps(self.cfg['public_url'] + '/api/v1'),
|
||||
'--outfile=' + str(loader)], cwd=front)
|
||||
# Runtime configuration belongs to this installation, not to the repository.
|
||||
live_web = self.root / 'deploy/local/.state/dashboard'
|
||||
shutil.copy2(live_web / 'config.js', stage / 'dashboard/config.js')
|
||||
if (live_web / 'local-demo.html').exists():
|
||||
shutil.copy2(live_web / 'local-demo.html', stage / 'dashboard/local-demo.html')
|
||||
shutil.rmtree(front / 'node_modules')
|
||||
lock = Path('deploy/local/requirements.lock.txt')
|
||||
self.new_venv = None
|
||||
if (source / lock).read_bytes() != (self.root / lock).read_bytes():
|
||||
progress(self.cfg, 'preparing', '后端依赖已变更,正在创建独立环境,网站仍正常接待。')
|
||||
self.new_venv = stage / 'venv'
|
||||
self.run(['/usr/bin/python3', '-m', 'venv', str(self.new_venv)])
|
||||
self.run([str(self.new_venv / 'bin/python'), '-m', 'pip', 'install', '-r', str(source / lock)], timeout=3600)
|
||||
return stage
|
||||
|
||||
def backup(self, stage: Path, current: str) -> Path:
|
||||
backup = self.state_dir / 'backups' / stage.name
|
||||
backup.mkdir(parents=True)
|
||||
(backup / 'agent').mkdir()
|
||||
(backup / 'systemd').mkdir()
|
||||
for file in Path('/opt/replypier-updater').glob('*.py'):
|
||||
shutil.copy2(file, backup / 'agent' / file.name)
|
||||
for file in Path('/etc/systemd/system').glob('replypier-update*'):
|
||||
if file.is_file():
|
||||
shutil.copy2(file, backup / 'systemd' / file.name)
|
||||
old_paths = repository.paths(self.cfg, current)
|
||||
with tarfile.open(backup / 'code.tar.gz', 'w:gz') as tar:
|
||||
for path in old_paths:
|
||||
file = self.root / path
|
||||
if file.exists() and not repository.protected(path):
|
||||
tar.add(file, arcname=path, recursive=False)
|
||||
(backup / 'manifest.json').write_text(json.dumps({'old': old_paths, 'new': self.new_paths}))
|
||||
venv = self.root / 'backend/.venv'
|
||||
if venv.is_symlink():
|
||||
(backup / 'venv-link').write_text(os.readlink(venv))
|
||||
with transaction(self.cfg) as state:
|
||||
state['update']['backup_path'] = str(backup)
|
||||
state['update']['previous_revision'] = current
|
||||
state['update']['backup_ready'] = False
|
||||
progress(self.cfg, 'backing_up', '正在暂停接待并备份数据库;页面会自动恢复。')
|
||||
(self.state_dir / 'maintenance').touch()
|
||||
self.services('stop')
|
||||
with (backup / 'database.dump').open('wb') as out:
|
||||
self.run(['docker', 'exec', self.cfg['db_container'], 'pg_dump', '-Fc',
|
||||
'-U', self.cfg['db_user'], '-d', self.cfg['db_name']], stdout=out)
|
||||
with (backup / 'database.dump').open('rb') as dump:
|
||||
self.run(['docker', 'exec', '-i', self.cfg['db_container'], 'pg_restore', '--list'], stdin=dump)
|
||||
with transaction(self.cfg) as state:
|
||||
state['update']['backup_ready'] = True
|
||||
return backup
|
||||
|
||||
def switch(self, stage: Path, backup: Path) -> None:
|
||||
progress(self.cfg, 'deploying', '正在安装新版本源码。')
|
||||
manifest = json.loads((backup / 'manifest.json').read_text())
|
||||
for path in manifest['new']:
|
||||
target = self.root / path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(stage / 'source' / path, target, follow_symlinks=False)
|
||||
for path in set(manifest['old']) - set(manifest['new']):
|
||||
if not repository.protected(path):
|
||||
(self.root / path).unlink(missing_ok=True)
|
||||
if self.new_venv:
|
||||
venv = self.root / 'backend/.venv'
|
||||
if venv.is_symlink():
|
||||
venv.unlink()
|
||||
else:
|
||||
venv.rename(backup / 'original-venv')
|
||||
venv.symlink_to(self.new_venv, target_is_directory=True)
|
||||
progress(self.cfg, 'migrating', '正在应用数据库迁移。')
|
||||
self.run([str(self.root / 'backend/.venv/bin/python'), '-m', 'alembic', 'upgrade', 'head'],
|
||||
cwd=self.root / 'backend', env={'ENV_FILE': str(self.root / 'backend/.env'),
|
||||
'NO_ENTERPRISE': '1', 'PYTHONPATH': str(self.root / 'backend')})
|
||||
progress(self.cfg, 'verifying', '正在启动服务并检查新版本。')
|
||||
self.run(['systemctl', 'start', 'replypier-backend.service'], timeout=90)
|
||||
self.healthy()
|
||||
state = self.root / 'deploy/local/.state'
|
||||
for name in ('assets', 'dashboard'):
|
||||
(state / name).rename(backup / name)
|
||||
(stage / name).rename(state / name)
|
||||
|
||||
def restore(self, backup: Path, *, boot: bool = False) -> None:
|
||||
if not boot:
|
||||
self.services('stop')
|
||||
manifest = json.loads((backup / 'manifest.json').read_text())
|
||||
for path in set(manifest['new']) - set(manifest['old']):
|
||||
if not repository.protected(path):
|
||||
(self.root / path).unlink(missing_ok=True)
|
||||
with tarfile.open(backup / 'code.tar.gz') as tar:
|
||||
tar.extractall(self.root, filter='data')
|
||||
venv = self.root / 'backend/.venv'
|
||||
if (backup / 'original-venv').exists():
|
||||
if venv.is_symlink():
|
||||
venv.unlink()
|
||||
(backup / 'original-venv').rename(venv)
|
||||
elif (backup / 'venv-link').exists():
|
||||
if venv.is_symlink():
|
||||
venv.unlink()
|
||||
venv.symlink_to((backup / 'venv-link').read_text(), target_is_directory=True)
|
||||
state = self.root / 'deploy/local/.state'
|
||||
for name in ('assets', 'dashboard'):
|
||||
if (backup / name).exists():
|
||||
if (state / name).exists():
|
||||
(state / name).rename(backup / f'failed-{name}')
|
||||
(backup / name).rename(state / name)
|
||||
for _ in range(30):
|
||||
ready = subprocess.run(['docker', 'exec', self.cfg['db_container'], 'pg_isready',
|
||||
'-U', self.cfg['db_user'], '-d', 'postgres'], capture_output=True)
|
||||
if ready.returncode == 0:
|
||||
break
|
||||
time.sleep(2)
|
||||
else:
|
||||
raise RuntimeError('数据库尚未就绪,备份和维护标记已保留。')
|
||||
with (backup / 'database.dump').open('rb') as dump:
|
||||
self.run(['docker', 'exec', '-i', self.cfg['db_container'], 'pg_restore',
|
||||
'--clean', '--if-exists', '--create', '--exit-on-error', '--no-owner', '--no-privileges',
|
||||
'-U', self.cfg['db_user'], '-d', 'postgres'], stdin=dump)
|
||||
for source in (backup / 'agent').glob('*.py'):
|
||||
shutil.copy2(source, Path('/opt/replypier-updater') / source.name)
|
||||
for source in (backup / 'systemd').glob('*'):
|
||||
shutil.copy2(source, Path('/etc/systemd/system') / source.name)
|
||||
self.run(['systemctl', 'daemon-reload'], timeout=30)
|
||||
if not boot:
|
||||
self.services('start')
|
||||
self.healthy()
|
||||
|
||||
def recover(self, *, boot: bool = False) -> None:
|
||||
if not (self.state_dir / 'maintenance').exists():
|
||||
if read(self.cfg)['update']['status'] in BUSY:
|
||||
progress(self.cfg, 'failed', '更新进程已中断,在线版本未改变。', finished_at=now())
|
||||
return
|
||||
state = read(self.cfg)
|
||||
update = state['update']
|
||||
if update['status'] == 'succeeded':
|
||||
if not boot:
|
||||
self.services('start')
|
||||
self.healthy()
|
||||
(self.state_dir / 'maintenance').unlink(missing_ok=True)
|
||||
return
|
||||
progress(self.cfg, 'rolling_back', '正在恢复更新前的版本。')
|
||||
if update.get('backup_ready'):
|
||||
self.restore(Path(update['backup_path']), boot=boot)
|
||||
with transaction(self.cfg) as state:
|
||||
state['current'] = repository.commit(self.cfg, update['previous_revision'])
|
||||
elif not boot:
|
||||
self.services('start')
|
||||
self.healthy()
|
||||
(self.state_dir / 'maintenance').unlink(missing_ok=True)
|
||||
progress(self.cfg, 'rolled_back', '更新未完成,已恢复原版本。', finished_at=now())
|
||||
|
||||
def apply(self, target: str) -> None:
|
||||
backup = None
|
||||
try:
|
||||
if shutil.disk_usage(self.root).free < 4 * 1024**3:
|
||||
raise RuntimeError('可用磁盘空间不足 4 GiB,请先清理磁盘。')
|
||||
progress(self.cfg, 'preparing', '正在获取已确认的版本。', started_at=now(), error=None)
|
||||
repository.git(self.cfg, 'cat-file', '-e', target + '^{commit}')
|
||||
current = read(self.cfg)['current']['revision']
|
||||
repository.ensure_forward(self.cfg, current, target)
|
||||
repository.ensure_clean(self.cfg, current, self.root)
|
||||
stage = self.prepare(target)
|
||||
backup = self.backup(stage, current)
|
||||
self.switch(stage, backup)
|
||||
# Keep the independent agent recoverable if copying its next version fails.
|
||||
agent = stage / 'source/deploy/server/updater'
|
||||
for source in agent.glob('*.py'):
|
||||
temp = Path('/opt/replypier-updater') / (source.name + '.next')
|
||||
shutil.copy2(source, temp)
|
||||
temp.replace(temp.with_suffix(''))
|
||||
for source in (stage / 'source/deploy/server/systemd').glob('replypier-update*'):
|
||||
shutil.copy2(source, Path('/etc/systemd/system') / source.name)
|
||||
self.run(['systemctl', 'daemon-reload'], timeout=30)
|
||||
self.run(['systemctl', 'start', 'replypier-knowledge.service'], timeout=90)
|
||||
time.sleep(3)
|
||||
self.run(['systemctl', 'is-active', '--quiet', 'replypier-knowledge.service'], timeout=10)
|
||||
with transaction(self.cfg) as state:
|
||||
state['current'] = repository.commit(self.cfg, target)
|
||||
state['update'].update(status='succeeded', message='更新完成,服务已恢复。', finished_at=now())
|
||||
(self.state_dir / 'maintenance').unlink(missing_ok=True)
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
try:
|
||||
self.recover()
|
||||
status = 'rolled_back' if backup else 'failed'
|
||||
progress(self.cfg, status, '更新失败,原版本仍可使用。', error=error, finished_at=now())
|
||||
except Exception as recovery_error:
|
||||
progress(self.cfg, 'recovery_failed', '自动恢复失败,请管理员执行服务器恢复命令。',
|
||||
error=f'{error};恢复错误:{recovery_error}', finished_at=now())
|
||||
raise
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Read-only Git access, with a dedicated deployment key and pinned host key."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import io
|
||||
import tarfile
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from state import directory
|
||||
|
||||
|
||||
def git(cfg: dict[str, Any], *args: str, timeout: int = 120) -> str:
|
||||
env = {**os.environ, 'GIT_TERMINAL_PROMPT': '0',
|
||||
'GIT_SSH_COMMAND': shlex.join([
|
||||
'ssh', '-i', cfg['ssh_key'], '-o', 'IdentitiesOnly=yes',
|
||||
'-o', f"UserKnownHostsFile={cfg['known_hosts']}",
|
||||
'-o', 'StrictHostKeyChecking=yes', '-o', 'BatchMode=yes',
|
||||
'-o', 'ConnectTimeout=15'])}
|
||||
result = subprocess.run(['git', '--git-dir', str(directory(cfg) / 'repository.git'), *args],
|
||||
env=env, capture_output=True, text=True, timeout=timeout)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"Git 读取失败:{result.stderr.strip()[-800:]}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def fetch(cfg: dict[str, Any]) -> str:
|
||||
repo = directory(cfg) / 'repository.git'
|
||||
if not repo.exists():
|
||||
repo.mkdir()
|
||||
subprocess.run(['git', 'init', '--bare', str(repo)], check=True, capture_output=True)
|
||||
git(cfg, 'remote', 'add', 'origin', cfg['repository'])
|
||||
git(cfg, 'fetch', 'origin', f"+refs/heads/{cfg['branch']}:refs/remotes/origin/{cfg['branch']}")
|
||||
return git(cfg, 'rev-parse', f"refs/remotes/origin/{cfg['branch']}")
|
||||
|
||||
|
||||
def commit(cfg: dict[str, Any], revision: str) -> dict[str, str]:
|
||||
parts = git(cfg, 'show', '-s', '--format=%H%n%cI%n%s', revision).split('\n', 2)
|
||||
return dict(zip(('revision', 'date', 'subject'), parts))
|
||||
|
||||
|
||||
def paths(cfg: dict[str, Any], revision: str) -> list[str]:
|
||||
return git(cfg, 'ls-tree', '-r', '--name-only', revision).splitlines()
|
||||
|
||||
|
||||
def ensure_forward(cfg: dict[str, Any], current: str, target: str) -> None:
|
||||
git(cfg, 'merge-base', '--is-ancestor', current, target)
|
||||
|
||||
|
||||
def protected(path: str) -> bool:
|
||||
p = Path(path)
|
||||
return p.is_absolute() or '..' in p.parts or any(
|
||||
part in {'.git', '.venv', 'node_modules', '.state', 'uploads'} for part in p.parts
|
||||
) or p.name in {'.env', '.env.prod', '.env.dev'} or p.suffix in {'.pem', '.key'}
|
||||
|
||||
|
||||
def ensure_clean(cfg: dict[str, Any], revision: str, root: Path) -> None:
|
||||
result = subprocess.run(['git', '--git-dir', str(directory(cfg) / 'repository.git'),
|
||||
'archive', '--format=tar', revision], capture_output=True, check=True)
|
||||
different = []
|
||||
with tarfile.open(fileobj=io.BytesIO(result.stdout)) as tar:
|
||||
for member in tar:
|
||||
if not member.isfile() or protected(member.name):
|
||||
continue
|
||||
actual = root / member.name
|
||||
if not actual.exists() or actual.read_bytes() != tar.extractfile(member).read():
|
||||
different.append(member.name)
|
||||
if different:
|
||||
raise RuntimeError('服务器有未同步的源码,请先同步到 Gitea:' + '、'.join(different[:5]))
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Persistent state shared by the independent update services."""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
CONFIG_FILE = Path('/etc/replypier-updater/config.json')
|
||||
BUSY = {'queued', 'preparing', 'building', 'backing_up', 'deploying', 'migrating', 'verifying', 'rolling_back'}
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def config() -> dict[str, Any]:
|
||||
return json.loads(CONFIG_FILE.read_text())
|
||||
|
||||
|
||||
def directory(cfg: dict[str, Any]) -> Path:
|
||||
return Path(cfg['state_dir'])
|
||||
|
||||
|
||||
def read(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
p = directory(cfg) / 'status.json'
|
||||
return json.loads(p.read_text()) if p.exists() else {
|
||||
'current': {'revision': cfg['initial_revision'], 'subject': '已安装版本'},
|
||||
'latest': None, 'checked_at': None, 'checking': False,
|
||||
'check_error': None, 'update': {'status': 'idle', 'message': ''},
|
||||
}
|
||||
|
||||
|
||||
def write(cfg: dict[str, Any], state: dict[str, Any]) -> None:
|
||||
target = directory(cfg) / 'status.json'
|
||||
temp = target.with_suffix('.tmp')
|
||||
with temp.open('w') as out:
|
||||
json.dump(state, out, ensure_ascii=False, indent=2)
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
temp.chmod(0o644)
|
||||
temp.replace(target)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def transaction(cfg: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
||||
directory(cfg).mkdir(parents=True, exist_ok=True)
|
||||
with (directory(cfg) / 'state.lock').open('a') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
state = read(cfg)
|
||||
yield state
|
||||
write(cfg, state)
|
||||
|
||||
|
||||
def progress(cfg: dict[str, Any], status: str, message: str, **values: Any) -> None:
|
||||
with transaction(cfg) as state:
|
||||
state['update'].update(status=status, message=message, **values)
|
||||
|
||||
|
||||
def public_status(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
state = read(cfg)
|
||||
current = state['current']['revision']
|
||||
latest = (state.get('latest') or {}).get('revision')
|
||||
return {**state, 'configured': True, 'can_manage': True,
|
||||
'repository_url': cfg['web_url'], 'branch': cfg['branch'],
|
||||
'check_interval_minutes': 30,
|
||||
'update_available': bool(latest and latest != current and not state.get('check_error'))}
|
||||
Reference in new issue
Block a user