实现 Gitea CI 编译缓存自动刷新与定向清理

基于固定 master 提交构建全新缓存镜像,并在成功验证后保留当前版和回滚版。
增加任务领取网关,在不打断运行中 CI 的前提下切换镜像。
补充宿主维护服务、部署说明、共享记忆和定向行为测试。
修复构建测试在 Windows 与 WSL 下并发写入日志的竞态。
This commit is contained in:
2026-09-22 07:37:45 +00:00
parent fdc14404b2
commit e3c2c1b732
17 changed files with 1915 additions and 43 deletions
+46 -2
View File
@@ -89,7 +89,7 @@ runner 配置保留原 `ubuntu-latest` 映射,`genarrative-ci` 继续映射到
2. 执行 `load-runner`,确认该 ID 已进入 runner 内层 Docker。
3. 确认没有活跃 job,将当前 runner config 备份到仓库外的受控位置;备份不得进入 Git,也不得在文档或日志中回显注册信息。
4. 增加或替换 `genarrative-ci` 的精确 `docker://<Image ID>` 映射,然后执行 `docker restart --timeout 660 gitea-runner`。
5. 重跑真实 PR 的四个 CI job;全部通过且隔离边界复核完成后,才能清理旧镜像。
5. 用真实 CI 的九个 job 验证新镜像;全部通过且隔离边界复核完成后,才能清理旧镜像。
`docker restart --timeout 660` 只提供容器停止宽限,不是 Runner drain API;rootless DinD 的 supervisor 可能与 runner 同时停止内层 dockerd。重启前必须同时确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空,不能依赖该 timeout 等待活跃 job。
@@ -97,9 +97,15 @@ runner 配置保留原 `ubuntu-latest` 映射,`genarrative-ci` 继续映射到
### Rust 测试组编译对象快照
自动维护由宿主 systemd timer 调用 `scripts/maintain-gitea-rust-cache.py`,只管理 Gitea CI 测试镜像,不修改 Jenkins、生产发布、本地开发或客户端发行构建。维护器固定最新 master SHA,**不要求该提交事先全绿**;编译/镜像校验失败则继续使用旧版。任务串行执行,编译输入未变化时跳过,CI 繁忙时延后预热;每次只取当时最新 master,不为中间提交建立队列。构建过程中固定 SHA,不混入后续提交。
切换先通过专属入口阻断新的 FetchTask,确认已转发的领取请求全部收到完整上游响应,再确认全局 runner 不 busy、Gitea 没有 in_progress run、内层 Docker 没有容器。有任务即恢复领取并延后,不停止任务。切换后等待使用该 Image ID 的完整真实 master push CI 通过,才允许下一次升级及定向清理;不会自动重跑失败用例或为了验收额外触发整轮 CI。首次接管的历史镜像默认不归自动清理管理。
维护状态、凭据、归档和配置备份保存在仓库外。当前版、回滚版、待验证候选、它们的基础镜像及容器引用的镜像均受保护。清理只针对维护器登记的专属 tag、完整 Image ID 和专用目录中的归档;禁止全局 prune。API、构建、验证或空闲检查失败时保留现役镜像与回滚资料,不以失败重跑制造全绿结果。
`scripts/build-gitea-rust-cache.sh` 在已验证的 job 镜像上生成候选镜像,覆盖 AGC Rust 两条 lane、crates、agent-run smoke、Backend 和 Native shell 的桌面壳测试。Native shell 的 release build smoke 显式清空两个 wrapper,不消费测试快照。固定 sccache `0.18.0` 的 Linux x64 musl 归档并校验 SHA-256,维护者从 origin/master 的确定提交预热编译对象,PR job 没有生成/发布公共快照的权限。
基础镜像必须不含 `/opt/genarrative-ci/rust-cache`;脚本在拉取源码、下载工具和预热前执行只读、断网检查,发现已有对象快照就拒绝构建。不能在旧缓存镜像上删除目录再叠加新快照,删除操作不会释放旧镜像层。切换且真实 CI 验证通过后,人工定向清理更旧的缓存镜像与导出归档,保留当前版、一个回滚版及运行中 CI 使用的版本;对象缓存上限不覆盖这些宿主文件,不使用全局 prune。
基础镜像必须不含 `/opt/genarrative-ci/rust-cache`;脚本在拉取源码、下载工具和预热前执行只读、断网检查,发现已有对象快照就拒绝构建。不能在旧缓存镜像上删除目录再叠加新快照,删除操作不会释放旧镜像层。切换且真实 CI 验证通过后,维护器定向清理自己登记的更旧缓存镜像与导出归档,保留当前版、一个回滚版、所需基础镜像及容器引用的版本;接管前的试验镜像/归档仍由维护者确认后人工清理。对象缓存上限不覆盖这些宿主文件,不使用全局 prune,也不清理其它构建的 Docker build cache。
```bash
bash scripts/build-gitea-rust-cache.sh genarrative/gitea-project-ci:20260920.2 genarrative/gitea-project-ci:rust-cache-candidate
@@ -108,6 +114,44 @@ bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/ci-rust-cache.t
bash scripts/gitea-ci-job-image.sh load-runner genarrative/gitea-project-ci:rust-cache-candidate
```
自动维护调用构建脚本时增加第三个参数 `<完整 master SHA>`。脚本确认该 SHA 是抓取到的 master 祖先,源码和 `ci-rust-cache.sh` 均来自该提交;两参数人工调用仍默认使用最新 master。基础镜像的 `revision` 由同一份 build context 清单计算;输入变化时维护器先生成新的无对象缓存基础镜像。Rust 输入指纹保守包含代码、配置、资源、脚本和内嵌 skill,仅排除一般 `docs/` 及几个根说明文档,`docs/openapi/` 始终参与。
### 自动维护首次部署与恢复
首次部署在本变更合入 master 后进行。维护脚本安装到 `/opt/genarrative-ci-cache/scripts/`;运行状态、源码专用 clone、日志和归档放在 `/var/lib/genarrative-ci-cache/`,token 放在 `/etc/genarrative-ci-cache/api-token`(root 所有、0600),均不进入 Git。复制 `gitea-ci-cache.config.example.json` 为该目录的 `config.json` 并核实 runner ID;当前全局 runner 的只读 API 需要管理员账号的 `read:admin`,仓库 Actions/日志读取需要对应的只读权限,不能复用只有普通仓库权限的 token。维护器没有写 Gitea API 的调用。
领取入口由 `gitea-runner-fetch-gate.compose.yml` 启动,使用已验证且含 Python 3 的无对象缓存 CI Image ID(`GITEA_FETCH_GATE_IMAGE`)。它只连接现有 `gitea-actions` 内部网络,不发布宿主端口、不挂 Docker socket;仅转发 `/api/actions/` RPC,控制 socket 位于独立私有目录 `/var/lib/genarrative-ci-cache-gate/`。该目录必须由 root 持有且权限为 0700。普通 job 无法访问控制 socket。部署前先运行下述测试,不直接启用 timer。
```bash
python3 -m unittest discover -s scripts -p 'test_gitea_cache_*.py'
install -d -m 700 /etc/genarrative-ci-cache /var/lib/genarrative-ci-cache /var/lib/genarrative-ci-cache-gate
install -d /opt/genarrative-ci-cache/scripts
install -m 755 scripts/maintain-gitea-rust-cache.py scripts/gitea-runner-fetch-gate.py /opt/genarrative-ci-cache/scripts/
install -m 600 deploy/container/gitea-ci-cache.config.example.json /etc/genarrative-ci-cache/config.json
# 由维护者写入只读 API token,并核实 config.json;不要在终端回显 token。
# 设置 GITEA_FETCH_GATE_IMAGE 为已验证基础镜像的完整 Image ID 后:
docker compose -f deploy/container/gitea-runner-fetch-gate.compose.yml up -d
```
**首次接入需要空闲维护窗口**:确认无活跃 CI 且暂停新 CI 触发,再备份现有 runner 配置和注册文件,在 runner 部署的 `GITEA_INSTANCE_URL` 及 `/data/.runner` 的 `address` 中改用 `http://gitea-runner-fetch-gate:8080`,保留其余注册字段。同时在 `/data/config.yaml` 的 `runner.envs` 中设置 `GENARRATIVE_GITEA_REPOSITORY_URL: "http://gitea:3000/GenarrativeAI/Genarrative.git"`,与维护器的 `repository_url` 一致,沿用现有 job 已可达的内部 Git 通道。此专用变量也覆盖旧 PR 的 checkout;不要用同名 GITHUB_SERVER_URL 环境变量代替,Runner 会再次覆盖它。按原流程重启并验证 runner 注册。
不能仅改磁盘文件却不让进程加载;维护器还会检查独立 checkout URL,并确认入口实际见到了当前容器本次启动后的 FetchTask 来源 IP。此一次接入不由维护器冒险猜测空闲,也不对运行中 CI 动手。实例 API 根地址仍使用配置中的 HTTPS Gitea 地址,不能指向只支持 runner RPC 的入口。当前 Project CI 没有 `uses:` 或直接依赖 `github.server_url` 的 API 请求;后续新增这类调用时需沿用独立真实 Git/API 地址,不能假定只支持 RPC 的入口也是通用 Gitea 地址。
```bash
# --apply 缺省时只检查 API、runner、master 和入口路径,不修改配置或镜像。
python3 /opt/genarrative-ci-cache/scripts/maintain-gitea-rust-cache.py --config /etc/genarrative-ci-cache/config.json
install -m 644 deploy/systemd/genarrative-ci-cache.service deploy/systemd/genarrative-ci-cache.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now genarrative-ci-cache.timer
journalctl -u genarrative-ci-cache.service -n 50
```
timer 在上次执行结束后约 5 分钟再次检查,文件锁防止人工与定时执行重叠。构建日志位于状态目录 `artifacts/<SHA>/build.log`。同 SHA 构建失败后不每 5 分钟重复消耗资源;新 master 到来后自动重试,也可修复环境后显式运行 `--apply --retry`。切换事务及暂停归属先落状态文件;`ExecStopPost --resume` 恢复本维护器暂停的领取,下次执行再收敛中断的切换。其它人暂停的入口或 disabled runner 不由维护器擅自恢复。
候选切换后的验收读取真实 **master push** 的完整九个 job,要求全部 success、每个 job 均使用目标 Image ID,六个 Rust job 有启用缓存、正命中数和零缓存错误。测试失败、旧 PR 缺 prepare、混用镜像或缺日志均不清理旧版,也不伪造“缓存已验收”。仍保留源代码失败需要修复的原始结果。
入口遇到“已发送 FetchTask,但上游响应未完整结束”会持久化 `uncertain` 并拒绝继续领取/自动切换;不会因为客户端的 5 秒超时就认定服务端事务已回滚。其它 RPC 和任务上报仍继续转发。维护者需先核实 Gitea 在途领取事务与该 runner 的任务全部收敛,在维护窗口停止入口,清理它的 `uncertain` / `inflight` 标记后再启动并恢复领取。禁止自动删这些标记绕过屏障。停用自动维护先 `systemctl disable --now genarrative-ci-cache.timer`;不要为了停 timer 停止运行中的 CI 容器。已接入的领取入口继续运行,不影响普通 CI。
预热容器上限为 4 核、12 GiB,移除 capabilities,不挂宿主目录/socket,也不注入 Git/OSS/Jenkins 凭据。源码通过 `git archive` 复制,当前工作区、ignored 文件和 `.git` 不进入容器。最终从原镜像重新组装,仅复制 `/opt/genarrative-ci/rust-cache` 的 sccache、对象和来源元数据,不提交含源码/target 的预热容器;镜像本身的下载缓存与工具链校验保持原样。
快照由固定 Image ID 分发,每个 job 仅修改容器自己的写时复制层,缓存上限 4 GiB,构建末尾输出实际对象体积,结束后不回传。`ci-rust-cache.sh prepare` 清空继承的 `SCCACHE_*` 远程配置,使用独立配置和 Unix socket;旧镜像、工具链不匹配或限时 wrapper 探测失败时使用直接 rustc,正式编译启用 sccache 的 server IO 错误回退。真实编译/测试失败保留非零退出码。`report` 输出命中统计并停止本 job daemon,分片日志输出独立编译耗时。sccache 0.18.0 的只读模式在 miss 后仍打包再拒绝写入,不能用它宣称零 miss 开销;普通 CI 继续只向容器层写入。
@@ -0,0 +1,12 @@
{
"api_url": "https://git.genarrative.world/git/api/v1",
"repository": "GenarrativeAI/Genarrative",
"repository_url": "http://gitea:3000/GenarrativeAI/Genarrative.git",
"clone_url": "https://git.genarrative.world/git/GenarrativeAI/Genarrative.git",
"runner_container": "gitea-runner",
"runner_api_path": "admin/actions/runners/2",
"token_file": "/etc/genarrative-ci-cache/api-token",
"state_dir": "/var/lib/genarrative-ci-cache",
"gate_socket": "/var/lib/genarrative-ci-cache-gate/gate.sock",
"gate_url": "http://gitea-runner-fetch-gate:8080"
}
@@ -0,0 +1,23 @@
# 只加入 runner 与 Gitea 共用的内部网络,不发布端口,不挂 Docker socket。
services:
gitea-runner-fetch-gate:
image: ${GITEA_FETCH_GATE_IMAGE:?Set a verified no-object-cache CI image ID with Python 3}
container_name: gitea-runner-fetch-gate
restart: unless-stopped
entrypoint: ['python3', '/app/gitea-runner-fetch-gate.py']
environment:
GITEA_RUNNER_UPSTREAM: http://gitea:3000
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
cpus: 0.5
mem_limit: 256m
pids_limit: 128
volumes:
- /opt/genarrative-ci-cache/scripts/gitea-runner-fetch-gate.py:/app/gitea-runner-fetch-gate.py:ro
- /var/lib/genarrative-ci-cache-gate:/control
networks: [gitea-actions]
networks:
gitea-actions:
external: true
@@ -0,0 +1,22 @@
[Unit]
Description=Refresh and retain trusted Gitea CI Rust cache images
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
ConditionPathExists=/etc/genarrative-ci-cache/config.json
[Service]
Type=oneshot
User=root
WorkingDirectory=/opt/genarrative-ci-cache
UMask=0077
ExecStart=/usr/bin/python3 /opt/genarrative-ci-cache/scripts/maintain-gitea-rust-cache.py --config /etc/genarrative-ci-cache/config.json --apply
ExecStopPost=/usr/bin/python3 /opt/genarrative-ci-cache/scripts/maintain-gitea-rust-cache.py --config /etc/genarrative-ci-cache/config.json --resume
TimeoutStartSec=3h
TimeoutStopSec=60s
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
[Install]
WantedBy=multi-user.target
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Check for new Gitea CI compilation inputs
[Timer]
OnBootSec=5min
OnUnitInactiveSec=5min
RandomizedDelaySec=30s
Unit=genarrative-ci-cache.service
[Install]
WantedBy=timers.target