Files
SMANAGE/GitMirror/app.py
T

349 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""GitMirror 服务入口
把 git.gvsds.com/GVSDS/* 实时镜像到 github.com/GVSDS/*:
- POST /hooks/gitea Gitea 组织级 webhook(HMAC-SHA256 验签)→ 入队同步
- POST /backfill 手动全量回填(X-Admin-Token)
- POST /sync/<repo> 手动同步单仓(X-Admin-Token)
- GET /status 镜像状态(X-Admin-Token)
- GET /healthz 健康检查
后台:工作线程池执行镜像;定时线程按 SyncIntervalMinutes 做全量兜底。
用法:
python app.py serve
python app.py backfill
python app.py sync <repo>
python app.py register-webhook --url https://mirror.example.com/hooks/gitea
python app.py status
"""
from __future__ import annotations
import argparse
import hashlib
import hmac
import json
import logging
import os
import queue
import sys
import threading
import time
from flask import Flask, jsonify, request
from mirror import (
GiteaClient,
GithubClient,
MirrorConfig,
MirrorError,
MirrorManager,
MirrorStore,
)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.environ.get('GITMIRROR_CONFIG') or os.path.join(BASE_DIR, 'config.ini')
logger = logging.getLogger('gitmirror')
# ---------------------------------------------------------------------------
# 全局组件(CLI 与 HTTP 共用)
# ---------------------------------------------------------------------------
Cfg = MirrorConfig(CONFIG_PATH)
Gitea = GiteaClient(Cfg.GiteaBaseUrl, Cfg.GiteaOrg, Cfg.GiteaToken)
Github = GithubClient(Cfg.GithubOrg, Cfg.GithubToken)
Store = MirrorStore(Cfg.DbFile)
Manager = MirrorManager(Cfg, Gitea, Github, Store)
class SyncWorker:
"""去重的同步队列 + 工作线程
失败处理:丢弃本地镜像并把该仓库**追加到队尾**(不阻塞其它仓库)。
每个仓库在单轮回填中最多重排 MAX_REQUEUE 次,成功即清零;
每 15 分钟的定时回填会重置预算,继续兜底。
"""
MAX_REQUEUE = 10
def __init__(self, Workers: int):
self.Queue: queue.Queue[tuple[str, dict | None]] = queue.Queue()
self._Pending: set[str] = set()
self._Guard = threading.Lock()
self._Workers = Workers
self._Threads: list[threading.Thread] = []
self._Requeues: dict[str, int] = {}
def Start(self) -> None:
for Index in range(self._Workers):
Thread = threading.Thread(target=self._Run, name=f'GitMirrorWorker-{Index}', daemon=True)
Thread.start()
self._Threads.append(Thread)
def Put(self, RepoName: str, Repo: dict | None = None) -> bool:
"""入队;已在队列中的同名仓库不重复入队"""
with self._Guard:
if RepoName in self._Pending:
return False
self._Pending.add(RepoName)
self.Queue.put((RepoName, Repo))
return True
def PendingCount(self) -> int:
with self._Guard:
return len(self._Pending)
def ResetRequeueBudget(self) -> None:
"""新一轮回填:清空重排计数"""
with self._Guard:
self._Requeues.clear()
def _Run(self) -> None:
while True:
RepoName, Repo = self.Queue.get()
Requeue = False
try:
Requeue = self._SyncOnce(RepoName, Repo)
except Exception: # 兜底,避免工作线程退出
logger.exception('同步任务异常:%s', RepoName)
finally:
with self._Guard:
self._Pending.discard(RepoName)
self.Queue.task_done()
if Requeue:
self.Put(RepoName, Repo)
def _SyncOnce(self, RepoName: str, Repo: dict | None) -> bool:
"""执行一次同步;返回 True 表示需要重新排到队尾"""
try:
Info = Repo or Gitea.GetRepo(RepoName)
if not Info:
raise MirrorError(f'Gitea 中不存在仓库 {RepoName}')
Manager.Sync(Info)
with self._Guard:
self._Requeues.pop(RepoName, None)
return False
except Exception as Error:
logger.warning('同步失败 %s:%s', RepoName, Error)
# 丢弃可能损坏的本地镜像(大仓 HTTP/2 断流后无法续传)
Manager.DropLocal(RepoName)
with self._Guard:
Count = self._Requeues.get(RepoName, 0) + 1
self._Requeues[RepoName] = Count
if Count <= self.MAX_REQUEUE:
Store.SetStatus(RepoName, Status='pending', LastError=str(Error)[:1000])
logger.info('已排到队尾(第 %s 次重排):%s', Count, RepoName)
return True
Store.SetStatus(RepoName, Status='failed', LastError=str(Error)[:1000])
logger.error('超过重排上限,标记失败 %s:%s', RepoName, Error)
return False
Worker = SyncWorker(Cfg.Concurrency)
# ---------------------------------------------------------------------------
# 任务:全量回填 / 删除处理
# ---------------------------------------------------------------------------
def Backfill() -> dict:
"""枚举组织全部仓库并入队(幂等)"""
Worker.ResetRequeueBudget()
Repos = Gitea.ListRepos()
Queued = 0
for Repo in Repos:
Name = Repo.get('name') or ''
if not Name:
continue
Store.SetStatus(Name, SourcePrivate=int(bool(Repo.get('private', True))))
if Worker.Put(Name, Repo):
Queued += 1
logger.info('全量回填:共 %s 个仓库,新入队 %s 个', len(Repos), Queued)
return {'Total': len(Repos), 'Queued': Queued}
def HandleRepoDeleted(RepoName: str) -> None:
try:
Manager.HandleRepoDeleted(RepoName)
except Exception:
logger.exception('处理源仓库删除失败:%s', RepoName)
class Scheduler:
"""定时兜底:每 SyncIntervalMinutes 一次全量回填(防 webhook 丢失)"""
def __init__(self, IntervalMinutes: int, RunOnStart: bool):
self.Interval = max(1, IntervalMinutes) * 60
self.RunOnStart = RunOnStart
self._Stop = threading.Event()
def Start(self) -> None:
threading.Thread(target=self._Run, name='GitMirrorScheduler', daemon=True).start()
def _Run(self) -> None:
if self.RunOnStart:
try:
Backfill()
except Exception:
logger.exception('启动回填失败')
while not self._Stop.wait(self.Interval):
try:
Backfill()
except Exception:
logger.exception('定时回填失败')
# ---------------------------------------------------------------------------
# Flask
# ---------------------------------------------------------------------------
App = Flask(__name__)
def _VerifySignature(RawBody: bytes, Signature: str | None) -> bool:
if not Signature:
return False
Expected = hmac.new(Cfg.WebhookSecret.encode('utf-8'), RawBody, hashlib.sha256).hexdigest()
return hmac.compare_digest(Expected, Signature)
def _IsAdmin() -> bool:
return bool(Cfg.AdminToken) and request.headers.get('X-Admin-Token') == Cfg.AdminToken
@App.get('/healthz')
def Healthz():
return jsonify({'ok': True, 'pending': Worker.PendingCount(), 'gitea': Cfg.GiteaOrg, 'github': Cfg.GithubOrg})
@App.post('/hooks/gitea')
def GiteaHook():
Raw = request.get_data() or b''
if not _VerifySignature(Raw, request.headers.get('X-Gitea-Signature')):
return jsonify({'code': 1, 'msg': '签名校验失败'}), 401
Event = request.headers.get('X-Gitea-Event', '')
Payload = request.get_json(silent=True) or {}
Repo = Payload.get('repository') or {}
RepoName = (Repo.get('name') or '').strip()
if not RepoName:
return jsonify({'code': 0, 'msg': 'ignored'}), 202
Action = (Payload.get('action') or '').strip()
if Event == 'repository' and Action == 'deleted':
threading.Thread(target=HandleRepoDeleted, args=(RepoName,), daemon=True).start()
return jsonify({'code': 0, 'msg': 'repo deleted handled'}), 202
Worker.Put(RepoName, Repo)
logger.info('webhook 入队 event=%s action=%s repo=%s', Event, Action, RepoName)
return jsonify({'code': 0, 'msg': 'queued'}), 202
@App.post('/backfill')
def BackfillRoute():
if not _IsAdmin():
return jsonify({'code': 1, 'msg': 'unauthorized'}), 401
return jsonify({'code': 0, 'data': Backfill()})
@App.post('/sync/<RepoName>')
def SyncRoute(RepoName: str):
if not _IsAdmin():
return jsonify({'code': 1, 'msg': 'unauthorized'}), 401
Queued = Worker.Put(RepoName)
return jsonify({'code': 0, 'data': {'RepoName': RepoName, 'Queued': Queued}})
@App.get('/status')
def StatusRoute():
if not _IsAdmin():
return jsonify({'code': 1, 'msg': 'unauthorized'}), 401
return jsonify({'code': 0, 'data': {'Pending': Worker.PendingCount(), 'Repos': Store.List()}})
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _SetupLogging() -> None:
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
)
def _CmdServe() -> None:
Worker.Start()
Scheduler(Cfg.SyncIntervalMinutes, Cfg.BackfillOnStart).Start()
logger.info('GitMirror 启动:%s -> github.com/%s,每 %s 分钟兜底,webhook secret 已就绪',
f'{Cfg.GiteaBaseUrl}/{Cfg.GiteaOrg}', Cfg.GithubOrg, Cfg.SyncIntervalMinutes)
logger.info('监听 %s:%s(webhook 路径 /hooks/gitea)', Cfg.ServerHost, Cfg.ServerPort)
App.run(host=Cfg.ServerHost, port=Cfg.ServerPort, threaded=True)
def _CmdBackfill() -> None:
Result = Backfill()
Worker.Start()
logger.info('已入队 %s 个仓库,等待完成(Ctrl+C 可提前退出)', Result['Queued'])
while Worker.PendingCount() > 0:
time.sleep(1)
logger.info('回填结束')
def _CmdSync(RepoName: str) -> None:
Info = Gitea.GetRepo(RepoName)
if not Info:
raise MirrorError(f'Gitea 中不存在仓库 {RepoName}')
Manager.Sync(Info)
logger.info('同步完成:%s', RepoName)
def _CmdRegisterWebhook(Url: str) -> None:
Action = Gitea.RegisterWebhook(Url, Cfg.WebhookSecret)
logger.info('组织 webhook 已 %s:%s(events: push/create/delete/repository)', Action, Url)
logger.info('Webhook Secret:%s', Cfg.WebhookSecret)
def _CmdStatus() -> None:
print(json.dumps({'Pending': Worker.PendingCount(), 'Repos': Store.List()}, ensure_ascii=False, indent=2))
def main() -> int:
_SetupLogging()
Parser = argparse.ArgumentParser(description='Gitea → GitHub 仓库镜像服务')
Sub = Parser.add_subparsers(dest='command')
Sub.add_parser('serve', help='启动 HTTP 服务(webhook + 定时兜底)')
Sub.add_parser('backfill', help='全量回填一次')
SyncParser = Sub.add_parser('sync', help='同步单个仓库')
SyncParser.add_argument('repo')
HookParser = Sub.add_parser('register-webhook', help='注册/更新 Gitea 组织级 webhook')
HookParser.add_argument('--url', required=True, help='本服务对外地址,如 https://mirror.example.com/hooks/gitea')
Sub.add_parser('status', help='打印镜像状态')
Args = Parser.parse_args()
Command = Args.command or 'serve'
try:
if Command == 'serve':
_CmdServe()
elif Command == 'backfill':
_CmdBackfill()
elif Command == 'sync':
_CmdSync(Args.repo)
elif Command == 'register-webhook':
_CmdRegisterWebhook(Args.url)
elif Command == 'status':
_CmdStatus()
else:
Parser.print_help()
return 2
except MirrorError as Error:
logger.error('%s', Error)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())