init
ci / test (push) Canceled after 0s
docker / build (push) Canceled after 0s

This commit is contained in:
iceBear67
2026-08-14 07:13:22 +00:00
commit a006483bbc
26 changed files with 3826 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
# syncbot
定时把一个 git 仓库(src)镜像推送到另一个 git 仓库(dst)的小机器人。
典型用途:把内网 GitLab / Gitea 上的仓库持续同步到 GitHubdst 侧用一把 deploy key 授权。
- **单文件、低占用** — 静态二进制 6.4 MB,空转常驻内存约 11 MB,同步时的重活交给短命的 `git` 子进程
- **配置热更新** — 改完 `config.toml` 直接保存,几秒内生效;只有被改动的仓库会重启,其它仓库的计时器和进行中的同步不受影响
- **无状态、自愈** — 不存任何同步进度。每轮都直接问 src 和 dst 各自有哪些引用,只做必要的操作。有人手贱 force push 了 dst、上一次推送推到一半失败、容器被重建 —— 下一轮自动收敛
- **省流量** — 每轮先用 `ls-remote` 探一下,src 没动就不 fetchdst 已经一致就不 push
- **不会误删** — src 突然变空(URL 写错、token 过期长这样)时拒绝把 dst 清空
- **凭据不落盘** — 带 token 的 URL 只在命令行里传,不写进 `.git/config`;日志和报错里的密码一律脱敏
---
## 快速开始
### Docker Compose(推荐)
```bash
mkdir -p syncbot/keys && cd syncbot
curl -O https://raw.githubusercontent.com/OWNER/syncbot/main/docker-compose.yml
curl -o config.toml https://raw.githubusercontent.com/OWNER/syncbot/main/config.example.toml
# 编辑 docker-compose.yml 里的 image 和 config.toml 里的仓库地址
docker compose up -d
docker compose logs -f
```
### 直接跑二进制
```bash
go build -o syncbot .
./syncbot -config ./config.toml -check # 先校验配置
./syncbot -config ./config.toml
```
### 最小配置
```toml
[global]
work_dir = "/var/lib/syncbot"
[[repo]]
name = "my-project"
src = "https://gitlab.internal/team/my-project.git"
dst = "git@github.com:me/my-project.git"
interval = "5m"
ssh_key = "/etc/syncbot/keys/my-project"
```
完整示例见 [`config.example.toml`](config.example.toml)。
---
## 给 bot 配 GitHub deploy key
```bash
# 1. 生成一把不带密码的 key(bot 无人值守,不能有 passphrase
ssh-keygen -t ed25519 -N "" -C "syncbot" -f keys/my-project
# 2. 把公钥加到 GitHub:仓库 → Settings → Deploy keys → Add deploy key
cat keys/my-project.pub
# ⚠️ 必须勾选 "Allow write access",否则只能读不能推
# 3. 配置里指向私钥
# ssh_key = "/etc/syncbot/keys/my-project"
```
几个坑:
- **一把 deploy key 只能绑一个仓库。** GitHub 不允许同一把公钥重复添加到多个仓库(会报 key is already in use)。要镜像多个仓库就每个仓库生成一把,或者改用 machine user / GitHub App token 走 HTTPS。
- **私钥权限不用操心。** 从 Kubernetes Secret 或 `:ro` 挂载进来的 key 常常是 0644,`ssh` 会直接拒绝。syncbot 会自动在私有临时目录做一份 0600 的副本再用,任务结束即删。
- **主机密钥校验。** 默认 `accept-new`(首次连接自动信任并记到 `work_dir/home/known_hosts`)。想更严格就先固定下来:
```bash
ssh-keyscan github.com > keys/known_hosts
```
```toml
known_hosts = "/etc/syncbot/keys/known_hosts"
strict_host_key = "yes"
```
---
## 配置
### `[global]`
| 字段 | 默认值 | 说明 |
| --- | --- | --- |
| `work_dir` | `/var/lib/syncbot` | 本地镜像仓库和 ssh 状态的存放目录,需要可写 |
| `interval` | `5m` | 默认轮询间隔 |
| `timeout` | `30m` | 单次同步的超时 |
| `concurrency` | `min(4, CPU 核数)` | 同时运行的 git 进程数上限,**内存主要靠它控制** |
| `max_backoff` | `1h` | 连续失败后的退避上限 |
| `reload_interval` | `5s` | 多久检查一次配置文件有没有变 |
| `listen` | 空(不监听) | HTTP 端点地址,如 `:8080` |
| `log_level` | `info` | `debug` / `info` / `warn` / `error` |
| `log_format` | `text` | `text` / `json` |
`[global]` 里还可以写下面所有 `[[repo]]` 字段作为默认值。
### `[[repo]]`
| 字段 | 默认值 | 说明 |
| --- | --- | --- |
| `name` | 必填 | 唯一标识,同时用作目录名和监控标签,限 `[A-Za-z0-9._-]` |
| `src` | 必填 | 源仓库,字符串或表(见下) |
| `dst` | 必填 | 目标仓库 |
| `enabled` | `true` | 设为 `false` 可临时停掉而不用删配置 |
| `interval` / `timeout` / `max_backoff` | 继承 global | |
| `refs` | `["refs/heads/*", "refs/tags/*"]` | 镜像哪些引用,每条最多一个 `*` |
| `prune` | `true` | src 上删掉的分支/标签,也在 dst 上删掉 |
| `force` | `true` | 允许非快进推送。上游 rebase 过就必须开 |
| `atomic` | `false` | `true` 表示所有引用要么全成功要么全不动 |
| `allow_empty` | `false` | 允许「空的 src 清空 dst」,见下方保护机制 |
| `ssh_key` | 无 | 私钥绝对路径,同时作用于 src 和 dst |
| `known_hosts` | 无 | 固定的 known_hosts 文件 |
| `strict_host_key` | 有 `known_hosts` 时 `yes`,否则 `accept-new` | `yes` / `no` / `accept-new` |
| `git_config` | 无 | 原样传给 git 的 `-c key=value`,用于压内存等 |
### 端点写法
src / dst 可以直接写成字符串:
```toml
src = "https://gitlab.internal/team/repo.git"
dst = "git@github.com:me/repo.git"
```
两边需要各自的凭据时写成表:
```toml
[repo.src]
url = "git@gitlab.internal:team/repo.git"
ssh_key = "/etc/syncbot/keys/gitlab"
[repo.dst]
url = "git@github.com:me/repo.git"
ssh_key = "/etc/syncbot/keys/github"
known_hosts = "/etc/syncbot/keys/known_hosts"
strict_host_key = "yes"
```
### 环境变量
URL 和路径里的 `${VAR}` 会用环境变量展开,适合放 token:
```toml
src = "https://x-access-token:${SRC_TOKEN}@github.com/upstream/repo.git"
```
- 变量没定义会**直接启动失败并指出名字**,不会静默展开成空串给你一个坏掉的 URL
- 只认 `${VAR}`,裸写的 `$VAR` 原样保留(密码里 `$` 很常见)
- 环境变量在进程启动时读取,改环境变量需要重启
---
## 热更新
syncbot 每 `reload_interval` 比对一次配置文件内容的哈希,变了就重新加载;也可以 `kill -HUP` 立即触发。
重新加载时会把新配置和正在跑的任务做 diff:
- 新增的仓库 → 启动
- 删掉或 `enabled = false` 的仓库 → 停掉,其正在进行的 git 操作会被取消
- 配置有改动的仓库 → 重启(新任务会等旧任务完全退出,避免两个进程同时动同一个镜像目录)
- **没改动的仓库 → 完全不动**,计时器和进行中的同步都不受影响
几点说明:
- **配置写错了不会挂。** 解析失败会记一条 ERROR,然后继续用上一份能跑的配置。修好保存即可恢复
- `log_level` 改了立即生效
- `listen` 改了需要重启进程,日志里会 WARN 提示
- 累计的 `syncs` / `pushes` 计数在仓库重启后保留,只有仓库被删掉才清零
---
## 运维
### 命令行
```
syncbot -config PATH 配置文件路径(默认 /etc/syncbot/config.toml
-check 校验配置并打印解析结果后退出
-once 所有仓库同步一次就退出(适合 cron / CI)
-version 打印版本
```
### 信号
| 信号 | 行为 |
| --- | --- |
| `SIGHUP` | 立即重新加载配置 |
| `SIGTERM` / `SIGINT` | 优雅退出,最多等 20 秒让进行中的同步收尾 |
### HTTP 端点(需要设置 `listen`
| 路径 | 说明 |
| --- | --- |
| `/healthz` | 进程活着就返回 200,适合容器 healthcheck |
| `/readyz` | 所有仓库都至少成功同步过一次才返回 200,否则 503 |
| `/status` | JSON,每个仓库的最后运行时间、失败次数、引用数、最后一次错误 |
| `/metrics` | Prometheus 文本格式 |
指标:`syncbot_build_info`、`syncbot_uptime_seconds`、`syncbot_sync_total`、
`syncbot_push_total`、`syncbot_consecutive_failures`、`syncbot_refs`、
`syncbot_last_success_timestamp_seconds`、`syncbot_last_duration_seconds`。
告警建议盯 `syncbot_consecutive_failures > 0`,或者
`time() - syncbot_last_success_timestamp_seconds` 超过 interval 的若干倍。
---
## 工作原理
每个仓库一个 goroutine、一个独立计时器,所以某个仓库卡住不会拖累别的。
一个全局信号量限制同时运行的 git 进程数(`concurrency`)。
单轮同步:
1. `git ls-remote` 问 src 现在有哪些引用
2. 和本地镜像(`work_dir/mirrors/<name>.git`)比对 → 一样就**跳过 fetch**
3. 不一样才 `git fetch --prune`
4. `git ls-remote` 问 dst 现在有哪些引用
5. 和本地镜像比对 → 一样就**跳过 push**
6. 不一样才 `git push --prune --force`
因为每轮都重新问过两边,所以不需要任何本地状态文件,删掉 `work_dir` 也只是让下次重新克隆一遍而已。
失败时按 `interval → 2×→ 4×` 退避,上限 `max_backoff`;成功后立刻恢复正常节奏。
所有 git 调用都在独立进程组里,超时或退出时整组一起收掉,不会漏下 `ssh` 之类的子进程。
每次调用都用隔离的 `HOME` 并禁用系统/全局 gitconfig,行为不受宿主机环境影响。
---
## 常见问题
**推送被拒:`refusing to delete the current branch`**
dst 的默认分支(HEAD 指向的那个)不允许被删除,GitHub 也一样。
上游把 `master` 改名成 `main` 之后会撞上这个:先去 GitHub 仓库设置里把默认分支改成新名字,再让 syncbot 同步。
**日志出现 `refusing to mirror an empty source`**
src 一个引用都没返回,但 dst 有内容。绝大多数情况是 URL 写错或者 token 过期,
所以默认拒绝推送以免把 dst 清空。确实想清空的话给那个仓库加 `allow_empty = true`。
**`Permission denied (publickey)`**
依次检查:deploy key 是否勾了 *Allow write access*;配置里 `ssh_key` 是否指向**私钥**(不是 `.pub`);
key 是否没有 passphrase;同一把 key 是否被重复用在了多个 GitHub 仓库上。
`log_level = "debug"` 可以看到每条 git 命令。
**只想同步分支,不要标签 / PR 引用**
`refs = ["refs/heads/*"]`。默认就不会碰 `refs/pull/*` 这类引用。
**大仓库把内存吃满了**
吃内存的是 `git`,不是 syncbot 自己。调小 `concurrency`,并给该仓库加:
```toml
git_config = ["pack.threads=1", "pack.windowMemory=64m", "core.bigFileThreshold=8m"]
```
**想立刻同步一次,不等下个周期**
`docker compose restart syncbot`,或者用 `-once` 单独跑一次。
---
## 开发
```bash
go test ./... # 单元测试 + 跑真实 git 的端到端测试
go test -race ./...
go vet ./...
go build .
```
代码结构和设计约定见 [AGENTS.md](AGENTS.md)。
镜像由 GitHub Actions 构建并推送到 `ghcr.io/OWNER/syncbot`
`main` 分支推 `latest`,打 `v*` 标签推对应语义化版本,支持 amd64 / arm64。