Files
replypier/deploy/server/updater/repository.py
T
WangShen007 f3ad539998 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.
2026-10-24 07:26:41 +00:00

72 lines
2.9 KiB
Python

"""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]))