71 lines
2.9 KiB
Python
71 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]))
|