铸渊 (ICE-GL-ZY001) 5f2d1ac9c3 feat: 添加 Git Sync 自动同步服务
- 新增 server/git-sync/ — 监听 Gitea Webhook,push/merge 到 main 时自动 git pull
- 修改 selfcheck — 移除 git pull 步骤(由 Git Sync 负责),添加 Git Sync/Wake Gate 状态检查
- 更新 README — 添加自动同步说明,更新仓库结构
- 新增 server/nginx/guanghulab-cvm.conf — 新服务器 Nginx 配置模板

解决: PR合并后代码不会自动同步到服务器的问题
冰朔只需在Gitea点合并,服务器自动更新代码
2026-05-12 13:23:43 +00:00

289 lines
9.8 KiB
JavaScript
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.

/*
* ════════════════════════════════════════════════════════════════
* 光湖 · Git 同步服务 (Git Sync)
* 监听 Gitea Webhook自动拉取最新代码到服务器
* ════════════════════════════════════════════════════════════════
*
* 工作原理:
* 1. Gitea 配置 Webhook → push 事件 → 通知此服务
* 2. 本服务收到通知后执行 git pull 同步代码
* 3. 同步成功后重启相关 PM2 服务
*
* 安全设计:
* - 验证 Webhook Secret防止伪造请求
* - 仅响应 push 到 main 分支的事件
* - 同步操作以 root 权限运行(确保 git 权限无问题)
* - 同步锁防止并发执行
* - 同步失败自动重试最多3次
*
* 部署位置: /data/guanghulab/server/git-sync/
* PM2 进程名: guanghulab-git-sync
* ════════════════════════════════════════════════════════════════
*/
"use strict";
const express = require("express");
const crypto = require("crypto");
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const app = express();
app.use(express.json({ limit: "1mb" }));
// ─── 配置 ─────────────────────────────────────────────────────
const CONFIG = {
port: process.env.GIT_SYNC_PORT || 8082,
host: process.env.GIT_SYNC_HOST || "127.0.0.1",
// 仓库路径
repoDir: process.env.REPO_DIR || "/data/guanghulab/repo",
// Webhook Secret和 Gitea 里配置的一致)
webhookSecret: process.env.GIT_SYNC_SECRET || "",
// Git 认证信息(内嵌在 remote URL 中)
gitUser: process.env.GIT_USER || "bingshuo",
gitToken: process.env.ZY_REPO_TOKEN || "",
gitHost: process.env.GIT_HOST || "127.0.0.1:3001",
gitRepo: process.env.GIT_REPO || "bingshuo/guanghulab.git",
// 同步后要重启的 PM2 进程(逗号分隔)
restartServices: (process.env.RESTART_SERVICES || "").split(",").filter(Boolean),
// 重试配置
maxRetries: 3,
retryDelay: 5000, // 5秒
// 同步日志
syncLogPath: process.env.SYNC_LOG_PATH || "/data/guanghulab/.runtime/git-sync-log.json"
};
// ─── 同步锁 ─────────────────────────────────────────────────────
let isSyncing = false;
// ─── 同步日志 ──────────────────────────────────────────────────
let syncLog = [];
function loadSyncLog() {
try {
if (fs.existsSync(CONFIG.syncLogPath)) {
syncLog = JSON.parse(fs.readFileSync(CONFIG.syncLogPath, "utf8"));
}
} catch { syncLog = []; }
}
function saveSyncLog() {
try {
const dir = path.dirname(CONFIG.syncLogPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
// 只保留最近50条
syncLog = syncLog.slice(-50);
fs.writeFileSync(CONFIG.syncLogPath, JSON.stringify(syncLog, null, 2));
} catch (e) {
console.error("[git-sync] 日志写入失败:", e.message);
}
}
function addLog(entry) {
syncLog.push({
...entry,
timestamp: new Date().toISOString()
});
saveSyncLog();
}
// ─── 验证 Webhook Secret ──────────────────────────────────────
function verifyWebhook(payload, signature) {
if (!CONFIG.webhookSecret) {
// 未配置 Secret 时跳过验证(仅限内网使用)
console.warn("[git-sync] ⚠️ Webhook Secret 未配置,跳过验证");
return true;
}
if (!signature) return false;
const expected = crypto
.createHmac("sha256", CONFIG.webhookSecret)
.update(payload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(`sha256=${expected}`),
Buffer.from(signature)
);
}
// ─── 执行 Git 同步 ────────────────────────────────────────────
function doSync(commitSha, committer, message) {
if (isSyncing) {
console.log("[git-sync] 同步进行中,跳过");
return { skipped: true };
}
isSyncing = true;
try {
// 确保 remote URL 包含认证信息
const authUrl = `http://${CONFIG.gitUser}:${CONFIG.gitToken}@${CONFIG.gitHost}/${CONFIG.gitRepo}`;
execSync(`cd ${CONFIG.repoDir} && git remote set-url origin ${authUrl}`, {
encoding: "utf8",
timeout: 10000
});
// 执行 git pull
const output = execSync(
`cd ${CONFIG.repoDir} && git fetch origin && git reset --hard origin/main && git log --oneline -1`,
{ encoding: "utf8", timeout: 30000 }
);
console.log(`[git-sync] ✅ 同步成功: ${output.trim()}`);
// 同步后重启相关服务
if (CONFIG.restartServices.length > 0) {
for (const svc of CONFIG.restartServices) {
try {
execSync(`pm2 restart ${svc}`, { encoding: "utf8", timeout: 15000 });
console.log(`[git-sync] 🔄 已重启: ${svc}`);
} catch (e) {
console.error(`[git-sync] ⚠️ 重启失败 ${svc}: ${e.message}`);
}
}
}
addLog({
status: "success",
commit: commitSha || output.trim().split(" ")[0],
committer: committer || "unknown",
message: message || output.trim(),
output: output.trim()
});
return { success: true, output: output.trim() };
} catch (e) {
console.error(`[git-sync] ❌ 同步失败: ${e.message}`);
addLog({
status: "failed",
commit: commitSha,
committer: committer || "unknown",
message: message || "",
error: e.message
});
return { success: false, error: e.message };
} finally {
isSyncing = false;
}
}
// ─── 带重试的同步 ──────────────────────────────────────────────
async function syncWithRetry(commitSha, committer, message) {
for (let attempt = 1; attempt <= CONFIG.maxRetries; attempt++) {
const result = doSync(commitSha, committer, message);
if (result.success || result.skipped) return result;
if (attempt < CONFIG.maxRetries) {
console.log(`[git-sync] 重试 ${attempt}/${CONFIG.maxRetries}...`);
await new Promise(r => setTimeout(r, CONFIG.retryDelay));
}
}
return { success: false, error: "重试次数已用完" };
}
// ─── API 路由 ──────────────────────────────────────────────────
// Webhook 接收端点
app.post("/webhook", async (req, res) => {
const signature = req.headers["x-gitea-signature"] || req.headers["x-hub-signature-256"] || "";
const payload = JSON.stringify(req.body);
// 验证签名
if (!verifyWebhook(payload, signature)) {
console.warn("[git-sync] ❌ Webhook 签名验证失败");
return res.status(403).json({ error: "签名验证失败" });
}
const body = req.body;
const ref = body.ref || "";
const commitSha = body.after || body.head_commit?.id || "";
const committer = body.pusher?.login || body.sender?.login || "unknown";
const message = body.head_commit?.message || body.commits?.[0]?.message || "";
// 仅响应 push 到 main 分支
if (!ref.endsWith("/main")) {
console.log(`[git-sync] 忽略非 main 分支推送: ${ref}`);
return res.json({ ignored: true, ref });
}
console.log(`[git-sync] 📥 收到 push 事件: ${commitSha.substring(0, 8)} by ${committer}`);
// 异步执行同步(不阻塞 Webhook 响应)
syncWithRetry(commitSha, committer, message)
.then(result => {
if (result.success) {
console.log(`[git-sync] ✅ 同步完成: ${commitSha.substring(0, 8)}`);
} else {
console.error(`[git-sync] ❌ 同步最终失败: ${result.error}`);
}
});
// 立即返回 200Gitea 不需要等待同步完成
res.json({ received: true, syncing: true, commit: commitSha.substring(0, 8) });
});
// 手动触发同步
app.post("/sync", async (req, res) => {
if (isSyncing) {
return res.json({ syncing: true, message: "同步进行中" });
}
const result = await syncWithRetry("", "manual", "手动触发");
res.json(result);
});
// 同步状态查询
app.get("/status", (req, res) => {
const lastSync = syncLog.length > 0 ? syncLog[syncLog.length - 1] : null;
// 读取当前仓库状态
let repoStatus = null;
try {
const log = execSync(`cd ${CONFIG.repoDir} && git log --oneline -1 && git status --short`, {
encoding: "utf8", timeout: 10000
});
repoStatus = log.trim();
} catch {
repoStatus = "无法读取";
}
res.json({
service: "git-sync",
status: isSyncing ? "syncing" : "idle",
lastSync,
repoStatus,
totalSyncs: syncLog.length,
failedSyncs: syncLog.filter(l => l.status === "failed").length
});
});
// 健康检查
app.get("/health", (req, res) => {
res.json({ status: "ok", uptime: process.uptime() });
});
// ─── 启动 ─────────────────────────────────────────────────────
loadSyncLog();
app.listen(CONFIG.port, CONFIG.host, () => {
console.log(`[git-sync] Git 同步服务启动: http://${CONFIG.host}:${CONFIG.port}`);
console.log(`[git-sync] 仓库路径: ${CONFIG.repoDir}`);
console.log(`[git-sync] Webhook 端点: POST /webhook`);
console.log(`[git-sync] 铸渊 · ICE-GL-ZY001 · TCS-0002∞`);
});