复用 master CI 产物自动更新编译缓存
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
仅由 master CI 导出新增缓存对象,宿主合并去重并组装限额快照,避免重复预热编译。 跟踪任务领取与最终上报,仅在安全空闲时切换镜像,移除管理员 API 依赖。 排除取消及不完整产物,定向清理过期镜像、归档和中断上传残块。 同步部署文档、共享记忆及缓存发布、合并和清理的定向测试。
This commit is contained in:
@@ -98,6 +98,12 @@ jobs:
|
||||
- name: Report isolated Rust compilation cache
|
||||
if: always()
|
||||
run: bash scripts/ci-rust-cache.sh report
|
||||
- name: Publish master Rust cache artifact
|
||||
continue-on-error: true
|
||||
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
env:
|
||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||
run: python3 scripts/export-gitea-rust-cache.py
|
||||
|
||||
ai-game-creator-shell-rust-lane-2:
|
||||
name: AI game creator shell Rust lane 2/2
|
||||
@@ -141,6 +147,12 @@ jobs:
|
||||
- name: Report isolated Rust compilation cache
|
||||
if: always()
|
||||
run: bash scripts/ci-rust-cache.sh report
|
||||
- name: Publish master Rust cache artifact
|
||||
continue-on-error: true
|
||||
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
env:
|
||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||
run: python3 scripts/export-gitea-rust-cache.py
|
||||
|
||||
# agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖,
|
||||
# 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。
|
||||
@@ -183,6 +195,12 @@ jobs:
|
||||
- name: Report isolated Rust compilation cache
|
||||
if: always()
|
||||
run: bash scripts/ci-rust-cache.sh report
|
||||
- name: Publish master Rust cache artifact
|
||||
continue-on-error: true
|
||||
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
env:
|
||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||
run: python3 scripts/export-gitea-rust-cache.py
|
||||
|
||||
# AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。
|
||||
ai-game-creator-shell-rust-crates:
|
||||
@@ -291,6 +309,12 @@ jobs:
|
||||
- name: Report isolated Rust compilation cache
|
||||
if: always()
|
||||
run: bash scripts/ci-rust-cache.sh report
|
||||
- name: Publish master Rust cache artifact
|
||||
continue-on-error: true
|
||||
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
env:
|
||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||
run: python3 scripts/export-gitea-rust-cache.py
|
||||
|
||||
backend-tests:
|
||||
name: Backend tests
|
||||
@@ -380,6 +404,12 @@ jobs:
|
||||
- name: Report isolated Rust compilation cache
|
||||
if: always()
|
||||
run: bash scripts/ci-rust-cache.sh report
|
||||
- name: Publish master Rust cache artifact
|
||||
continue-on-error: true
|
||||
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
env:
|
||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||
run: python3 scripts/export-gitea-rust-cache.py
|
||||
|
||||
# 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁,
|
||||
# 以及依赖发布产物的构建 smoke。
|
||||
@@ -442,6 +472,12 @@ jobs:
|
||||
- name: Report isolated Rust compilation cache
|
||||
if: always()
|
||||
run: bash scripts/ci-rust-cache.sh report
|
||||
- name: Publish master Rust cache artifact
|
||||
continue-on-error: true
|
||||
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
env:
|
||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||
run: python3 scripts/export-gitea-rust-cache.py
|
||||
|
||||
frontend-tests:
|
||||
name: Frontend tests
|
||||
|
||||
+17
-12
@@ -97,13 +97,13 @@ runner 配置保留原 `ubuntu-latest` 映射,`genarrative-ci` 继续映射到
|
||||
|
||||
### Rust 测试组编译对象快照
|
||||
|
||||
自动维护由宿主 systemd timer 调用 `scripts/maintain-gitea-rust-cache.py`,只管理 Gitea CI 测试镜像,不修改 Jenkins、生产发布、本地开发或客户端发行构建。维护器固定最新 master SHA,**不要求该提交事先全绿**;编译/镜像校验失败则继续使用旧版。任务串行执行,编译输入未变化时跳过,CI 繁忙时延后预热;每次只取当时最新 master,不为中间提交建立队列。构建过程中固定 SHA,不混入后续提交。
|
||||
自动维护由宿主 systemd timer 调用 `scripts/maintain-gitea-rust-cache.py`,只管理 Gitea CI 测试镜像,不修改 Jenkins、生产发布、本地开发或客户端发行构建。六个 Rust job 仅在 master push 中导出本次 CI 新增的 sccache 对象;已命中的继承对象只上传新近使用时间,通过 Gitea 原生 V4 artifact 接口上传;PR 不发布。维护器选择已结束且六组产物完整的最新 master run,校验提交、任务尝试、工具链与来源镜像,与六组实际使用的同一镜像快照合并去重,并按新近使用时间限制快照总容量为 4 GiB,然后从无对象缓存基础镜像组装新镜像,**不重复执行 Cargo 预热编译,也不要求源 run 事先全绿**。缺组、取消或校验失败时保留现役版,不混合不同 run 的对象来假装完整快照。
|
||||
|
||||
切换先通过专属入口阻断新的 FetchTask,确认已转发的领取请求全部收到完整上游响应,再确认全局 runner 不 busy、Gitea 没有 in_progress run、内层 Docker 没有容器。有任务即恢复领取并延后,不停止任务。切换后等待使用该 Image ID 的完整真实 master push CI 通过,才允许下一次升级及定向清理;不会自动重跑失败用例或为了验收额外触发整轮 CI。首次接管的历史镜像默认不归自动清理管理。
|
||||
切换先通过专属入口阻断新的 FetchTask,确认已转发的领取请求全部收到完整上游响应,并检查入口持久化跟踪的已领取任务全部结束、内层 Docker 没有活动容器。任务终态必须依据 Runner 的执行结束及最终上报协议,不能由容器暂时为空、API 已取消或请求超时推断。有任务即恢复领取并延后,不停止任务;状态未知拒绝切换。维护器只需普通账号的 `write:repository` Token(包括查询、下载及定向删除 artifact),不访问全局 Runner 管理 API。切换后等待使用该 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 没有生成/发布公共快照的权限。
|
||||
`scripts/build-gitea-rust-cache.sh` 仅用于首次缺少可消费快照时的人工 bootstrap,在已验证的 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 验证通过后,维护器定向清理自己登记的更旧缓存镜像与导出归档,保留当前版、一个回滚版、所需基础镜像及容器引用的版本;接管前的试验镜像/归档仍由维护者确认后人工清理。对象缓存上限不覆盖这些宿主文件,不使用全局 prune,也不清理其它构建的 Docker build cache。
|
||||
|
||||
@@ -114,11 +114,15 @@ 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/` 始终参与。
|
||||
自动维护不调用预热脚本;以下人工 bootstrap 脚本接受第三个参数 `<完整 master SHA>`。脚本确认该 SHA 是抓取到的 master 祖先,源码和 `ci-rust-cache.sh` 均来自该提交;两参数人工调用仍默认使用最新 master。基础镜像的 `revision` 由同一份 build context 清单计算;输入变化时维护器先生成新的无对象缓存基础镜像。Rust 输入指纹保守包含代码、配置、资源、脚本和内嵌 skill,仅排除一般 `docs/` 及几个根说明文档,`docs/openapi/` 始终参与。
|
||||
|
||||
缓存产物通过六个 `Publish master Rust cache artifact` 步骤发布,daemon 成功停止才导出。源 run 必须结束,且六组为 success/failure 并有完整上传证据;整轮或任一 Rust 组取消、旧 attempt、缺组、混用来源镜像均不采用。上传失败仅告警,不改变测试结论。每组仅导出相对其启动快照的新 key,命中只上报触达时间;宿主按真实 job 日志确认同一来源 Image ID,再复用其对象,不信任产物中的可执行文件。合并校验 tar/zip 路径、对象大小与 SHA256,同 key 内容冲突拒绝发布。sccache 可执行文件取自可信来源镜像,不从 artifact 执行代码。
|
||||
|
||||
候选已校验、导出并装载内层 Docker 后,可定向删除该 run 已收集的六份上传产物;旧镜像清理仍须等真实 CI 验证。专属缓存 artifact 保留 7 天,宿主也清理超过 7 天且源 run 已结束的遗留项,不删普通构建产物、run 或日志。Gitea 1.26.4 不清理未 finalized 的上传块:上传器使用专属双层编码块标识,宿主从 `artifact_storage_dir/tmp-upload/run-<id>-v4/` 定向清理本上传器的普通文件,要求所属仓库 master run 已结束超过 7 天且文件本身也超过 7 天。未知、年轻、符号链接或不属于本上传器的文件受到保护,不改数据库、不删除目录。已完成产物通过 API 删除;未完成上传留下的数据库元数据仍归 Gitea 管理。宿主的未完成下载/合并临时文件仅在登记的私有目录内清理。
|
||||
|
||||
### 自动维护首次部署与恢复
|
||||
|
||||
首次部署在本变更合入 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 的调用。
|
||||
首次部署在本变更合入 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 容器与网关地址。Token 使用具有目标仓库 Actions 读写权限的普通账号,授予 `write:repository` 即可,不需要 `admin`;写操作仅定向删除本维护器的缓存 artifact,不重跑或取消任务。宿主运行账号还须能通过 `clone_url` 拉取专属 clone,API Token 不自动传给 Git。`artifact_storage_dir` 必须指向当前 Gitea 本地 ActionsArtifacts 存储的真实宿主路径(不是容器路径);按当前 `/data` bind mount 和 `APP_DATA_PATH`,模板为 `/opt/gitea-stack/data/gitea/gitea/actions_artifacts`。部署时由 root 核实路径与 Gitea `[actions.artifacts]`/storage 配置一致,不能指向其它目录;此残块清理实现只支持本地存储,外部对象存储需要另行适配。
|
||||
|
||||
领取入口由 `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。
|
||||
|
||||
@@ -127,15 +131,16 @@ 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 644 scripts/gitea_cache_snapshot.py scripts/gitea_cache_upload_cleanup.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。
|
||||
# 由维护者写入 write:repository 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 注册。
|
||||
**首次接入或从不跟踪任务的旧网关升级,需要空闲维护窗口**:确认无活跃 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 地址。
|
||||
不能仅改磁盘文件却不让进程加载;维护器还会检查独立 checkout URL,并确认入口实际见到了当前容器本次启动后的 FetchTask 来源 IP。此一次接入不由维护器冒险猜测空闲,也不对运行中 CI 动手。实例 API 根地址仍使用配置中的 HTTPS Gitea 地址,不能指向只支持 runner RPC 的入口。上传脚本从独立 `GENARRATIVE_GITEA_REPOSITORY_URL` 推导真实 Gitea 地址,使用本任务临时凭据调用原生 V4 API;不依赖被网关覆盖的 `GITHUB_SERVER_URL` / `ACTIONS_RUNTIME_URL`。Gitea 1.26.4 的仓库 REST 下载接口只支持 V4,不能换回 V3 上传。宿主下载只跟随同一 HTTPS Gitea origin 的签名重定向,不转发长期 Token;如配置外部对象存储直出,需另行适配下载来源。
|
||||
|
||||
```bash
|
||||
# --apply 缺省时只检查 API、runner、master 和入口路径,不修改配置或镜像。
|
||||
@@ -146,15 +151,15 @@ 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 不由维护器擅自恢复。
|
||||
timer 在上次执行结束后约 5 分钟再次检查,文件锁防止人工与定时执行重叠。构建日志位于状态目录 `artifacts/<SHA>/build.log`。同一 run 组装失败后不每 5 分钟重复消耗资源;新的完整 master run 到来后自动尝试,也可修复环境后显式运行 `--apply --retry`。切换事务及暂停归属先落状态文件;`ExecStopPost --resume` 恢复本维护器暂停的领取,下次执行再收敛中断的切换。其它人暂停的入口不由维护器擅自恢复;不修改 Gitea 的 Runner disabled 设置。
|
||||
|
||||
候选切换后的验收读取真实 **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。
|
||||
入口遇到“已发送 FetchTask,但上游响应未完整结束”会持久化 `uncertain` 并拒绝继续领取/自动切换;不会因为客户端的 5 秒超时就认定服务端事务已回滚。其它 RPC 和任务上报仍继续转发。维护者需先核实 Gitea 在途领取事务与该 runner 的任务全部收敛,在维护窗口停止入口,核实并修复它的 `tasks.json` 任务账本及 `uncertain` / `inflight` 标记后再启动并恢复领取。网关按 Gitea 1.26.4 / Runner 2.0.0 的 Connect Protobuf 协议,在最终日志确认及 Runner 执行结束后的最终任务上报确认后才清账;取消响应不等于进程停止,任务 ID 不按超时自动删除。禁止自动删这些标记绕过屏障。停用自动维护先 `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 的预热容器;镜像本身的下载缓存与工具链校验保持原样。
|
||||
人工 bootstrap 预热容器上限为 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 继续只向容器层写入。
|
||||
快照由固定 Image ID 分发,每个 job 仅修改容器自己的写时复制层,缓存上限 4 GiB,只有 master push 在结束前回传新增对象;PR 不扫描基线、不打包、不回传。`ci-rust-cache.sh prepare` 清空继承的 `SCCACHE_*` 远程配置,使用独立配置和 Unix socket;旧镜像、工具链不匹配或限时 wrapper 探测失败时使用直接 rustc,正式编译启用 sccache 的 server IO 错误回退。真实编译/测试失败保留非零退出码。`report` 输出命中统计并停止本 job daemon,分片日志输出独立编译耗时。sccache 0.18.0 的只读模式在 miss 后仍打包再拒绝写入,不能用它宣称零 miss 开销;普通 CI 继续只向容器层写入。
|
||||
|
||||
生成候选不会改变 runner 配置。线上有活跃 CI 时禁止停止 job、重启 runner 或切换标签;只在确认空闲后按上节流程切换固定 Image ID。全组启用须重建完整快照;旧 AGC 单目标快照不能作为其它组预热成功的证据。按组用相同源码、独立干净 target 比较编译命中与耗时;分片测试内容和前置检查不同,不能直接用两条 lane 总耗时推断缓存收益。回滚时可移除各 job 的 prepare/report,或恢复无对象缓存的原基础镜像 ID,均不需要改变 incremental 或测试分片。
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
"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",
|
||||
"artifact_storage_dir": "/opt/gitea-stack/data/gitea/gitea/actions_artifacts",
|
||||
"gate_socket": "/var/lib/genarrative-ci-cache-gate/gate.sock",
|
||||
"gate_url": "http://gitea-runner-fetch-gate:8080"
|
||||
}
|
||||
|
||||
@@ -96,8 +96,8 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m
|
||||
|
||||
## Gitea CI 依赖闭合
|
||||
|
||||
Gitea Rust 缓存自动维护由宿主 `genarrative-ci-cache.timer` 调用 `scripts/maintain-gitea-rust-cache.py`:最新 master 固定 SHA 构建,不要求源提交先全绿;输入未变跳过,失败保留现役版。只有专属 FetchTask 入口已暂停且在途请求完整结束、全局 runner/API/内层 Docker 均空闲时才切换;随后真实 master push 的完整九个 job 与六组缓存统计通过,才清理自己登记的过期镜像/归档。保留当前、一个回滚版、所需基础镜像和容器引用。首次部署需要空闲窗口和只读管理员 API 凭据,操作入口见 `deploy/container/README.md`;合并代码不会自动启用宿主服务。范围仅 Gitea CI 测试镜像,不改 Jenkins、生产、本地或发行构建。
|
||||
Gitea Rust 缓存自动维护由宿主 `genarrative-ci-cache.timer` 收集同一 master push run 六个 Rust job 的原生 V4 缓存产物,不重复执行 Cargo 预热。只传本轮新 key,命中对象只传使用时间;宿主与真实来源镜像对象合并、去重、按新近使用时间裁剪到 4 GiB,从无对象缓存基础镜像重新组装。源 run 不要求全绿,但取消、缺组、旧 attempt、未完成上传或混用来源镜像不得采用。网关暂停新 FetchTask、在途领取结束、持久化任务账本清空且内层活动容器为空才切换,不打断运行中的 CI。首次接入/升级网关需空闲窗口;Token 只需普通仓库 `write:repository`,不查管理员 API。候选装载后清理已收集 artifact,遗留项保留 7 天;真实 master CI 验证后才清理旧镜像,保留当前、一个回滚版、基础镜像及容器引用。部署入口见 `deploy/container/README.md`,合并代码不等于服务启用。
|
||||
|
||||
AGC Rust 两条 lane、crates、smoke、Backend 和桌面壳测试使用镜像内可信 sccache 对象快照;Native shell release step 显式清空双 wrapper,前端/repository checks 不启用。维护者通过 `scripts/build-gitea-rust-cache.sh` 从远端 master 在限额、无宿主挂载的临时容器中按实际 cwd/profile/目标预热全部测试组,仅编译、不执行测试/应用;后端 workspace 与 spacetime-module 保持独立,AGC 的三个 cwd 入口之间清理预热 target,防止 fresh 判断漏产缓存键。最终镜像只追加 sccache、对象和来源元数据,不包含源码或 target。容量上限 4 GiB,不替代宿主旧镜像/归档清理。PR 只写当前容器层、不回传,不开放 Docker API/发布权限;继续禁用 incremental。`ci-rust-cache.sh` 在快照缺失、工具链不符或 wrapper 探测失败时直接编译,并隔离远程缓存配置和 daemon。分片日志记录编译耗时,收尾输出命中统计;两个 lane 的测试和前置检查不同,耗时差不是严格 A/B。线上存在活跃 CI 时不得重启 runner 或切换标签;全组启用前须刷新完整快照并逐组验证,详见开发运维文档。
|
||||
AGC Rust 两条 lane、crates、smoke、Backend 和桌面壳测试使用镜像内可信 sccache 对象快照;Native shell release step 显式清空双 wrapper,前端/repository checks 不启用。仅首次人工 bootstrap 时,维护者通过 `scripts/build-gitea-rust-cache.sh` 从远端 master 在限额、无宿主挂载的临时容器中按实际 cwd/profile/目标预热全部测试组,仅编译、不执行测试/应用;后端 workspace 与 spacetime-module 保持独立,AGC 的三个 cwd 入口之间清理预热 target,防止 fresh 判断漏产缓存键。最终镜像只追加 sccache、对象和来源元数据,不包含源码或 target。容量上限 4 GiB,不替代宿主旧镜像/归档清理。PR 只写当前容器层、不回传,不开放 Docker API/发布权限;继续禁用 incremental。`ci-rust-cache.sh` 在快照缺失、工具链不符或 wrapper 探测失败时直接编译,并隔离远程缓存配置和 daemon。分片日志记录编译耗时,收尾输出命中统计;两个 lane 的测试和前置检查不同,耗时差不是严格 A/B。线上存在活跃 CI 时不得重启 runner 或切换标签;全组启用前须刷新完整快照并逐组验证,详见开发运维文档。
|
||||
|
||||
`.gitea/workflows/project-ci.yml` 的客户端门禁拆成 lane 与功能 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust lane 1/2`、`lane 2/2` 各自预取一次 AGC 壳 manifest,并顺序运行两片 Rust bin 单测;`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。两条 Rust lane、smoke job 与 crates job 只用 cargo 与 node 内建模块,因此不执行 `npm ci`。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译后按 `--list` 名单分 4 片:每次分片调用用 `--shard-index=<i>` 只跑自己那片,片内保持 `--test-threads=1` 并使用独立 `TMPDIR`;两条 lane 之间并发,lane 内顺序运行两片,避免重复依赖预热和同一容器内多进程争抢。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片调用都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。
|
||||
|
||||
@@ -5955,5 +5955,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
|
||||
- 验证:相同源码、资源上限和独立干净 target 下分别记录无缓存、冷缓存、热缓存的编译耗时和 hit/miss;只有真实热命中有净收益才切换候选镜像。PR 的写入始终留在 job 容器层,公共快照仍由可信维护流程生成。
|
||||
- 统计:job 私有 daemon 设置 `SCCACHE_IDLE_TIMEOUT=0`,由 `report` 显式停止;最终测试 bin 的不可缓存编译或测试可能超过一分钟,短 idle timeout 会让 daemon 提前退出,结尾查询启动新 daemon 后误报零次请求。容器销毁仍会回收该 job 的全部进程。
|
||||
- 磁盘:快照构建拒绝含 `/opt/genarrative-ci/rust-cache` 的基础镜像,始终从无对象缓存的镜像重建;容器内删除旧对象不能释放 Docker 底层。对象缓存容量上限不涵盖宿主旧镜像及导出归档。自动维护只回收自己预先登记的 Image ID/tag 和专属归档,保留当前、一个回滚版、基础镜像及所有容器引用;内层按 ID 导入的镜像可能没有 tag,不能只按 tag 判断已清理。接管前历史试验版本仍需人工确认。
|
||||
- 自动切换:Gitea 1.26.4 的 disabled 检查与 FetchTask 创建任务事务之间没有共同锁,Runner 2.0.0 的 fetch_timeout 也不能保证服务端事务回滚。不能以“禁用后睡几秒”或反复 docker ps 冒充领取屏障。专属入口阻断新 FetchTask,并完整读取在途响应;未知完成状态持久化 uncertain 并停止自动切换。Runner 的 .runner 文件还会因 label 更新回写,mtime 不等于地址变更时间;要结合已配置地址、当前容器启动时间与入口实际见到的 FetchTask 来源确认路由已生效。
|
||||
- CI 产物清理:Gitea 1.26.4 的仓库 REST 仅列出 finalized/expired V4 artifact,内置到期清理不回收上传中断的 tmp-upload 分块。缓存上传块须带专属标识,宿主只清理目标仓库已结束且超过 7 天的 master run 中同样过期的自有普通文件,未知文件/符号链接保护,不改数据库或全局 prune。Artifact.workflow_run 仅含 ID/SHA,判断过期产物所属事件和状态须再读 run API,不能当作完整 run 使用。
|
||||
- 自动切换:Gitea 1.26.4 的 disabled 检查与 FetchTask 事务不原子,Runner 客户端超时不能证明服务端回滚,容器暂时为空也不能证明没有已领取任务。网关必须解析实际 Connect Protobuf/gzip,转发 FetchTask 结果前持久化任务 ID,仅在最终日志及执行清理后的最终 UpdateTask 确认后清账;取消响应不能提前释放。暂停新领取、在途为零、账本为零且内层活动容器为空才可切换,无需全局 Runner admin API。未知协议/响应或崩溃遗留标记停止切换;旧网关缺 active_tasks 不能默认零。首次接入与账本升级须空闲窗口。.runner 的 mtime 不证明地址已加载,应核验真实 FetchTask 来源及本次容器启动时间。
|
||||
- 扩展:预热所有 Rust 测试组时保留各自 cwd、profile、features 和锁策略;同一临时 target 的 Cargo fresh 不代表不同 cwd 都已生成缓存键,AGC 提示词契约、分片和 smoke 切换入口前清理预热 target。不要把 workspace 与 spacetime-module 合并成一次编译;Native shell release step 清空双 wrapper,避免将测试缓存扩展成发布缓存。当前 sccache 0.18.0 的 READ_ONLY 在 miss 后仍打包产物并产生 cache write error,不适合用来承诺“未命中无开销”。
|
||||
|
||||
@@ -334,17 +334,17 @@ master 日常交付必须禁止直接 push,只允许经 PR 在最近一次 Pro
|
||||
|
||||
#### Rust 测试组的隔离编译缓存
|
||||
|
||||
每次生成快照必须使用不含对象缓存的原始 CI 基础镜像;构建脚本在拉取源码和预热前拒绝已存在 `/opt/genarrative-ci/rust-cache` 的基础镜像,避免重复叠加不可释放的旧对象层。宿主自动维护器 `scripts/maintain-gitea-rust-cache.py` 固定最新 master SHA 构建,不要求该提交事先全绿;源码或镜像校验失败保留旧版。切换后通过真实 master CI 才按 `deploy/container/README.md` 自动定向清理自己登记的旧镜像及归档,保护当前版、一个回滚版、所需基础镜像及全部容器引用;首次接管前的手工试验版本不自动认领。
|
||||
每次生成快照必须使用不含对象缓存的原始 CI 基础镜像,避免叠加不可释放的旧层。宿主自动维护器 `scripts/maintain-gitea-rust-cache.py` 收集完整 master push CI 的六组 V4 增量产物,复用其真实来源镜像对象并合并去重,总容量 4 GiB,不重复执行 Cargo 预热编译。测试失败可收集,取消、缺组、未完成上传、旧 attempt 或混用来源镜像不可发布。候选校验、导出和装载后删除已收集 artifact,遗留项保留 7 天;切换后通过真实 master CI 才定向清理受管旧镜像/归档,保护当前、一个回滚版、基础镜像及容器引用。
|
||||
|
||||
AGC Rust 两条 lane、crates、agent-run smoke、Backend 和 Native shell 的桌面壳测试均启用 sccache。Native shell 的 release build smoke 显式清空两个 wrapper,保持发布构建原有 profile/features 与资源 staging;前端和 repository checks 不启用对象缓存。继续设置 `CARGO_INCREMENTAL=0`,不共享 target、不恢复 Actions 可写缓存。可信快照通过已有固定 Image ID 分发:镜像只增加固定版本的 sccache、编译对象和来源元数据,不包含源码、target 或凭据;容器写时复制层承接本 job 的新增对象,job 删除后丢弃,PR 没有 Docker API 或快照发布权限。该权限边界由 runner 基础设施保证,不能仅用 workflow 的分支条件替代。
|
||||
|
||||
维护者在受信任 checkout 中运行 `bash scripts/build-gitea-rust-cache.sh <已验证基础镜像> <候选镜像tag> [完整master-SHA]`。自动维护显式传入固定 SHA,脚本验证它为抓取到的 master 祖先,源码及缓存辅助脚本来自同一归档;人工省略 SHA 时默认最新 master。在无宿主目录挂载、无凭据且有 CPU/内存上限的临时容器中预热各组实际 Cargo 目标,只编译、不执行测试或应用。后端 workspace 仍排除 spacetime-module,再单独预热该模块,禁止为缓存合并 features。AGC 独立 crate 与插件沿用各自 manifest/锁文件策略;未提交锁文件的 crate 仍可能因新版本解析产生 miss。最终镜像从原基础镜像重新组装,只导出 sccache 对象,不提交预热容器。公共快照不接受 PR 上传,也不复用 Jenkins 发布缓存。工具链或系统依赖变化时重新生成快照,缓存命中仍由 sccache 的编译输入校验决定,不能省略 Cargo 构建。
|
||||
人工 bootstrap 在没有可消费快照时使用 `bash scripts/build-gitea-rust-cache.sh <已验证基础镜像> <候选镜像tag> [完整master-SHA]`;自动维护不调用它。bootstrap 固定 master 归档,在无宿主挂载、无凭据、有资源限额的容器中只编译预热,不执行测试/应用,Cargo features 和工作目录保持实际 CI 口径。日常更新由 master CI 导出新 key,命中继承对象只传使用时间;PR 不导出、不扫描。宿主验证来源、大小和哈希,不从上传产物执行程序,只从可信来源镜像复制固定 sccache。工具链或基础镜像输入变化时重建无对象缓存基础镜像,缓存工具链必须匹配。公共快照不接受 PR,不复用 Jenkins 发布缓存。
|
||||
|
||||
各 Rust job 在编译前执行 `scripts/ci-rust-cache.sh prepare`:检查快照与 rustc 身份,隔离 sccache 配置和 daemon,限时探测真实 wrapper。旧镜像没有快照或探测失败时保留空 wrapper,输出 fallback 原因;缓存故障不得把真实编译/测试失败改成成功,也不允许重跑整个测试组掩盖失败。结束时 `report` 输出命中统计;分片日志单独记录 Cargo 编译耗时。对象缓存上限为 4 GiB,快照构建完成后输出实际体积;最终测试 bin 的链接仍须执行。全组开启时必须生成覆盖全部目标的新快照,不能把旧 AGC 单目标快照当作后端/桌面壳的预热验收。
|
||||
|
||||
候选镜像沿用 `gitea-ci-job-image.sh verify/export/load-runner` 验证和装载。现役 CI 正在运行时只允许准备候选镜像,不停止 job、不重启 runner、不切换标签;待确认无运行中的 job 后再按上述镜像更新顺序切换。验收分别记录独立 target 的无缓存、冷缓存和热缓存构建耗时及命中率,并检查失效/故障回退、两容器写入互不影响。未完成真实 CI 验证前不能宣称提速或推广到其它 job。
|
||||
|
||||
自动维护首次部署及故障恢复统一见 `deploy/container/README.md` 的“自动维护首次部署与恢复”。systemd timer 约每 5 分钟检查一次,持有文件锁,使用独立 clone/状态/归档目录;只管理 Gitea CI 测试镜像,不改变 Jenkins、生产、本地或客户端发行构建,Native shell release smoke 继续清空双 wrapper。自动切换使用专属 FetchTask 入口的暂停与在途计数屏障,不能将 Gitea disabled 或客户端超时当成事务完成证据;屏障收敛后还检查全局 runner busy、Gitea in_progress 和内层容器。首次接入入口必须安排空闲维护窗口,不在运行中的 CI 上试切;脚本合并不等于服务已在线启用。
|
||||
自动维护首次部署及故障恢复统一见 `deploy/container/README.md`。timer 约每 5 分钟检查,文件锁串行执行,使用专属 clone/状态/归档目录;只管理 Gitea CI 测试镜像,Native shell release smoke 继续清空双 wrapper。凭据只需普通账号 `write:repository`,读取 run/job/日志/产物并定向删除缓存 artifact,不需要管理员权限。切换依赖 FetchTask 暂停与在途屏障、任务持久账本和内层 Docker 空闲;Gitea 显示 cancelled、disabled 或客户端超时都不能证明执行已经结束。网关依据最终日志与执行收尾后的最终任务上报确认释放任务。首次接入/旧网关升级须等待 CI 自然结束的空闲窗口,不在运行中的 CI 上试切;脚本合并不等于服务已部署。
|
||||
|
||||
Rust 对象 key 包含编译工作目录。预热必须使用已核实的 Gitea checkout 路径 `/workspace/GenarrativeAI/Genarrative`:后端、独立 crate、插件、桌面壳及提示词契约从仓库根目录启动 Cargo,AGC 分片从 `apps/ai-game-creator-shell/src-tauri` 启动,agent-run smoke 从 `apps/ai-game-creator-shell` 启动。切换 AGC 编译入口前清理该临时容器的 AGC target,防止 Cargo 的 fresh 判断跳过新 cwd 所需对象;CI 各 job 的 target 本来就独立。快照保存 `workspace.txt`,job 根路径不符时直接编译。不要仅设置 `SCCACHE_BASEDIRS` 就假定 Rust 可以跨 cwd 命中,也不为缓存改写 `RUSTFLAGS` 或源码路径语义。固定 sccache `0.18.0` 的基础设施错误码 `2` 会由 wrapper 回退执行本次 rustc;其它退出码原样返回,升级 sccache/Rust 时须复核此约定。
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# 仅消费镜像内的可信快照;所有写入留在当前容器的可写层。
|
||||
# 消费镜像内可信快照;写入留在任务容器,master push 可在任务结束后导出。
|
||||
set -euo pipefail
|
||||
|
||||
cache_root="${GENARRATIVE_CI_RUST_CACHE_ROOT:-/opt/genarrative-ci/rust-cache}"
|
||||
@@ -22,7 +22,7 @@ configure_local_cache() {
|
||||
case "${1:-}" in
|
||||
prepare)
|
||||
: "${GITHUB_ENV:?GITHUB_ENV is required}"
|
||||
printf 'RUSTC_WRAPPER=\nCARGO_BUILD_RUSTC_WRAPPER=\nGENARRATIVE_CI_RUST_CACHE_STATE=\n' >> "${GITHUB_ENV}"
|
||||
printf 'RUSTC_WRAPPER=\nCARGO_BUILD_RUSTC_WRAPPER=\nGENARRATIVE_CI_RUST_CACHE_STATE=\nGENARRATIVE_CI_RUST_CACHE_EXPORT_READY=\n' >> "${GITHUB_ENV}"
|
||||
fallback() { printf '[rust-cache] mode=direct reason=%s\n' "$1"; exit 0; }
|
||||
[[ -x "${cache_binary}" && -d "${cache_root}/objects" && -f "${cache_root}/rustc.txt" && -f "${cache_root}/source-commit.txt" && -f "${cache_root}/workspace.txt" ]] \
|
||||
|| fallback snapshot-unavailable
|
||||
@@ -44,6 +44,10 @@ case "${1:-}" in
|
||||
fallback wrapper-probe-failed
|
||||
fi
|
||||
script_path="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/$(basename "${BASH_SOURCE[0]}")"
|
||||
if [[ "${GITHUB_EVENT_NAME:-}" == push && "${GITHUB_REF:-}" == refs/heads/master ]]; then
|
||||
# 只记 key/大小/时间,不读取对象正文;PR 没有扫描与打包开销。
|
||||
python3 "$(dirname "${script_path}")/export-gitea-rust-cache.py" baseline "${state}/baseline.json"
|
||||
fi
|
||||
# wrapper 路径也参与 Rust cache key;固定容器内路径,隔离由 job 容器保证。
|
||||
wrapper_path="${cache_root}/rustc-wrapper"
|
||||
printf '#!/usr/bin/env bash\nexec bash %q "$@"\n' "${script_path}" > "${wrapper_path}"
|
||||
@@ -59,7 +63,13 @@ case "${1:-}" in
|
||||
if [[ -n "${state}" && -d "${state}" ]]; then
|
||||
configure_local_cache
|
||||
timeout --kill-after=2 5 "${cache_binary}" --show-stats || true
|
||||
timeout --kill-after=2 5 "${cache_binary}" --stop-server >/dev/null 2>&1 || true
|
||||
# 只有成功停服后才允许读取对象;失败不能把仍在写入的目录发布成完整快照。
|
||||
if timeout --kill-after=2 15 "${cache_binary}" --stop-server >/dev/null 2>&1; then
|
||||
if [[ "${GITHUB_EVENT_NAME:-}" == push && "${GITHUB_REF:-}" == refs/heads/master && ! -f "${state}/disabled" && -f "${state}/baseline.json" ]]; then
|
||||
mv -- "${state}/baseline.json" "${cache_root}/export-baseline.json"
|
||||
printf 'GENARRATIVE_CI_RUST_CACHE_EXPORT_READY=1\n' >> "${GITHUB_ENV}"
|
||||
fi
|
||||
fi
|
||||
rm -rf -- "${state}"
|
||||
else
|
||||
printf '[rust-cache] mode=direct\n'
|
||||
|
||||
@@ -35,7 +35,8 @@ function fixture(t) {
|
||||
`#!/bin/bash
|
||||
set -eu
|
||||
case "$1" in
|
||||
--stop-server|--show-stats) exit 0 ;;
|
||||
--stop-server) exit "\${STOP_FAILURE:-0}" ;;
|
||||
--show-stats) exit 0 ;;
|
||||
esac
|
||||
if [[ "$*" == *-vV ]]; then
|
||||
[[ "\${PROBE_FAILURE:-}" != 1 ]] || exit 1
|
||||
@@ -215,3 +216,25 @@ linuxTest('lost local cache state still invokes the real compiler', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.run(['/bin/bash', '-c', 'exit 43']).status, 43);
|
||||
});
|
||||
|
||||
linuxTest('only a cleanly stopped master cache can export objects', (t) => {
|
||||
const master = {
|
||||
GITHUB_EVENT_NAME: 'push',
|
||||
GITHUB_REF: 'refs/heads/master',
|
||||
};
|
||||
for (const stopFailure of ['0', '1']) {
|
||||
const f = fixture(t);
|
||||
const prepared = f.run(['prepare'], master);
|
||||
assert.equal(prepared.status, 0, prepared.stderr);
|
||||
const result = f.run(['report'], {
|
||||
...f.preparedEnv(),
|
||||
...master,
|
||||
STOP_FAILURE: stopFailure,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(
|
||||
f.preparedEnv().GENARRATIVE_CI_RUST_CACHE_EXPORT_READY,
|
||||
stopFailure === '0' ? '1' : '',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
"""只把 master push 的任务内 sccache 对象导出为 Gitea Actions 产物。"""
|
||||
|
||||
import base64
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
|
||||
MAX_OBJECT_BYTES = 4 * 1024**3
|
||||
CHUNK_BYTES = 8 * 1024**2
|
||||
JOBS = {
|
||||
"ai-game-creator-shell-rust-lane-1",
|
||||
"ai-game-creator-shell-rust-lane-2",
|
||||
"ai-game-creator-shell-rust-smoke",
|
||||
"ai-game-creator-shell-rust-crates",
|
||||
"backend-tests",
|
||||
"native-shell-tests",
|
||||
}
|
||||
|
||||
|
||||
def object_path(name):
|
||||
parts = name.split("/")
|
||||
return (
|
||||
len(parts) == 3
|
||||
and re.fullmatch(r"[a-f0-9]{64}", parts[2]) is not None
|
||||
and parts[0] == parts[2][0]
|
||||
and parts[1] == parts[2][1]
|
||||
)
|
||||
|
||||
|
||||
class HashingReader:
|
||||
def __init__(self, stream):
|
||||
self.stream = stream
|
||||
self.digest = hashlib.sha256()
|
||||
|
||||
def read(self, size=-1):
|
||||
data = self.stream.read(size)
|
||||
self.digest.update(data)
|
||||
return data
|
||||
|
||||
|
||||
def scan_objects(root):
|
||||
objects = root / "objects"
|
||||
if objects.is_symlink() or not objects.is_dir():
|
||||
raise ValueError("cache object directory is unavailable")
|
||||
candidates = []
|
||||
for directory, directories, files in os.walk(objects, followlinks=False):
|
||||
directories[:] = [
|
||||
name for name in directories if not (Path(directory) / name).is_symlink()
|
||||
]
|
||||
for filename in files:
|
||||
path = Path(directory) / filename
|
||||
relative = path.relative_to(objects).as_posix()
|
||||
info = path.lstat()
|
||||
if object_path(relative) and stat.S_ISREG(info.st_mode):
|
||||
candidates.append((path, relative, info))
|
||||
return candidates
|
||||
|
||||
|
||||
def save_baseline(root, destination):
|
||||
# sccache 命中会更新 mtime,不能把时间变化当成内容变化,否则又变成全量上传。
|
||||
baseline = {
|
||||
relative: {"size": info.st_size, "mtime_ns": info.st_mtime_ns}
|
||||
for _, relative, info in scan_objects(root)
|
||||
}
|
||||
destination.write_text(json.dumps(baseline), encoding="utf-8")
|
||||
|
||||
|
||||
def pack_snapshot(root, destination, metadata, baseline, limit=MAX_OBJECT_BYTES):
|
||||
"""仅归档新 key;命中的已有对象只传递新近使用时间,不传输对象内容。"""
|
||||
candidates = scan_objects(root)
|
||||
candidates.sort(key=lambda item: (-item[2].st_mtime_ns, item[1]))
|
||||
entries = []
|
||||
touched = []
|
||||
total = 0
|
||||
with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_STORED) as bundle, \
|
||||
bundle.open("snapshot.tar", "w", force_zip64=True) as tar_stream, \
|
||||
tarfile.open(fileobj=tar_stream, mode="w|", format=tarfile.USTAR_FORMAT) as archive:
|
||||
for path, relative, info in candidates:
|
||||
previous = baseline.get(relative)
|
||||
if previous is not None and previous["size"] == info.st_size:
|
||||
if previous["mtime_ns"] != info.st_mtime_ns:
|
||||
touched.append({"path": "objects/" + relative, "mtime_ns": info.st_mtime_ns})
|
||||
continue
|
||||
if info.st_size <= 0 or total + info.st_size > limit:
|
||||
continue
|
||||
# report 已成功停止 daemon;打开后再核实对象,避免导出变化中的文件。
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
with os.fdopen(descriptor, "rb") as stream:
|
||||
actual = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(actual.st_mode) or (
|
||||
actual.st_size, actual.st_mtime_ns, actual.st_ino
|
||||
) != (info.st_size, info.st_mtime_ns, info.st_ino):
|
||||
raise ValueError("cache object changed during export")
|
||||
member = tarfile.TarInfo("objects/" + relative)
|
||||
member.size = info.st_size
|
||||
member.mode = 0o644
|
||||
member.mtime = int(info.st_mtime)
|
||||
reader = HashingReader(stream)
|
||||
archive.addfile(member, reader)
|
||||
entries.append({
|
||||
"path": member.name,
|
||||
"size": info.st_size,
|
||||
"sha256": reader.digest.hexdigest(),
|
||||
"mtime_ns": info.st_mtime_ns,
|
||||
})
|
||||
total += info.st_size
|
||||
manifest = dict(metadata, schema=1, mode="delta", objects=entries, touched=touched)
|
||||
data = json.dumps(manifest, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
member = tarfile.TarInfo("manifest.json")
|
||||
member.size = len(data)
|
||||
member.mode = 0o644
|
||||
archive.addfile(member, io.BytesIO(data))
|
||||
return len(entries), total
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
# runtime token 只允许发往明确配置的 Gitea 地址。
|
||||
return None
|
||||
|
||||
|
||||
def request(method, url, token, data=None, headers=None, attempts=3):
|
||||
opener = urllib.request.build_opener(NoRedirect())
|
||||
for attempt in range(attempts):
|
||||
req = urllib.request.Request(url, data=data, method=method, headers={
|
||||
**({"Authorization": "Bearer " + token} if token else {}),
|
||||
"Content-Type": "application/json",
|
||||
**(headers or {}),
|
||||
})
|
||||
try:
|
||||
with opener.open(req, timeout=120) as response:
|
||||
body = response.read(1024 * 1024)
|
||||
return json.loads(body) if body else {}
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code not in (408, 429, 500, 502, 503, 504) or attempt == attempts - 1:
|
||||
raise RuntimeError(f"artifact {method} failed: HTTP {error.code}") from None
|
||||
except (urllib.error.URLError, TimeoutError, ConnectionError):
|
||||
if attempt == attempts - 1:
|
||||
raise RuntimeError(f"artifact {method} connection failed") from None
|
||||
time.sleep(2 ** attempt)
|
||||
|
||||
|
||||
def artifact_base_url(env):
|
||||
# .runner.address 可指向仅支持 RPC 的领取网关,不能用 GITHUB_SERVER_URL。
|
||||
repository = env["GITHUB_REPOSITORY"]
|
||||
clone_url = env["GENARRATIVE_GITEA_REPOSITORY_URL"]
|
||||
suffix = "/" + repository + ".git"
|
||||
parsed = urllib.parse.urlsplit(clone_url)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or parsed.username or parsed.password or parsed.query or parsed.fragment
|
||||
or not parsed.path.endswith(suffix)
|
||||
):
|
||||
raise ValueError("a credential-free HTTP Gitea repository URL is required")
|
||||
return urllib.parse.urlunsplit((
|
||||
parsed.scheme, parsed.netloc, parsed.path[:-len(suffix)], "", ""
|
||||
))
|
||||
|
||||
|
||||
def upload_snapshot(path, name, run_id, base_url, token):
|
||||
"""Gitea 1.26.4 原生 v4:只有完成全部块并校验 SHA256 后才发布。"""
|
||||
producer_match = re.fullmatch(r"rust-cache-v1-([a-z0-9-]+)-attempt-(0|[1-9][0-9]*)", name)
|
||||
if not producer_match or producer_match[1] not in JOBS:
|
||||
raise ValueError("unexpected cache artifact name")
|
||||
producer = f"{producer_match[1]}:{producer_match[2]}"
|
||||
endpoint = f"{base_url}/twirp/github.actions.results.api.v1.ArtifactService"
|
||||
# Gitea 从任务凭据确定 job,只要求请求中的 run ID 与任务所属 run 一致。
|
||||
identity = {"workflowRunBackendId": str(run_id), "name": name}
|
||||
created = request("POST", endpoint + "/CreateArtifact", token, json.dumps({
|
||||
**identity, "version": 4,
|
||||
# Gitea 将剩余小时向下取整成天;多给一天,服务端才实际保留至少七天。
|
||||
"expiresAt": (datetime.now(timezone.utc) + timedelta(days=8)).isoformat(),
|
||||
}).encode())
|
||||
received = urllib.parse.urlsplit(created.get("signedUploadUrl", ""))
|
||||
expected_path = urllib.parse.urlsplit(endpoint).path + "/UploadArtifact"
|
||||
if not created.get("ok") or received.path != expected_path or not received.query:
|
||||
raise ValueError("unexpected artifact upload path")
|
||||
# 服务可能返回外部 AppURL;保留签名,但只连接配置的内部 Gitea 地址。
|
||||
upload_url = endpoint + "/UploadArtifact?" + received.query
|
||||
size = path.stat().st_size
|
||||
digest = hashlib.sha256()
|
||||
blocks = []
|
||||
with path.open("rb") as source:
|
||||
while chunk := source.read(CHUNK_BYTES):
|
||||
# 双层编码中的内层保留所属任务;宿主只回收带此前缀的过期未完成块。
|
||||
block = base64.b64encode(
|
||||
f"genarrative-rust-cache-v1:{producer}:{len(blocks):08d}".encode()
|
||||
).decode()
|
||||
blocks.append(block)
|
||||
digest.update(chunk)
|
||||
request("PUT", upload_url + "&" + urllib.parse.urlencode({
|
||||
"comp": "block", "blockid": block,
|
||||
}), None, chunk, {
|
||||
"Content-Type": "application/octet-stream",
|
||||
})
|
||||
blocklist = "<BlockList>" + "".join(f"<Latest>{block}</Latest>" for block in blocks) + "</BlockList>"
|
||||
request("PUT", upload_url + "&comp=blocklist", None, blocklist.encode(), {
|
||||
"Content-Type": "application/xml",
|
||||
})
|
||||
# Finalize 会消耗服务端块列表;响应不确定时不盲目重试、也不宣告成功。
|
||||
finalized = request("POST", endpoint + "/FinalizeArtifact", token, json.dumps({
|
||||
**identity, "size": str(size), "hash": "sha256:" + digest.hexdigest(),
|
||||
}).encode(), attempts=1)
|
||||
if not finalized.get("ok"):
|
||||
raise ValueError("artifact was not finalized")
|
||||
|
||||
|
||||
def export(env):
|
||||
# 双重限定:手工调用、PR 和其它分支不会扫描、打包或上传对象。
|
||||
if env.get("GITHUB_EVENT_NAME") != "push" or env.get("GITHUB_REF") != "refs/heads/master":
|
||||
return
|
||||
if env.get("GENARRATIVE_CI_RUST_CACHE_EXPORT_READY") != "1":
|
||||
print("[rust-cache] export skipped: cache daemon did not stop cleanly")
|
||||
return
|
||||
job = env.get("GITHUB_JOB")
|
||||
if job not in JOBS:
|
||||
raise ValueError("unexpected Rust cache job")
|
||||
sha = env["GITHUB_SHA"]
|
||||
if not re.fullmatch(r"[a-f0-9]{40}", sha):
|
||||
raise ValueError("a complete source SHA is required")
|
||||
run_id, attempt = int(env["GITHUB_RUN_ID"]), int(env["GITHUB_RUN_ATTEMPT"])
|
||||
if run_id <= 0 or attempt < 0:
|
||||
raise ValueError("invalid run identity")
|
||||
token = env.get("ACTIONS_RUNTIME_TOKEN") or env["GENARRATIVE_GITEA_TOKEN"]
|
||||
base_url = artifact_base_url(env)
|
||||
root = Path(env.get("GENARRATIVE_CI_RUST_CACHE_ROOT", "/opt/genarrative-ci/rust-cache"))
|
||||
baseline_path = root / "export-baseline.json"
|
||||
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
|
||||
rustc = subprocess.check_output(["rustc", "-vV"], text=True)
|
||||
workspace = str(Path.cwd().resolve())
|
||||
if rustc != (root / "rustc.txt").read_text() or workspace != (root / "workspace.txt").read_text().strip():
|
||||
raise ValueError("cache provenance changed after prepare")
|
||||
metadata = {
|
||||
"repository": env["GITHUB_REPOSITORY"],
|
||||
"run_id": run_id, "run_attempt": attempt, "job": job, "source_sha": sha,
|
||||
"rustc": rustc, "workspace": workspace,
|
||||
"inherited_source_sha": (root / "source-commit.txt").read_text().strip(),
|
||||
"sccache_version": subprocess.check_output([str(root / "sccache"), "--version"], text=True).strip(),
|
||||
}
|
||||
if (root / "base-image.txt").is_file():
|
||||
metadata["base_image"] = (root / "base-image.txt").read_text().strip()
|
||||
name = f"rust-cache-v1-{job}-attempt-{attempt}"
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="ci-rust-cache-export-") as directory:
|
||||
path = Path(directory) / "snapshot.zip"
|
||||
count, size = pack_snapshot(root, path, metadata, baseline)
|
||||
upload_snapshot(path, name, run_id, base_url, token)
|
||||
finally:
|
||||
baseline_path.unlink(missing_ok=True)
|
||||
print(f"[rust-cache] artifact={name} objects={count} bytes={size} complete=true")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "baseline":
|
||||
save_baseline(Path(os.environ.get("GENARRATIVE_CI_RUST_CACHE_ROOT", "/opt/genarrative-ci/rust-cache")), Path(sys.argv[2]))
|
||||
else:
|
||||
export(os.environ)
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
import http.client
|
||||
import http.server
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -10,12 +12,90 @@ import socketserver
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import zlib
|
||||
|
||||
MAX_BODY = 32 * 1024 * 1024
|
||||
HOP_HEADERS = {
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailer", "transfer-encoding", "upgrade", "host", "content-length",
|
||||
}
|
||||
RPC_PREFIX = "/api/actions/runner.v1.RunnerService/"
|
||||
|
||||
|
||||
def protobuf_fields(data):
|
||||
"""只解码已核对的 actions-proto-go v0.4.1 字段,不引入 protobuf 运行时。"""
|
||||
fields = {}
|
||||
offset = 0
|
||||
|
||||
def varint():
|
||||
nonlocal offset
|
||||
value = 0
|
||||
for shift in range(0, 70, 7):
|
||||
if offset >= len(data):
|
||||
raise ValueError("truncated protobuf")
|
||||
byte = data[offset]
|
||||
offset += 1
|
||||
value |= (byte & 127) << shift
|
||||
if byte < 128:
|
||||
return value
|
||||
raise ValueError("invalid protobuf varint")
|
||||
|
||||
while offset < len(data):
|
||||
tag = varint()
|
||||
number, wire = tag >> 3, tag & 7
|
||||
if not number:
|
||||
raise ValueError("invalid protobuf tag")
|
||||
if wire == 0:
|
||||
value = varint()
|
||||
elif wire in (1, 2, 5):
|
||||
length = varint() if wire == 2 else (8 if wire == 1 else 4)
|
||||
if offset + length > len(data):
|
||||
raise ValueError("truncated protobuf field")
|
||||
value = data[offset:offset + length]
|
||||
offset += length
|
||||
else:
|
||||
raise ValueError("unsupported protobuf wire type")
|
||||
fields.setdefault(number, []).append(value)
|
||||
return fields
|
||||
|
||||
|
||||
def single(fields, number, default=None):
|
||||
values = fields.get(number, [])
|
||||
if len(values) > 1:
|
||||
raise ValueError("ambiguous tracking field")
|
||||
return values[0] if values else default
|
||||
|
||||
|
||||
def rpc_fields(body, headers):
|
||||
encoding = headers.get("Content-Encoding", "identity").lower()
|
||||
if encoding == "gzip":
|
||||
with gzip.GzipFile(fileobj=io.BytesIO(body)) as stream:
|
||||
body = stream.read(MAX_BODY + 1)
|
||||
elif encoding != "identity":
|
||||
raise ValueError("unsupported RPC encoding")
|
||||
if len(body) > MAX_BODY:
|
||||
raise ValueError("decoded RPC too large")
|
||||
if headers.get("Content-Type", "").split(";", 1)[0] != "application/proto":
|
||||
raise ValueError("task tracking requires Connect protobuf")
|
||||
return protobuf_fields(body)
|
||||
|
||||
|
||||
def task_state(fields):
|
||||
nested = single(fields, 1)
|
||||
if not isinstance(nested, bytes):
|
||||
raise ValueError("missing task state")
|
||||
state = protobuf_fields(nested)
|
||||
task_id = positive_id(single(state, 1))
|
||||
result = single(state, 2, 0)
|
||||
if type(result) is not int or result not in range(5):
|
||||
raise ValueError("unknown task result")
|
||||
return task_id, result
|
||||
|
||||
|
||||
def positive_id(value):
|
||||
if type(value) is not int or not 0 < value < 2 ** 63:
|
||||
raise ValueError("invalid task ID")
|
||||
return str(value)
|
||||
|
||||
|
||||
class Gate:
|
||||
@@ -26,7 +106,19 @@ class Gate:
|
||||
self.inflight = 0
|
||||
self.last_fetch_peer = None
|
||||
self.last_fetch_at = None
|
||||
self.tasks = {}
|
||||
self.uncertain = (self.directory / "uncertain").exists()
|
||||
try:
|
||||
if (self.directory / "tasks.json").exists():
|
||||
tasks = json.loads((self.directory / "tasks.json").read_text())
|
||||
if (not isinstance(tasks, dict) or any(
|
||||
positive_id(int(key)) != key or type(value) is not bool
|
||||
for key, value in tasks.items())):
|
||||
raise ValueError("invalid task ledger")
|
||||
self.tasks = tasks
|
||||
except (ValueError, TypeError, OSError):
|
||||
self.uncertain = True
|
||||
self.mark("uncertain")
|
||||
if (self.directory / "inflight").exists():
|
||||
self.uncertain = True
|
||||
self.mark("uncertain")
|
||||
@@ -48,9 +140,71 @@ class Gate:
|
||||
|
||||
def snapshot(self):
|
||||
return {"paused": self.paused, "inflight": self.inflight,
|
||||
"active_tasks": len(self.tasks), "task_ids": sorted(self.tasks),
|
||||
"uncertain": self.uncertain, "last_fetch_peer": self.last_fetch_peer,
|
||||
"last_fetch_at": self.last_fetch_at}
|
||||
|
||||
def save_tasks(self):
|
||||
temporary = self.directory / "tasks.json.tmp"
|
||||
with temporary.open("w", encoding="ascii") as stream:
|
||||
json.dump(self.tasks, stream, sort_keys=True)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, self.directory / "tasks.json")
|
||||
self.sync_directory()
|
||||
|
||||
def fail_closed(self):
|
||||
with self.lock:
|
||||
self.uncertain = self.paused = True
|
||||
self.mark("uncertain")
|
||||
|
||||
def assigned(self, fields):
|
||||
task = single(fields, 1)
|
||||
if task is None:
|
||||
return
|
||||
if not isinstance(task, bytes):
|
||||
raise ValueError("invalid fetched task")
|
||||
task_id = positive_id(single(protobuf_fields(task), 1))
|
||||
with self.lock:
|
||||
self.tasks[task_id] = False
|
||||
# 必须先持久化,再把领取结果交给 runner;仅保存 ID,不保存 secrets。
|
||||
self.save_tasks()
|
||||
|
||||
def reported(self, method, request, response):
|
||||
if method == "UpdateLog":
|
||||
task_id = positive_id(single(request, 1))
|
||||
index = single(request, 2, 0)
|
||||
ack = single(response, 1, 0)
|
||||
no_more = single(request, 4, 0)
|
||||
if (type(index) is not int or type(ack) is not int
|
||||
or index < 0 or ack < 0 or no_more not in (0, 1)):
|
||||
raise ValueError("invalid log acknowledgement")
|
||||
finalized = no_more == 1 and ack == index + len(request.get(3, []))
|
||||
else:
|
||||
task_id, result = task_state(request)
|
||||
response_id, response_result = task_state(response)
|
||||
if response_id != task_id:
|
||||
raise ValueError("mismatched task acknowledgement")
|
||||
# 取消响应可能出现在任务执行途中,必须等 runner 自己报告终态。
|
||||
finalized = result != 0 and response_result != 0
|
||||
output_keys = {single(protobuf_fields(entry), 1, b"")
|
||||
for entry in request.get(2, [])}
|
||||
finalized = finalized and output_keys.issubset(set(response.get(2, [])))
|
||||
with self.lock:
|
||||
if task_id not in self.tasks:
|
||||
# 客户端可能未读到已成功发出的终态响应而重试;v2.0.0 仅在
|
||||
# executor 清理后的 Close 中发送终态,不为幂等重报增加墓碑账本。
|
||||
if method == "UpdateTask" and finalized:
|
||||
return
|
||||
raise ValueError("report for untracked task; idle bootstrap required")
|
||||
if method == "UpdateLog" and finalized:
|
||||
self.tasks[task_id] = True
|
||||
self.save_tasks()
|
||||
elif method == "UpdateTask" and finalized and self.tasks[task_id]:
|
||||
# act_runner Reporter.Close 在 executor 清理之后先封存日志再报终态。
|
||||
del self.tasks[task_id]
|
||||
self.save_tasks()
|
||||
|
||||
def record_fetch(self, peer):
|
||||
with self.lock:
|
||||
self.last_fetch_peer = peer
|
||||
@@ -142,6 +296,7 @@ class Proxy(http.server.BaseHTTPRequestHandler):
|
||||
pass # 不输出 RPC 认证头、请求内容或带认证信息的 URL。
|
||||
|
||||
def reply(self, status, body, headers=()):
|
||||
delivered = False
|
||||
try:
|
||||
self.send_response(status)
|
||||
excluded = HOP_HEADERS | {
|
||||
@@ -155,9 +310,12 @@ class Proxy(http.server.BaseHTTPRequestHandler):
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
self.wfile.flush()
|
||||
delivered = True
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
self.close_connection = True
|
||||
return delivered
|
||||
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlsplit(self.path)
|
||||
@@ -170,7 +328,8 @@ class Proxy(http.server.BaseHTTPRequestHandler):
|
||||
except (ValueError, OSError):
|
||||
self.reply(400, b"invalid request body\n")
|
||||
return
|
||||
is_fetch = path.path.endswith("/FetchTask")
|
||||
method = path.path.removeprefix(RPC_PREFIX) if path.path.startswith(RPC_PREFIX) else ""
|
||||
is_fetch = method == "FetchTask"
|
||||
if is_fetch:
|
||||
# 包括暂停时被拒绝的请求;只记录网络来源与时间以证明 daemon 路由。
|
||||
self.server.gate.record_fetch(self.client_address[0])
|
||||
@@ -209,12 +368,23 @@ class Proxy(http.server.BaseHTTPRequestHandler):
|
||||
raise http.client.IncompleteRead(bytes(result), response.length)
|
||||
completed = True
|
||||
if is_fetch:
|
||||
try:
|
||||
if oversized or response.status != 200:
|
||||
raise ValueError("unknown FetchTask result")
|
||||
self.server.gate.assigned(rpc_fields(bytes(result), response.headers))
|
||||
except (ValueError, TypeError, OSError, EOFError, zlib.error):
|
||||
self.server.gate.fail_closed()
|
||||
self.server.gate.leave(True)
|
||||
is_fetch = False
|
||||
if oversized:
|
||||
self.reply(502, b"upstream response too large\n")
|
||||
else:
|
||||
self.reply(response.status, bytes(result), response.getheaders())
|
||||
# 日志封存先记账再返回,避免 runner 立即发终态时抢先读到旧账本。
|
||||
if method == "UpdateLog" and response.status == 200:
|
||||
self.observe_report(method, body, bytes(result), response.headers)
|
||||
delivered = self.reply(response.status, bytes(result), response.getheaders())
|
||||
if method == "UpdateTask" and response.status == 200 and delivered:
|
||||
self.observe_report(method, body, bytes(result), response.headers)
|
||||
except (OSError, http.client.HTTPException, ValueError):
|
||||
self.reply(502, b"runner upstream unavailable\n")
|
||||
finally:
|
||||
@@ -223,6 +393,13 @@ class Proxy(http.server.BaseHTTPRequestHandler):
|
||||
if is_fetch:
|
||||
self.server.gate.leave(completed)
|
||||
|
||||
def observe_report(self, method, request, response, headers):
|
||||
try:
|
||||
self.server.gate.reported(method, rpc_fields(request, self.headers),
|
||||
rpc_fields(response, headers))
|
||||
except (ValueError, TypeError, OSError, EOFError, zlib.error):
|
||||
self.server.gate.fail_closed()
|
||||
|
||||
|
||||
class Control(socketserver.StreamRequestHandler):
|
||||
def handle(self):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
"""定向回收 Gitea 1.26.4 不会自动过期的本仓库缓存上传块。"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
|
||||
|
||||
JOBS = {
|
||||
"ai-game-creator-shell-rust-lane-1",
|
||||
"ai-game-creator-shell-rust-lane-2",
|
||||
"ai-game-creator-shell-rust-smoke",
|
||||
"ai-game-creator-shell-rust-crates",
|
||||
"backend-tests",
|
||||
"native-shell-tests",
|
||||
}
|
||||
CHUNK = re.compile(r"block-([1-9][0-9]*)-([1-9][0-9]*)-(0|[1-9][0-9]*)-([A-Za-z0-9_-]+={0,2})")
|
||||
BLOCKLIST = re.compile(r"([1-9][0-9]*)-([1-9][0-9]*)-blocklist")
|
||||
MARKER = re.compile(r"genarrative-rust-cache-v1:([a-z0-9-]+):(0|[1-9][0-9]*):([0-9]{8})")
|
||||
|
||||
|
||||
def decode_owner(encoded):
|
||||
"""Gitea 文件名编码为 URL-base64(生产者上传的标准 base64 blockid)。"""
|
||||
try:
|
||||
block_id = base64.b64decode(encoded, altchars=b"-_", validate=True)
|
||||
if base64.urlsafe_b64encode(block_id).decode() != encoded:
|
||||
return None
|
||||
marker = base64.b64decode(block_id, validate=True)
|
||||
if base64.b64encode(marker) != block_id:
|
||||
return None
|
||||
match = MARKER.fullmatch(marker.decode("ascii"))
|
||||
if match and match[1] in JOBS:
|
||||
return match[1], int(match[2])
|
||||
except (binascii.Error, UnicodeError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _unchanged_old_regular(path, previous, cutoff):
|
||||
try:
|
||||
current = path.lstat()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
return (
|
||||
stat.S_ISREG(current.st_mode)
|
||||
and current.st_mtime < cutoff
|
||||
and (current.st_ino, current.st_size, current.st_mtime_ns)
|
||||
== (previous.st_ino, previous.st_size, previous.st_mtime_ns)
|
||||
)
|
||||
|
||||
|
||||
def cleanup_upload_chunks(storage_root: Path, eligible_runs: set[int], cutoff_timestamp: float) -> int:
|
||||
"""调用方先用仓库 API 确认这些 master run 已结束超过保留期限。"""
|
||||
storage_root = Path(storage_root)
|
||||
if not storage_root.is_absolute() or storage_root.resolve(strict=True) != storage_root:
|
||||
raise ValueError("artifact storage root must be an existing absolute real directory")
|
||||
if not stat.S_ISDIR(storage_root.lstat().st_mode):
|
||||
raise ValueError("artifact storage root must be a directory")
|
||||
if not math.isfinite(cutoff_timestamp) or cutoff_timestamp <= 0:
|
||||
raise ValueError("invalid artifact cleanup cutoff")
|
||||
if any(type(run_id) is not int or run_id <= 0 for run_id in eligible_runs):
|
||||
raise ValueError("invalid eligible run ID")
|
||||
temporary = storage_root / "tmp-upload"
|
||||
if not temporary.exists() and not temporary.is_symlink():
|
||||
return 0
|
||||
if temporary.is_symlink() or not temporary.is_dir():
|
||||
raise ValueError("artifact temporary path must be a real directory")
|
||||
|
||||
deleted = 0
|
||||
for run_id in sorted(eligible_runs):
|
||||
directory = temporary / f"run-{run_id}-v4"
|
||||
if directory.is_symlink() or not directory.is_dir():
|
||||
continue
|
||||
groups = {}
|
||||
unknown = False
|
||||
for path in directory.iterdir():
|
||||
chunk = CHUNK.fullmatch(path.name)
|
||||
blocklist = BLOCKLIST.fullmatch(path.name)
|
||||
match = chunk or blocklist
|
||||
if match and int(match[1]) == run_id:
|
||||
artifact_id = int(match[2])
|
||||
owner = decode_owner(chunk[4]) if chunk else None
|
||||
owned = owner is not None if chunk else True
|
||||
else:
|
||||
# 若仍能识别 artifact ID,仅保护该组;无法识别时保护整个 run。
|
||||
prefix = re.match(rf"(?:block-)?{run_id}-([1-9][0-9]*)-", path.name)
|
||||
if not prefix:
|
||||
unknown = True
|
||||
break
|
||||
artifact_id, owner, owned = int(prefix[1]), None, False
|
||||
group = groups.setdefault(artifact_id, {"files": [], "owners": set(), "safe": True})
|
||||
try:
|
||||
info = path.lstat()
|
||||
except FileNotFoundError:
|
||||
group["safe"] = False
|
||||
continue
|
||||
group["safe"] &= owned and stat.S_ISREG(info.st_mode) and info.st_mtime < cutoff_timestamp
|
||||
if owner:
|
||||
group["owners"].add(owner)
|
||||
group["files"].append((path, info))
|
||||
if unknown:
|
||||
continue
|
||||
for group in groups.values():
|
||||
if not group["safe"] or len(group["owners"]) != 1:
|
||||
continue
|
||||
files = group["files"]
|
||||
if not all(_unchanged_old_regular(path, info, cutoff_timestamp) for path, info in files):
|
||||
continue
|
||||
# 保留目录,不使用递归删除;没有所属块证明的孤立 blocklist 也不会删除。
|
||||
for path, _ in files:
|
||||
path.unlink()
|
||||
deleted += 1
|
||||
return deleted
|
||||
@@ -9,13 +9,18 @@ import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from gitea_cache_snapshot import ArtifactIdentity, ArtifactInput, merge_snapshots
|
||||
from gitea_cache_upload_cleanup import cleanup_upload_chunks
|
||||
|
||||
|
||||
IMAGE = re.compile(r"sha256:[0-9a-f]{64}\Z")
|
||||
SHA = re.compile(r"[0-9a-f]{40}\Z")
|
||||
@@ -29,6 +34,17 @@ JOBS = {
|
||||
"Repository checks", "AI game creator shell web tests",
|
||||
}
|
||||
RUST_JOBS = {name for name in JOBS if "Rust" in name or name in {"Backend tests", "Native shell tests"}}
|
||||
RUST_JOB_IDS = {
|
||||
"AI game creator shell Rust lane 1/2": "ai-game-creator-shell-rust-lane-1",
|
||||
"AI game creator shell Rust lane 2/2": "ai-game-creator-shell-rust-lane-2",
|
||||
"AI game creator shell Rust smoke": "ai-game-creator-shell-rust-smoke",
|
||||
"AI game creator shell Rust crates": "ai-game-creator-shell-rust-crates",
|
||||
"Backend tests": "backend-tests",
|
||||
"Native shell tests": "native-shell-tests",
|
||||
}
|
||||
ARTIFACT_PREFIX = "rust-cache-v1-"
|
||||
MAX_DOWNLOAD = 4 * 1024 ** 3 + 129 * 1024 ** 2
|
||||
EXPORT_STEP = "Publish master Rust cache artifact"
|
||||
|
||||
|
||||
def now():
|
||||
@@ -127,14 +143,14 @@ class Api:
|
||||
raise ValueError("api_url must use HTTPS")
|
||||
self.token_file = Path(token_file)
|
||||
|
||||
def request(self, path, body=None, raw=False):
|
||||
def request(self, path, body=None, raw=False, method=None):
|
||||
token = self.token_file.read_text().strip()
|
||||
if not token or "\n" in token:
|
||||
raise ValueError("invalid token file")
|
||||
request = urllib.request.Request(
|
||||
self.url + "/" + path.lstrip("/"),
|
||||
data=None if body is None else json.dumps(body).encode(),
|
||||
method="GET" if body is None else "PATCH",
|
||||
method=method or ("GET" if body is None else "PATCH"),
|
||||
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
@@ -143,8 +159,41 @@ class Api:
|
||||
with opener.open(request, timeout=30) as response:
|
||||
content = response.read().decode()
|
||||
except urllib.error.HTTPError as error:
|
||||
error.close()
|
||||
if method == "DELETE" and error.code == 404:
|
||||
return None
|
||||
raise RuntimeError(f"Gitea API HTTP {error.code}") from None
|
||||
return content if raw else json.loads(content)
|
||||
return content if raw else (json.loads(content) if content else None)
|
||||
|
||||
def download(self, path, destination):
|
||||
"""REST V4 archive redirects to a signed URL; never forward the API token."""
|
||||
token = self.token_file.read_text().strip()
|
||||
url = self.url + "/" + path.lstrip("/")
|
||||
opener = urllib.request.build_opener(NoRedirect())
|
||||
request = urllib.request.Request(url, headers={"Authorization": "token " + token})
|
||||
try:
|
||||
response = opener.open(request, timeout=60)
|
||||
except urllib.error.HTTPError as error:
|
||||
error.close()
|
||||
if error.code not in (301, 302, 303, 307, 308):
|
||||
raise RuntimeError(f"artifact download HTTP {error.code}") from None
|
||||
target = urllib.parse.urljoin(url, error.headers.get("Location", ""))
|
||||
parsed, origin = urllib.parse.urlsplit(target), urllib.parse.urlsplit(self.url)
|
||||
if (parsed.scheme != "https" or parsed.netloc != origin.netloc
|
||||
or parsed.username or parsed.password or target == url):
|
||||
raise RuntimeError("artifact redirect must stay on configured HTTPS Gitea origin") from None
|
||||
response = opener.open(target, timeout=60)
|
||||
try:
|
||||
with response, destination.open("wb") as out:
|
||||
total = 0
|
||||
while chunk := response.read(1024 * 1024):
|
||||
total += len(chunk)
|
||||
if total > MAX_DOWNLOAD:
|
||||
raise RuntimeError("artifact exceeds per-job size limit")
|
||||
out.write(chunk)
|
||||
except Exception:
|
||||
destination.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
def pages(self, path, key):
|
||||
separator = "&" if "?" in path else "?"
|
||||
@@ -175,9 +224,6 @@ class Maintenance:
|
||||
self.runner = config.get("runner_container", "gitea-runner")
|
||||
self.api = Api(config["api_url"], config["token_file"])
|
||||
self.repo_api = "repos/" + config["repository"]
|
||||
self.runner_api = config["runner_api_path"].strip("/")
|
||||
if not re.fullmatch(r"admin/actions/runners/\d+", self.runner_api):
|
||||
raise ValueError("this service requires the global runner admin endpoint")
|
||||
self.state_path = self.root / "state.json"
|
||||
self.state = json.loads(self.state_path.read_text()) if self.state_path.exists() else {
|
||||
"versions": [], "current": None, "rollback": None, "candidate": None,
|
||||
@@ -250,40 +296,149 @@ class Maintenance:
|
||||
command("bash", str(self.repo / "scripts" / script), *args, cwd=self.repo,
|
||||
env=env, output=out, timeout=7200)
|
||||
|
||||
def build(self, sha, inputs):
|
||||
def master_run(self, run):
|
||||
return (run.get("path") == "project-ci.yml@refs/heads/master"
|
||||
and run.get("event") == "push" and run.get("head_branch") == "master"
|
||||
and SHA.fullmatch(run.get("head_sha", "")) is not None)
|
||||
|
||||
def source_run(self):
|
||||
"""Only complete exports from the latest eligible master run; never mix runs."""
|
||||
current = self.version(self.state["current"])
|
||||
current_inputs = (cache_inputs(command("git", "ls-tree", "-rz", current["source"], cwd=self.repo))
|
||||
if current.get("source") else None)
|
||||
for run in self.api.pages(self.repo_api + "/actions/runs?branch=master&event=push", "workflow_runs"):
|
||||
if (not self.master_run(run) or run.get("status") != "completed"
|
||||
or run.get("conclusion") not in {"success", "failure"}
|
||||
or run["id"] <= current.get("run_id", 0)):
|
||||
continue
|
||||
sha = run["head_sha"]
|
||||
if sha == current.get("source"):
|
||||
continue
|
||||
ancestry = subprocess.run(["git", "merge-base", "--is-ancestor", sha, "FETCH_HEAD"],
|
||||
cwd=self.repo, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if ancestry.returncode:
|
||||
continue
|
||||
if current.get("source"):
|
||||
ancestry = subprocess.run(["git", "merge-base", "--is-ancestor", current["source"], sha],
|
||||
cwd=self.repo, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if ancestry.returncode:
|
||||
continue
|
||||
inputs = cache_inputs(command("git", "ls-tree", "-rz", sha, cwd=self.repo))
|
||||
if inputs == current_inputs:
|
||||
continue
|
||||
jobs = list(self.api.pages(self.repo_api + f'/actions/runs/{run["id"]}/jobs', "jobs"))
|
||||
rust_jobs = [job for job in jobs if job["name"] in RUST_JOB_IDS]
|
||||
if len(rust_jobs) != 6 or {job["name"] for job in rust_jobs} != RUST_JOBS:
|
||||
continue
|
||||
if any(job.get("status") != "completed" or job.get("conclusion") not in {"success", "failure"}
|
||||
or job.get("head_sha") != sha
|
||||
or not any(step.get("name") == EXPORT_STEP and step.get("conclusion") == "success"
|
||||
for step in job.get("steps", [])) for job in rust_jobs):
|
||||
continue
|
||||
artifacts = list(self.api.pages(self.repo_api + f'/actions/runs/{run["id"]}/artifacts', "artifacts"))
|
||||
selected = []
|
||||
images = set()
|
||||
for job in rust_jobs:
|
||||
name = f'{ARTIFACT_PREFIX}{RUST_JOB_IDS[job["name"]]}-attempt-{job["run_attempt"]}'
|
||||
matches = [item for item in artifacts if item["name"] == name and not item.get("expired")
|
||||
and item.get("workflow_run", {}).get("id") == run["id"]]
|
||||
if len(matches) != 1:
|
||||
break
|
||||
content = self.api.request(self.repo_api + f'/actions/jobs/{job["id"]}/logs', raw=True)
|
||||
if not re.search(re.escape(f"[rust-cache] artifact={name}")
|
||||
+ r" objects=\d+ bytes=\d+ complete=true", content):
|
||||
break
|
||||
used = set(re.findall(r"(?m)^\S+ image: (sha256:[0-9a-f]{64})\s*$", content))
|
||||
if len(used) != 1:
|
||||
break
|
||||
images.update(used)
|
||||
selected.append({"id": matches[0]["id"], "name": name,
|
||||
"job": RUST_JOB_IDS[job["name"]], "attempt": job["run_attempt"]})
|
||||
if len(selected) != 6 or len(images) != 1:
|
||||
continue
|
||||
source_image = images.pop()
|
||||
if source_image not in {row["image"] for row in self.state["versions"]}:
|
||||
continue
|
||||
return {"run_id": run["id"], "source": sha, "inputs": inputs, "source_image": source_image,
|
||||
"exports": selected}
|
||||
return None
|
||||
|
||||
def build(self, source):
|
||||
"""Assemble existing CI objects; no cargo warm-up or test execution."""
|
||||
sha = source["source"]
|
||||
command("git", "checkout", "--detach", "--force", sha, cwd=self.repo)
|
||||
command("git", "clean", "-ffdx", cwd=self.repo)
|
||||
artifact = self.root / "artifacts" / sha
|
||||
artifact.mkdir(parents=True, exist_ok=True)
|
||||
build_log = artifact / "build.log"
|
||||
tag = "genarrative/gitea-project-ci:rust-cache-auto-" + sha
|
||||
base_tag = "genarrative/gitea-project-ci:base-auto-" + sha
|
||||
self.state.setdefault("attempts", []).append({
|
||||
"source": sha, "inputs": inputs, "tag": tag,
|
||||
"base_tag": base_tag, "artifact": str(artifact),
|
||||
})
|
||||
attempt = {**source, "tag": tag, "base_tag": base_tag, "artifact": str(artifact)}
|
||||
self.state.setdefault("attempts", []).append(attempt)
|
||||
self.save()
|
||||
env = {**os.environ, "GENARRATIVE_GITEA_RUNNER_CONTAINER": self.runner}
|
||||
env.pop("CI", None)
|
||||
current = self.version(self.state["current"])
|
||||
base = current["base"]
|
||||
labels = self.image_info(source["source_image"])["Config"].get("Labels") or {}
|
||||
inherited_source = labels.get("world.genarrative.ci.rust-cache-source")
|
||||
base = labels.get("world.genarrative.ci.rust-cache-base")
|
||||
if not IMAGE.fullmatch(base or "") or not SHA.fullmatch(inherited_source or ""):
|
||||
raise RuntimeError("source image must contain a trusted cache snapshot")
|
||||
revision = command("bash", "scripts/gitea-ci-job-image.sh", "revision", cwd=self.repo).strip()
|
||||
labels = self.image_info(base)["Config"].get("Labels") or {}
|
||||
if labels.get("com.genarrative.ci.definition-sha256") != revision:
|
||||
base_labels = self.image_info(base)["Config"].get("Labels") or {}
|
||||
if base_labels.get("com.genarrative.ci.definition-sha256") != revision:
|
||||
env["GENARRATIVE_GITEA_CI_IMAGE_TAG"] = base_tag
|
||||
self.build_command(build_log, "gitea-ci-job-image.sh", "build", env=env)
|
||||
base = self.image_info(base_tag)["Id"]
|
||||
self.state.setdefault("bases", {})[base] = base_tag
|
||||
self.save()
|
||||
self.build_command(build_log, "build-gitea-rust-cache.sh", base, tag, sha, env=env)
|
||||
# 与旧缓存镜像分离;绝不把 Docker 可写层、源码或 target commit 成镜像。
|
||||
self.docker("run", "--rm", "--network", "none", "--read-only", "--cap-drop=ALL",
|
||||
"--entrypoint", "bash", base, "-c", "test ! -e /opt/genarrative-ci/rust-cache")
|
||||
with tempfile.TemporaryDirectory(prefix="assemble-", dir=artifact) as temporary:
|
||||
work = Path(temporary)
|
||||
inherited = work / "inherited"
|
||||
inherited.mkdir()
|
||||
container = self.docker("create", source["source_image"]).strip()
|
||||
try:
|
||||
self.docker("cp", container + ":/opt/genarrative-ci/rust-cache/.", str(inherited), timeout=600)
|
||||
finally:
|
||||
self.docker("rm", "--volumes", container)
|
||||
inputs = []
|
||||
for export in source["exports"]:
|
||||
archive = work / (str(export["id"]) + ".zip")
|
||||
self.api.download(self.repo_api + f'/actions/artifacts/{export["id"]}/zip', archive)
|
||||
inputs.append(ArtifactInput(archive, ArtifactIdentity(
|
||||
self.config["repository"], source["run_id"], export["attempt"], export["job"], sha)))
|
||||
snapshot = work / "snapshot"
|
||||
merged = merge_snapshots(inputs, snapshot, base_objects=inherited / "objects",
|
||||
expected_inherited_source_sha=inherited_source)
|
||||
if merged.sccache_version != "sccache 0.18.0":
|
||||
raise RuntimeError("unsupported sccache version")
|
||||
if merged.base_image is not None and merged.base_image != labels["world.genarrative.ci.rust-cache-base"]:
|
||||
raise RuntimeError("artifact base differs from its actual source image")
|
||||
rustc = self.docker("run", "--rm", "--network", "none", "--read-only", base, "rustc", "-vV")
|
||||
if rustc.strip() != merged.rustc.strip():
|
||||
raise RuntimeError("artifact toolchain differs from target base image")
|
||||
if merged.workspace != "/workspace/" + self.config["repository"]:
|
||||
raise RuntimeError("artifact workspace differs from CI checkout")
|
||||
shutil.copyfile(inherited / "sccache", snapshot / "sccache")
|
||||
(snapshot / "sccache").chmod(0o755)
|
||||
(snapshot / "base-image.txt").write_text(base + "\n")
|
||||
(work / "Dockerfile").write_text(
|
||||
f"FROM {base}\nCOPY snapshot/ /opt/genarrative-ci/rust-cache/\n"
|
||||
f'LABEL world.genarrative.ci.rust-cache-source="{sha}"\n'
|
||||
f'LABEL world.genarrative.ci.rust-cache-base="{base}"\n')
|
||||
(work / ".dockerignore").write_text("**\n!Dockerfile\n!snapshot/\n!snapshot/**\n")
|
||||
with build_log.open("a") as out:
|
||||
self.docker("build", "--pull=false", "--tag", tag, str(work), output=out, timeout=1800)
|
||||
self.build_command(build_log, "gitea-ci-job-image.sh", "verify", tag, env=env)
|
||||
image = self.image_info(tag)["Id"]
|
||||
record = {"image": image, "tag": tag, "source": sha, "inputs": inputs,
|
||||
"base": base, "owned": True, "verified_run": None,
|
||||
"artifact": str(artifact)}
|
||||
# 先登记再导出;进程中断后下次可以复用候选,不留下无归属成功镜像。
|
||||
self.state["versions"].append(record)
|
||||
self.state["versions"].append({**attempt, "image": image, "base": base,
|
||||
"owned": True, "verified_run": None})
|
||||
self.state["candidate"] = image
|
||||
self.state["attempts"] = [row for row in self.state["attempts"] if row["source"] != sha]
|
||||
self.save()
|
||||
log(f"candidate built source={sha} image={image}")
|
||||
log(f"candidate assembled run={source['run_id']} source={sha} image={image}")
|
||||
|
||||
def stage_candidate(self):
|
||||
candidate = self.version(self.state["candidate"])
|
||||
@@ -309,10 +464,14 @@ class Maintenance:
|
||||
self.save()
|
||||
|
||||
def idle(self):
|
||||
runner = self.api.request(self.runner_api)
|
||||
active = self.api.request("admin/actions/runs?status=in_progress&limit=1")
|
||||
return (runner.get("busy") is False and active["total_count"] == 0
|
||||
and not self.docker("ps", "-q", inner=True).strip())
|
||||
gate = self.gate("status")
|
||||
if type(gate.get("active_tasks")) is not int or gate["active_tasks"] < 0:
|
||||
raise RuntimeError("gate must support durable task tracking before automatic switching")
|
||||
# 已领取但尚未建容器、正在收尾上报的任务都由入口跟踪,不查管理员 API。
|
||||
return (gate.get("uncertain") is False and gate.get("active_tasks") == 0
|
||||
and not self.docker("ps", "-q", "--filter", "status=running",
|
||||
"--filter", "status=created", "--filter", "status=restarting",
|
||||
"--filter", "status=paused", inner=True).strip())
|
||||
|
||||
def verify_current(self):
|
||||
current = self.version(self.state["current"])
|
||||
@@ -402,12 +561,62 @@ class Maintenance:
|
||||
del self.state["bases"][image]
|
||||
self.save()
|
||||
|
||||
def cleanup_exports(self):
|
||||
"""Only our named artifacts; keep logs/runs and every unrelated artifact."""
|
||||
protected_runs = {row["run_id"] for row in self.state.get("attempts", []) if row.get("run_id")}
|
||||
for row in self.state["versions"]:
|
||||
if row.get("run_id") and not row.get("staged"):
|
||||
protected_runs.add(row["run_id"])
|
||||
if not row.get("staged"):
|
||||
continue
|
||||
for item in row.get("exports", []):
|
||||
if item.get("deleted"):
|
||||
continue
|
||||
self.api.request(self.repo_api + f'/actions/artifacts/{item["id"]}', method="DELETE")
|
||||
item["deleted"] = True
|
||||
self.save()
|
||||
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=7)
|
||||
# 先收集再删除,避免按页删除让下一页位置前移、漏掉旧产物。
|
||||
artifacts = list(self.api.pages(self.repo_api + "/actions/artifacts", "artifacts"))
|
||||
pattern = re.compile(re.escape(ARTIFACT_PREFIX) + "(" + "|".join(RUST_JOB_IDS.values())
|
||||
+ r")-attempt-\d+\Z")
|
||||
runs = {}
|
||||
for item in artifacts:
|
||||
run_id = (item.get("workflow_run") or {}).get("id")
|
||||
if (not pattern.fullmatch(item["name"]) or not run_id or run_id in protected_runs
|
||||
or timestamp(item["created_at"]) >= cutoff):
|
||||
continue
|
||||
# Artifact.workflow_run 在 Gitea 1.26.4 中只有 id/repository_id/head_sha。
|
||||
if run_id not in runs:
|
||||
runs[run_id] = self.api.request(self.repo_api + f"/actions/runs/{run_id}")
|
||||
run = runs[run_id]
|
||||
if not self.master_run(run) or run.get("status") != "completed":
|
||||
continue
|
||||
self.api.request(self.repo_api + f'/actions/artifacts/{item["id"]}', method="DELETE")
|
||||
|
||||
def cleanup_pending_uploads(self):
|
||||
# Gitea 1.26.4 的过期/DELETE API 不会清理未 finalized 的 V4 分块。
|
||||
# 只处理本上传器命名的块,且 run 和文件本身均已过保留期限。
|
||||
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=7)
|
||||
eligible = set()
|
||||
protected = {row["run_id"] for row in self.state.get("attempts", []) if row.get("run_id")}
|
||||
for run in self.api.pages(self.repo_api + "/actions/runs?branch=master&event=push", "workflow_runs"):
|
||||
if (self.master_run(run) and run.get("status") == "completed"
|
||||
and run["id"] not in protected and run.get("completed_at")
|
||||
and timestamp(run["completed_at"]) < cutoff):
|
||||
eligible.add(run["id"])
|
||||
removed = cleanup_upload_chunks(Path(self.config["artifact_storage_dir"]), eligible, cutoff.timestamp())
|
||||
if removed:
|
||||
log(f"removed {removed} expired owned upload fragments")
|
||||
|
||||
def tick(self, retry=False):
|
||||
if not self.recover_switch():
|
||||
return
|
||||
self.adopt_current()
|
||||
sha, inputs = self.fetch_source()
|
||||
self.fetch_source()
|
||||
self.recover_builds()
|
||||
self.cleanup_exports()
|
||||
self.cleanup_pending_uploads()
|
||||
if not self.verify_current():
|
||||
return
|
||||
self.cleanup()
|
||||
@@ -415,35 +624,42 @@ class Maintenance:
|
||||
self.stage_candidate()
|
||||
self.activate()
|
||||
return
|
||||
current = self.version(self.state["current"])
|
||||
if "inputs" not in current and current.get("source"):
|
||||
current["inputs"] = cache_inputs(command("git", "ls-tree", "-rz", current["source"], cwd=self.repo))
|
||||
self.save()
|
||||
if current.get("inputs") == inputs:
|
||||
log(f"unchanged compilation inputs at master={sha}")
|
||||
source = self.source_run()
|
||||
if source is None:
|
||||
log("waiting for a complete set of master CI cache exports")
|
||||
return
|
||||
if not retry and self.state.get("failed_source") == sha:
|
||||
log(f"previous build failed at {sha}; waiting for new master or --retry")
|
||||
if not retry and self.state.get("failed_run") == source["run_id"]:
|
||||
log(f'previous assembly failed at run={source["run_id"]}; waiting for new run or --retry')
|
||||
return
|
||||
# 新的 CI 波峰不额外争抢预热资源,等下一次维护周期。
|
||||
# 下载、合并和镜像装载也消耗宿主 IO;繁忙时留给 CI,下轮再收集。
|
||||
if not self.idle():
|
||||
log("CI active; defer refresh")
|
||||
return
|
||||
try:
|
||||
self.build(sha, inputs)
|
||||
self.build(source)
|
||||
except Exception:
|
||||
self.state["failed_source"] = sha
|
||||
self.state["failed_run"] = source["run_id"]
|
||||
self.save()
|
||||
raise
|
||||
self.state.pop("failed_source", None)
|
||||
self.state.pop("failed_run", None)
|
||||
self.save()
|
||||
self.stage_candidate()
|
||||
self.cleanup_exports()
|
||||
self.activate()
|
||||
|
||||
def recover_builds(self):
|
||||
# 宕机可能发生在 docker build 完成之后、登记 Image ID 之前。
|
||||
# 只接管预先登记的确定性 tag;恢复的候选仍须经过 stage 的 verify/load。
|
||||
for attempt in list(self.state.get("attempts", [])):
|
||||
artifact = self.root / "artifacts" / attempt["source"]
|
||||
if (not SHA.fullmatch(attempt["source"]) or Path(attempt["artifact"]).resolve() != artifact
|
||||
or artifact.is_symlink() or artifact.parent.is_symlink()):
|
||||
raise RuntimeError("interrupted assembly directory is outside managed artifacts")
|
||||
# 持有维护锁,只有此前中断的组装可能遗留这些私有工作目录。
|
||||
for directory in artifact.glob("assemble-*"):
|
||||
if directory.is_symlink() or not directory.is_dir():
|
||||
raise RuntimeError("unexpected interrupted assembly entry")
|
||||
shutil.rmtree(directory)
|
||||
for kind in ("base_tag", "tag"):
|
||||
ids = set(self.docker("image", "ls", "--no-trunc", "--quiet", attempt[kind]).split())
|
||||
if not ids:
|
||||
@@ -508,16 +724,14 @@ class Maintenance:
|
||||
gate = self.gate("status")
|
||||
# .runner 会因 label 更新而回写;mtime 不能证明内存中的地址。
|
||||
# 要求入口实际见到本次容器启动后、来自它的 FetchTask。
|
||||
if gate.get("last_fetch_peer") not in addresses or gate.get("last_fetch_at", 0) < started:
|
||||
if gate.get("last_fetch_peer") not in addresses or (gate.get("last_fetch_at") or 0) < started:
|
||||
raise RuntimeError("gate has not observed FetchTask from this runner startup")
|
||||
if type(gate.get("active_tasks")) is not int or gate["active_tasks"] < 0:
|
||||
raise RuntimeError("gate must support durable task tracking before automatic switching")
|
||||
|
||||
def activate(self):
|
||||
self.check_gate_route()
|
||||
candidate = self.version(self.state["candidate"])
|
||||
runner = self.api.request(self.runner_api)
|
||||
if runner.get("disabled") is not False:
|
||||
log("runner disabled by operator; defer switch")
|
||||
return False
|
||||
config = self.read_config()
|
||||
gate = self.gate("status")
|
||||
if gate.get("paused") is not False and not self.state.get("pause_owned"):
|
||||
@@ -608,7 +822,7 @@ def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", required=True)
|
||||
parser.add_argument("--apply", action="store_true", help="执行维护;默认只检查连接和配置")
|
||||
parser.add_argument("--retry", action="store_true", help="重试同一 master 上失败的构建")
|
||||
parser.add_argument("--retry", action="store_true", help="重试同一 master run 上失败的缓存组装")
|
||||
parser.add_argument("--resume", action="store_true", help="仅恢复本维护器暂停的领取;用于 ExecStopPost")
|
||||
args = parser.parse_args()
|
||||
if os.environ.get("CI") == "true":
|
||||
@@ -617,14 +831,15 @@ def main():
|
||||
config = json.loads(Path(args.config).read_text())
|
||||
maintenance = Maintenance(config)
|
||||
if not args.apply and not args.resume:
|
||||
runner = maintenance.api.request(maintenance.runner_api)
|
||||
maintenance.api.request(maintenance.repo_api + "/actions/artifacts?limit=1")
|
||||
image = configured_image(maintenance.read_config())
|
||||
head = command("git", "ls-remote", config["clone_url"], "refs/heads/master").split()[0]
|
||||
maintenance.check_gate_route()
|
||||
cleanup_upload_chunks(Path(config["artifact_storage_dir"]), set(), time.time())
|
||||
gate = maintenance.gate("status")
|
||||
if gate.get("uncertain"):
|
||||
raise RuntimeError("runner gate needs manual inspection")
|
||||
log(f'check master={head} image={image} runner_disabled={runner["disabled"]} busy={runner["busy"]}')
|
||||
log(f'check master={head} image={image} active_tasks={gate.get("active_tasks")}')
|
||||
return
|
||||
import fcntl # Linux 宿主;纯逻辑测试仍可在 Windows 上导入。
|
||||
maintenance.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -142,6 +142,38 @@ function backendStepIndex(stepName: string) {
|
||||
}
|
||||
|
||||
describe('project CI workflow', () => {
|
||||
it('publishes cache deltas only after Rust reporting on non-cancelled master pushes', () => {
|
||||
const producers = [
|
||||
'ai-game-creator-shell-rust-lane-1',
|
||||
'ai-game-creator-shell-rust-lane-2',
|
||||
'ai-game-creator-shell-rust-smoke',
|
||||
'ai-game-creator-shell-rust-crates',
|
||||
'backend-tests',
|
||||
'native-shell-tests',
|
||||
];
|
||||
for (const job of jobNames) {
|
||||
if (!producers.includes(job)) {
|
||||
expect(jobSection(job)).not.toContain('export-gitea-rust-cache.py');
|
||||
continue;
|
||||
}
|
||||
const publish = stepSection(job, 'Publish master Rust cache artifact');
|
||||
expect(publish).toContain(
|
||||
"if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}",
|
||||
);
|
||||
expect(publish).toContain('GENARRATIVE_GITEA_TOKEN: ${{ github.token }}');
|
||||
expect(publish).toContain('continue-on-error: true');
|
||||
expect(publish).toContain(
|
||||
'run: python3 scripts/export-gitea-rust-cache.py',
|
||||
);
|
||||
const section = jobSection(job);
|
||||
expect(
|
||||
section.indexOf('Publish master Rust cache artifact'),
|
||||
).toBeGreaterThan(
|
||||
section.indexOf('run: bash scripts/ci-rust-cache.sh report'),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses isolated compilation caching for every Rust test job', () => {
|
||||
const firstRustSteps = {
|
||||
'ai-game-creator-shell-rust-lane-1':
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""master 产物的增量归档与 Gitea v4 上传协议回归。"""
|
||||
|
||||
import hashlib
|
||||
import base64
|
||||
from contextlib import redirect_stdout
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import urllib.parse
|
||||
import zipfile
|
||||
|
||||
from gitea_cache_upload_cleanup import decode_owner
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location("cache_export", Path(__file__).with_name("export-gitea-rust-cache.py"))
|
||||
exporter = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(exporter)
|
||||
|
||||
|
||||
class ExportTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
(self.root / "objects").mkdir()
|
||||
|
||||
def object(self, key, value, mtime):
|
||||
path = self.root / "objects" / key[0] / key[1] / key
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(value)
|
||||
os.utime(path, ns=(mtime * 10**9, mtime * 10**9))
|
||||
return path
|
||||
|
||||
def unpack(self, baseline, limit=1024):
|
||||
destination = self.root / "snapshot.zip"
|
||||
result = exporter.pack_snapshot(self.root, destination, {"job": "fixture"}, baseline, limit)
|
||||
with zipfile.ZipFile(destination) as bundle:
|
||||
self.assertEqual(bundle.namelist(), ["snapshot.tar"])
|
||||
with tarfile.open(fileobj=io.BytesIO(bundle.read("snapshot.tar"))) as archive:
|
||||
files = {member.name: archive.extractfile(member).read() for member in archive}
|
||||
return result, json.loads(files.pop("manifest.json")), files
|
||||
|
||||
def test_delta_omits_hit_bytes_preserves_recency_and_exports_new_keys(self):
|
||||
inherited = self.object("a" * 64, b"old", 10)
|
||||
baseline_path = self.root / "baseline.json"
|
||||
exporter.save_baseline(self.root, baseline_path)
|
||||
baseline = json.loads(baseline_path.read_text())
|
||||
# sccache 对命中对象更新 mtime,不能因此重复上传已有对象。
|
||||
os.utime(inherited, ns=(20 * 10**9, 20 * 10**9))
|
||||
self.object("b" * 64, b"new", 30)
|
||||
result, manifest, files = self.unpack(baseline)
|
||||
key = "objects/b/b/" + "b" * 64
|
||||
self.assertEqual(result, (1, 3))
|
||||
self.assertEqual(files, {key: b"new"})
|
||||
self.assertEqual(manifest["mode"], "delta")
|
||||
self.assertEqual(manifest["touched"], [{"path": "objects/a/a/" + "a" * 64, "mtime_ns": 20 * 10**9}])
|
||||
self.assertEqual(manifest["objects"][0]["sha256"], hashlib.sha256(b"new").hexdigest())
|
||||
|
||||
def test_empty_delta_is_valid_and_changed_size_is_exported(self):
|
||||
path = self.object("a" * 64, b"old", 10)
|
||||
baseline = {"a/a/" + "a" * 64: {"size": 3, "mtime_ns": 10 * 10**9}}
|
||||
self.assertEqual(self.unpack(baseline)[0], (0, 0))
|
||||
path.write_bytes(b"replacement")
|
||||
self.assertEqual(self.unpack(baseline)[0], (1, 11))
|
||||
|
||||
def test_capacity_prefers_newest_objects_and_excludes_unrelated_files(self):
|
||||
self.object("a" * 64, b"old", 10)
|
||||
self.object("b" * 64, b"new", 20)
|
||||
(self.root / "objects" / "credentials").write_bytes(b"secret")
|
||||
(self.root / "sccache").write_bytes(b"binary")
|
||||
result, _, files = self.unpack({}, limit=3)
|
||||
self.assertEqual(result, (1, 3))
|
||||
self.assertEqual(list(files.values()), [b"new"])
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "symlink fixture requires POSIX")
|
||||
def test_symlink_objects_and_subdirectories_are_not_exported(self):
|
||||
outside = self.root / "secret"
|
||||
outside.write_bytes(b"secret")
|
||||
linked = self.object("a" * 64, b"old", 10)
|
||||
linked.unlink()
|
||||
linked.symlink_to(outside)
|
||||
(self.root / "objects" / "b").symlink_to(self.root, target_is_directory=True)
|
||||
self.assertEqual(self.unpack({})[0], (0, 0))
|
||||
|
||||
def test_non_master_and_unclean_shutdown_do_not_scan_or_upload(self):
|
||||
with patch.object(exporter, "scan_objects", side_effect=AssertionError("must not scan")), \
|
||||
patch.object(exporter, "upload_snapshot", side_effect=AssertionError("must not upload")):
|
||||
exporter.export({"GITHUB_EVENT_NAME": "pull_request", "GITHUB_REF": "refs/heads/master"})
|
||||
exporter.export({"GITHUB_EVENT_NAME": "push", "GITHUB_REF": "refs/heads/feature"})
|
||||
exporter.export({"GITHUB_EVENT_NAME": "push", "GITHUB_REF": "refs/heads/master"})
|
||||
|
||||
def test_internal_repository_route_bypasses_rpc_only_gateway(self):
|
||||
env = {
|
||||
"GITHUB_REPOSITORY": "owner/repo",
|
||||
"GITHUB_SERVER_URL": "http://fetch-gate:8080",
|
||||
"GENARRATIVE_GITEA_REPOSITORY_URL": "http://gitea:3000/sub/owner/repo.git",
|
||||
}
|
||||
self.assertEqual(exporter.artifact_base_url(env), "http://gitea:3000/sub")
|
||||
env["GENARRATIVE_GITEA_REPOSITORY_URL"] = "http://token@gitea:3000/owner/repo.git"
|
||||
with self.assertRaises(ValueError):
|
||||
exporter.artifact_base_url(env)
|
||||
|
||||
def test_native_v4_chunks_finalize_with_verified_size_and_hash(self):
|
||||
calls = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||
calls.append((self.command, self.path, dict(self.headers), body))
|
||||
payload = {"ok": True}
|
||||
if self.path.endswith("CreateArtifact"):
|
||||
payload["signedUploadUrl"] = "https://external.invalid/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=private&taskID=4&artifactID=8"
|
||||
else:
|
||||
payload["artifactId"] = "8"
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(payload).encode())
|
||||
|
||||
def do_PUT(self):
|
||||
body = self.rfile.read(int(self.headers["Content-Length"]))
|
||||
calls.append((self.command, self.path, dict(self.headers), body))
|
||||
self.send_response(201)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'"created"')
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
path = self.root / "test.zip"
|
||||
path.write_bytes(b"123456789")
|
||||
with patch.object(exporter, "CHUNK_BYTES", 4):
|
||||
exporter.upload_snapshot(path, "rust-cache-v1-backend-tests-attempt-1", 123, f"http://127.0.0.1:{server.server_port}", "ephemeral")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
self.assertEqual(len(calls), 6)
|
||||
self.assertEqual(calls[0][3]["workflowRunBackendId"], "123")
|
||||
remaining = datetime.fromisoformat(calls[0][3]["expiresAt"]) - datetime.now(timezone.utc)
|
||||
self.assertGreater(remaining.total_seconds(), 7 * 24 * 3600)
|
||||
self.assertLessEqual(remaining.total_seconds(), 8 * 24 * 3600)
|
||||
blocks = calls[1:4]
|
||||
self.assertEqual(b"".join(call[3] for call in blocks), b"123456789")
|
||||
self.assertEqual(len({urllib.parse.parse_qs(urllib.parse.urlsplit(call[1]).query)["blockid"][0] for call in blocks}), 3)
|
||||
for call in blocks:
|
||||
block_id = urllib.parse.parse_qs(urllib.parse.urlsplit(call[1]).query)["blockid"][0]
|
||||
gitea_filename_suffix = base64.urlsafe_b64encode(block_id.encode()).decode()
|
||||
self.assertEqual(decode_owner(gitea_filename_suffix), ("backend-tests", 1))
|
||||
self.assertTrue(all("Authorization" not in call[2] for call in calls[1:5]))
|
||||
self.assertEqual(calls[-1][2]["Authorization"], "Bearer ephemeral")
|
||||
self.assertEqual(calls[-1][3]["size"], "9")
|
||||
self.assertEqual(calls[-1][3]["hash"], "sha256:" + hashlib.sha256(b"123456789").hexdigest())
|
||||
|
||||
def test_upload_failure_never_finalizes_an_incomplete_artifact(self):
|
||||
path = self.root / "test.zip"
|
||||
path.write_bytes(b"data")
|
||||
with patch.object(exporter, "request", side_effect=[
|
||||
{"ok": True, "signedUploadUrl": "http://gitea/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=x"},
|
||||
RuntimeError("upload failed"),
|
||||
]) as request:
|
||||
with self.assertRaisesRegex(RuntimeError, "upload failed"):
|
||||
exporter.upload_snapshot(path, "rust-cache-v1-backend-tests-attempt-1", 123, "http://gitea", "ephemeral")
|
||||
self.assertEqual(request.call_count, 2)
|
||||
|
||||
def test_failed_optional_export_never_emits_host_completion_marker(self):
|
||||
(self.root / "export-baseline.json").write_text("{}")
|
||||
(self.root / "rustc.txt").write_text("rustc fixture\n")
|
||||
(self.root / "workspace.txt").write_text(str(Path.cwd().resolve()))
|
||||
(self.root / "source-commit.txt").write_text("a" * 40)
|
||||
self.object("b" * 64, b"new", 20)
|
||||
env = {
|
||||
"GITHUB_EVENT_NAME": "push", "GITHUB_REF": "refs/heads/master",
|
||||
"GENARRATIVE_CI_RUST_CACHE_EXPORT_READY": "1",
|
||||
"GITHUB_JOB": "backend-tests", "GITHUB_SHA": "b" * 40,
|
||||
"GITHUB_RUN_ID": "123", "GITHUB_RUN_ATTEMPT": "1",
|
||||
"GENARRATIVE_GITEA_TOKEN": "ephemeral",
|
||||
"GITHUB_REPOSITORY": "owner/repo",
|
||||
"GENARRATIVE_GITEA_REPOSITORY_URL": "http://gitea/owner/repo.git",
|
||||
"GENARRATIVE_CI_RUST_CACHE_ROOT": str(self.root),
|
||||
}
|
||||
output = io.StringIO()
|
||||
with patch.object(exporter.subprocess, "check_output", side_effect=["rustc fixture\n", "sccache 0.18.0\n"]), \
|
||||
patch.object(exporter, "upload_snapshot", side_effect=RuntimeError("upload failed")), \
|
||||
redirect_stdout(output):
|
||||
with self.assertRaisesRegex(RuntimeError, "upload failed"):
|
||||
exporter.export(env)
|
||||
self.assertNotIn("complete=true", output.getvalue())
|
||||
self.assertFalse((self.root / "export-baseline.json").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import http.client
|
||||
import http.server
|
||||
import gzip
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
@@ -21,6 +22,29 @@ MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
FETCH = "/api/actions/runner.v1.RunnerService/FetchTask"
|
||||
UPDATE = "/api/actions/runner.v1.RunnerService/UpdateTask"
|
||||
LOG = "/api/actions/runner.v1.RunnerService/UpdateLog"
|
||||
PING = "/api/actions/ping.v1.PingService/Ping"
|
||||
|
||||
|
||||
def varint(value):
|
||||
data = bytearray()
|
||||
while value > 127:
|
||||
data.append((value & 127) | 128)
|
||||
value >>= 7
|
||||
data.append(value)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def field(number, value):
|
||||
# actions-proto-go v0.4.1: task/state ID=1; result=2; log task_id=1,
|
||||
# index=2, rows=3, no_more=4; log response ack_index=1.
|
||||
if isinstance(value, bytes):
|
||||
return varint(number * 8 + 2) + varint(len(value)) + value
|
||||
return varint(number * 8) + varint(value)
|
||||
|
||||
|
||||
def state(task_id, result=0):
|
||||
return field(1, field(1, task_id) + field(2, result))
|
||||
|
||||
|
||||
def core_state(state):
|
||||
@@ -50,8 +74,17 @@ class Upstream(http.server.BaseHTTPRequestHandler):
|
||||
self.wfile.write(b"incomplete")
|
||||
self.close_connection = True
|
||||
return
|
||||
body = b'{"task":null}'
|
||||
self.send_response(200)
|
||||
body = self.server.responses.get(self.path, b"")
|
||||
if self.path == UPDATE and self.path not in self.server.responses:
|
||||
body = state(*map(int, MODULE.task_state(MODULE.protobuf_fields(data))))
|
||||
if self.path == LOG and self.path not in self.server.responses:
|
||||
fields = MODULE.protobuf_fields(data)
|
||||
body = field(1, MODULE.single(fields, 2, 0) + len(fields.get(3, [])))
|
||||
self.send_response(self.server.statuses.get(self.path, 200))
|
||||
self.send_header("Content-Type", self.server.content_type)
|
||||
if self.server.compressed:
|
||||
body = gzip.compress(body)
|
||||
self.send_header("Content-Encoding", "gzip")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
@@ -66,6 +99,10 @@ class GateTests(unittest.TestCase):
|
||||
self.upstream.entered = threading.Event()
|
||||
self.upstream.release = threading.Event()
|
||||
self.upstream.truncated = False
|
||||
self.upstream.responses = {}
|
||||
self.upstream.statuses = {}
|
||||
self.upstream.content_type = "application/proto"
|
||||
self.upstream.compressed = False
|
||||
self.proxy = MODULE.create_proxy(("127.0.0.1", 0),
|
||||
f"http://127.0.0.1:{self.upstream.server_port}", self.gate)
|
||||
self.servers = [self.upstream, self.proxy]
|
||||
@@ -79,13 +116,15 @@ class GateTests(unittest.TestCase):
|
||||
server.server_close()
|
||||
self.temp.cleanup()
|
||||
|
||||
def request(self, path, body=b"{}", chunked=False):
|
||||
def request(self, path, body=b"", chunked=False):
|
||||
connection = http.client.HTTPConnection("127.0.0.1", self.proxy.server_port, timeout=5)
|
||||
try:
|
||||
if chunked:
|
||||
connection.request("POST", path, body=[body], encode_chunked=True)
|
||||
connection.request("POST", path, body=[body], encode_chunked=True,
|
||||
headers={"Content-Type": "application/proto"})
|
||||
else:
|
||||
connection.request("POST", path, body=body)
|
||||
connection.request("POST", path, body=body,
|
||||
headers={"Content-Type": "application/proto"})
|
||||
response = connection.getresponse()
|
||||
result = response.status, response.read()
|
||||
return result
|
||||
@@ -112,7 +151,7 @@ class GateTests(unittest.TestCase):
|
||||
self.assertEqual(core_state(self.gate.control("pause")),
|
||||
{"paused": True, "inflight": 1, "uncertain": False})
|
||||
self.assertEqual(self.request(FETCH)[0], 503)
|
||||
self.assertEqual(self.request(UPDATE)[0], 200)
|
||||
self.assertEqual(self.request(PING)[0], 200)
|
||||
self.assertEqual(sum(path == FETCH for path, _ in self.upstream.requests), 1)
|
||||
self.upstream.release.set()
|
||||
thread.join(3)
|
||||
@@ -159,8 +198,8 @@ class GateTests(unittest.TestCase):
|
||||
self.assertFalse(MODULE.Gate(self.temp.name).control("status")["paused"])
|
||||
|
||||
def test_chunked_body_is_decoded_and_non_rpc_path_rejected(self):
|
||||
self.assertEqual(self.request(UPDATE, b'{"state":"running"}', chunked=True)[0], 200)
|
||||
self.assertEqual(self.upstream.requests[-1], (UPDATE, b'{"state":"running"}'))
|
||||
self.assertEqual(self.request(PING, b"ping", chunked=True)[0], 200)
|
||||
self.assertEqual(self.upstream.requests[-1], (PING, b"ping"))
|
||||
self.assertEqual(self.request("/api/v1/repos")[0], 404)
|
||||
self.assertEqual(self.request("/api/actions/../v1/repos")[0], 404)
|
||||
|
||||
@@ -200,12 +239,109 @@ class GateTests(unittest.TestCase):
|
||||
self.assertEqual(state["last_fetch_peer"], "127.0.0.1")
|
||||
self.assertGreaterEqual(state["last_fetch_at"], before)
|
||||
self.assertEqual(self.upstream.requests, [])
|
||||
self.assertEqual(self.request(UPDATE)[0], 200)
|
||||
self.assertEqual(self.request(PING)[0], 200)
|
||||
self.assertEqual(self.gate.control("status")["last_fetch_at"], state["last_fetch_at"])
|
||||
restarted = MODULE.Gate(self.temp.name).control("status")
|
||||
self.assertIsNone(restarted["last_fetch_peer"])
|
||||
self.assertIsNone(restarted["last_fetch_at"])
|
||||
|
||||
def fetch_task(self, task_id=42):
|
||||
self.upstream.responses[FETCH] = field(1, field(1, task_id)) + field(2, 10)
|
||||
self.upstream.release.set()
|
||||
self.assertEqual(self.request(FETCH)[0], 200)
|
||||
self.assertEqual(self.gate.control("status")["task_ids"], [str(task_id)])
|
||||
|
||||
def finalize_log(self, task_id=42):
|
||||
self.assertEqual(self.request(LOG, field(1, task_id) + field(2, 3)
|
||||
+ field(3, b"log row") + field(4, 1))[0], 200)
|
||||
self.wait_for(lambda: self.gate.tasks.get(str(task_id)) is True)
|
||||
|
||||
def test_assigned_task_survives_pause_until_runner_finished_reporting(self):
|
||||
self.fetch_task()
|
||||
self.assertEqual(self.gate.control("pause")["active_tasks"], 1)
|
||||
self.assertEqual(self.request(FETCH)[0], 503)
|
||||
self.assertEqual(self.request(UPDATE, state(42, 1))[0], 200)
|
||||
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
|
||||
self.finalize_log()
|
||||
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
|
||||
self.assertEqual(self.request(UPDATE, state(42, 1))[0], 200)
|
||||
self.wait_for(lambda: self.gate.control("status")["active_tasks"] == 0)
|
||||
self.assertFalse(self.gate.control("status")["uncertain"])
|
||||
# 终态响应在客户端超时后重试,不应再次阻断后续任务领取。
|
||||
self.request(UPDATE, state(42, 1))
|
||||
self.assertFalse(self.gate.control("status")["uncertain"])
|
||||
|
||||
def test_compressed_fetch_and_task_ledger_survive_restart(self):
|
||||
self.upstream.compressed = True
|
||||
self.fetch_task()
|
||||
restored = MODULE.Gate(self.temp.name)
|
||||
self.assertEqual(restored.control("status")["task_ids"], ["42"])
|
||||
self.assertFalse(restored.control("status")["uncertain"])
|
||||
self.proxy.gate = self.gate = restored
|
||||
self.finalize_log()
|
||||
self.request(UPDATE, state(42, 2))
|
||||
self.wait_for(lambda: self.gate.control("status")["active_tasks"] == 0)
|
||||
self.assertEqual(MODULE.Gate(self.temp.name).control("status")["active_tasks"], 0)
|
||||
|
||||
def test_server_cancellation_does_not_retire_executing_task(self):
|
||||
self.fetch_task()
|
||||
self.upstream.responses[UPDATE] = state(42, 3)
|
||||
self.request(UPDATE, state(42))
|
||||
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
|
||||
self.finalize_log()
|
||||
self.request(UPDATE, state(42))
|
||||
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
|
||||
self.request(UPDATE, state(42, 3))
|
||||
self.wait_for(lambda: self.gate.control("status")["active_tasks"] == 0)
|
||||
|
||||
def test_partial_final_log_acknowledgement_keeps_task_active(self):
|
||||
self.fetch_task()
|
||||
self.upstream.responses[LOG] = field(1, 3)
|
||||
self.request(LOG, field(1, 42) + field(2, 3) + field(3, b"row") + field(4, 1))
|
||||
self.request(UPDATE, state(42, 1))
|
||||
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
|
||||
del self.upstream.responses[LOG]
|
||||
self.finalize_log()
|
||||
self.request(UPDATE, state(42, 1))
|
||||
self.wait_for(lambda: self.gate.control("status")["active_tasks"] == 0)
|
||||
|
||||
def test_final_state_waits_for_success_and_output_acknowledgement(self):
|
||||
self.fetch_task()
|
||||
self.finalize_log()
|
||||
self.upstream.statuses[UPDATE] = 500
|
||||
self.request(UPDATE, state(42, 1))
|
||||
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
|
||||
self.upstream.statuses[UPDATE] = 200
|
||||
output = field(2, field(1, b"key") + field(2, b"value"))
|
||||
self.request(UPDATE, state(42, 1) + output)
|
||||
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
|
||||
self.upstream.responses[UPDATE] = state(42, 1) + field(2, b"key")
|
||||
self.request(UPDATE, state(42, 1) + output)
|
||||
self.wait_for(lambda: self.gate.control("status")["active_tasks"] == 0)
|
||||
|
||||
def test_unknown_or_invalid_fetch_result_prevents_idle(self):
|
||||
self.upstream.release.set()
|
||||
self.upstream.responses[FETCH] = b"not protobuf"
|
||||
self.assertEqual(self.request(FETCH)[0], 200)
|
||||
self.assertTrue(self.gate.control("status")["uncertain"])
|
||||
self.assertTrue(MODULE.Gate(self.temp.name).control("status")["uncertain"])
|
||||
|
||||
def test_task_report_without_observed_assignment_requires_idle_bootstrap(self):
|
||||
self.assertEqual(self.request(UPDATE, state(42))[0], 200)
|
||||
self.wait_for(lambda: self.gate.control("status")["uncertain"])
|
||||
self.assertIn("error", self.gate.control("resume"))
|
||||
|
||||
def test_disconnected_fetch_client_still_leaves_assigned_task_busy(self):
|
||||
self.upstream.responses[FETCH] = field(1, field(1, 42))
|
||||
client = socket.create_connection(self.proxy.server_address, timeout=3)
|
||||
client.sendall(f"POST {FETCH} HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n".encode())
|
||||
self.assertTrue(self.upstream.entered.wait(3))
|
||||
client.close()
|
||||
self.gate.control("pause")
|
||||
self.upstream.release.set()
|
||||
self.wait_for(lambda: self.gate.control("status")["inflight"] == 0)
|
||||
self.assertEqual(self.gate.control("status")["task_ids"], ["42"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
@@ -28,14 +29,11 @@ def runner_config(image: str) -> str:
|
||||
|
||||
|
||||
class FakeApi:
|
||||
def __init__(self, disabled: bool = False):
|
||||
self.disabled = disabled
|
||||
def __init__(self):
|
||||
self.requests: list[str] = []
|
||||
|
||||
def request(self, path, body=None, raw=False):
|
||||
self.requests.append(path)
|
||||
if path.startswith("admin/actions/runners/"):
|
||||
return {"disabled": self.disabled, "busy": False}
|
||||
raise AssertionError(f"unexpected API request: {path}")
|
||||
|
||||
|
||||
@@ -51,7 +49,6 @@ class GiteaCacheMaintenanceTest(unittest.TestCase):
|
||||
"token_file": str(self.token),
|
||||
"repository": "team/project",
|
||||
"repository_url": "http://gitea:3000/team/project.git",
|
||||
"runner_api_path": "admin/actions/runners/1",
|
||||
"runner_container": "gitea-runner",
|
||||
"gate_socket": str(self.root / "gate.sock"),
|
||||
"gate_url": "https://gitea.example.test",
|
||||
@@ -168,7 +165,8 @@ class GiteaCacheMaintenanceTest(unittest.TestCase):
|
||||
return json.dumps([{"State": {"StartedAt": started}, "NetworkSettings": {
|
||||
"Networks": {"internal": {"IPAddress": "10.0.0.2"}}}}])
|
||||
instance.docker = docker
|
||||
proof = {"last_fetch_peer": "10.0.0.2", "last_fetch_at": maintenance_module.timestamp(started).timestamp() + 1}
|
||||
proof = {"last_fetch_peer": "10.0.0.2", "last_fetch_at": maintenance_module.timestamp(started).timestamp() + 1,
|
||||
"active_tasks": 0}
|
||||
instance.gate = lambda action: proof
|
||||
instance.check_gate_route()
|
||||
proof["last_fetch_at"] -= 2
|
||||
@@ -225,6 +223,186 @@ class GiteaCacheMaintenanceTest(unittest.TestCase):
|
||||
self.assertEqual(instance.state["candidate"], IMAGE)
|
||||
self.assertNotIn("pause_owned", instance.state)
|
||||
|
||||
def test_idle_requires_task_ledger_and_all_live_container_states(self):
|
||||
instance = self.maintenance({"versions": [], "current": None})
|
||||
state = {"uncertain": False, "active_tasks": 1}
|
||||
instance.gate = lambda _: state
|
||||
calls = []
|
||||
instance.docker = lambda *args, **kw: calls.append((args, kw)) or ""
|
||||
self.assertFalse(instance.idle()) # assigned task before its first container
|
||||
self.assertEqual(calls, [])
|
||||
state["active_tasks"] = 0
|
||||
self.assertTrue(instance.idle())
|
||||
self.assertIn("status=created", calls[-1][0])
|
||||
self.assertTrue(calls[-1][1]["inner"])
|
||||
instance.docker = lambda *_a, **_k: "created-container\n"
|
||||
self.assertFalse(instance.idle())
|
||||
del state["active_tasks"]
|
||||
with self.assertRaisesRegex(RuntimeError, "durable task tracking"):
|
||||
instance.idle()
|
||||
|
||||
def source_fixture(self):
|
||||
old = self.record(OLD_IMAGE, source="b" * 40, owned=False, verified_run=1)
|
||||
instance = self.maintenance({"versions": [old], "current": OLD_IMAGE})
|
||||
run = {"id": 44, "status": "completed", "conclusion": "failure", "event": "push",
|
||||
"path": "project-ci.yml@refs/heads/master", "head_branch": "master", "head_sha": SHA}
|
||||
jobs = [{"id": i, "name": name, "status": "completed", "conclusion": "failure",
|
||||
"head_sha": SHA, "run_attempt": 1,
|
||||
"steps": [{"name": maintenance_module.EXPORT_STEP, "conclusion": "success"}]}
|
||||
for i, name in enumerate(maintenance_module.RUST_JOB_IDS, start=1)]
|
||||
artifacts = [{"id": job["id"], "name": "rust-cache-v1-" + maintenance_module.RUST_JOB_IDS[job["name"]]
|
||||
+ "-attempt-1", "expired": False, "workflow_run": run} for job in jobs]
|
||||
class Api:
|
||||
def pages(self, path, key):
|
||||
return iter({"workflow_runs": [run], "jobs": jobs, "artifacts": artifacts}[key])
|
||||
def request(self, path, raw=False):
|
||||
job_id = int(path.split("/jobs/")[1].split("/")[0])
|
||||
name = next(item["name"] for item in artifacts if item["id"] == job_id)
|
||||
return f"worker image: {OLD_IMAGE}\n[rust-cache] artifact={name} objects=1 bytes=10 complete=true\n"
|
||||
instance.api = Api()
|
||||
return instance, run, jobs, artifacts
|
||||
|
||||
def select_source(self, instance):
|
||||
with patch.object(maintenance_module.subprocess, "run", return_value=type("Result", (), {"returncode": 0})()), \
|
||||
patch.object(maintenance_module, "command", lambda *args, **kw: "100644 blob " + args[-1] + "\tsrc.rs\0"):
|
||||
return instance.source_run()
|
||||
|
||||
def test_complete_failed_master_can_supply_cache_but_pr_and_missing_groups_cannot(self):
|
||||
instance, run, jobs, artifacts = self.source_fixture()
|
||||
result = self.select_source(instance)
|
||||
self.assertEqual(result["run_id"], 44)
|
||||
self.assertEqual(len(result["exports"]), 6)
|
||||
run["event"] = "pull_request"
|
||||
self.assertIsNone(self.select_source(instance))
|
||||
run["event"] = "push"
|
||||
artifacts.pop()
|
||||
self.assertIsNone(self.select_source(instance))
|
||||
|
||||
def test_stale_attempt_cancelled_job_or_failed_export_cannot_supply_cache(self):
|
||||
instance, run, jobs, artifacts = self.source_fixture()
|
||||
run["conclusion"] = "cancelled"
|
||||
self.assertIsNone(self.select_source(instance)) # all six artifacts may already exist
|
||||
run["conclusion"] = "failure"
|
||||
jobs[0]["run_attempt"] = 2
|
||||
self.assertIsNone(self.select_source(instance))
|
||||
jobs[0]["run_attempt"] = 1
|
||||
jobs[0]["conclusion"] = "cancelled"
|
||||
self.assertIsNone(self.select_source(instance))
|
||||
jobs[0]["conclusion"] = "success"
|
||||
jobs[0]["steps"][0]["conclusion"] = "failure"
|
||||
self.assertIsNone(self.select_source(instance))
|
||||
|
||||
def test_exports_using_different_images_are_not_combined(self):
|
||||
instance, _, _, _ = self.source_fixture()
|
||||
original = instance.api.request
|
||||
instance.api.request = lambda path, raw=False: original(path, raw).replace(OLD_IMAGE, IMAGE) if '/jobs/1/' in path else original(path, raw)
|
||||
self.assertIsNone(self.select_source(instance))
|
||||
|
||||
def test_completed_artifact_without_upload_completion_log_is_not_used(self):
|
||||
instance, _, _, _ = self.source_fixture()
|
||||
instance.api.request = lambda *_a, **_k: f"worker image: {OLD_IMAGE}\n"
|
||||
self.assertIsNone(self.select_source(instance))
|
||||
|
||||
def test_assembly_uses_ci_objects_and_trusted_binary_without_warming_compiler(self):
|
||||
instance, _, _, _ = self.source_fixture()
|
||||
source = self.select_source(instance)
|
||||
shell_calls, docker_calls = [], []
|
||||
instance.build_command = lambda log, script, *args, **kw: shell_calls.append((script, args))
|
||||
def info(image, inner=False):
|
||||
return {"Id": IMAGE if image.startswith("genarrative/") else image, "Config": {"Labels": {
|
||||
"world.genarrative.ci.rust-cache-source": "b" * 40,
|
||||
"world.genarrative.ci.rust-cache-base": BASE_IMAGE,
|
||||
"com.genarrative.ci.definition-sha256": "definition",
|
||||
}}}
|
||||
instance.image_info = info
|
||||
def docker(*args, **kw):
|
||||
docker_calls.append(args)
|
||||
if args[0] == "create":
|
||||
return "temporary-copy-container"
|
||||
if args[0] == "cp":
|
||||
root = Path(args[-1])
|
||||
(root / "objects").mkdir()
|
||||
(root / "sccache").write_bytes(b"trusted binary")
|
||||
if args[-2:] == ("rustc", "-vV"):
|
||||
return "rustc test\n"
|
||||
return ""
|
||||
instance.docker = docker
|
||||
instance.api.download = lambda path, destination: destination.write_bytes(b"download")
|
||||
def merge(inputs, output, **kwargs):
|
||||
self.assertEqual(len(inputs), 6)
|
||||
self.assertEqual(kwargs["expected_inherited_source_sha"], "b" * 40)
|
||||
output.mkdir()
|
||||
(output / "objects").mkdir()
|
||||
return type("Merged", (), {"sccache_version": "sccache 0.18.0", "rustc": "rustc test\n",
|
||||
"workspace": "/workspace/team/project", "base_image": BASE_IMAGE})()
|
||||
with patch.object(maintenance_module, "command", return_value="definition\n"), \
|
||||
patch.object(maintenance_module, "merge_snapshots", merge):
|
||||
instance.build(source)
|
||||
self.assertEqual(shell_calls, [("gitea-ci-job-image.sh", ("verify", "genarrative/gitea-project-ci:rust-cache-auto-" + SHA))])
|
||||
self.assertIn(("create", OLD_IMAGE), docker_calls)
|
||||
self.assertIn(("rm", "--volumes", "temporary-copy-container"), docker_calls)
|
||||
self.assertEqual(instance.state["candidate"], IMAGE)
|
||||
self.assertEqual(instance.version(IMAGE)["run_id"], 44)
|
||||
|
||||
def test_export_cleanup_only_deletes_collected_or_expired_owned_master_artifacts(self):
|
||||
current = self.record(OLD_IMAGE, owned=False, verified_run=1)
|
||||
current.update(staged=True, run_id=44, exports=[{"id": 1}])
|
||||
instance = self.maintenance({"versions": [current], "current": OLD_IMAGE})
|
||||
old_run = {"id": 9, "status": "completed", "path": "project-ci.yml@refs/heads/master",
|
||||
"event": "push", "head_branch": "master", "head_sha": SHA}
|
||||
artifact = {"id": 2, "name": "rust-cache-v1-backend-tests-attempt-1",
|
||||
"workflow_run": {"id": 9}, "created_at": "2020-01-01T00:00:00Z"}
|
||||
rows = [artifact, {**artifact, "id": 3, "name": "release-binary"},
|
||||
{**artifact, "id": 4, "workflow_run": {"id": 10}},
|
||||
{**artifact, "id": 5, "workflow_run": {"id": 11}}]
|
||||
deleted = []
|
||||
instance.api.pages = lambda *_: iter(rows)
|
||||
def request(path, method=None):
|
||||
if method == "DELETE":
|
||||
return deleted.append((path, method))
|
||||
return {9: old_run, 10: {**old_run, "event": "pull_request"},
|
||||
11: {**old_run, "status": "in_progress"}}[int(path.rsplit("/", 1)[1])]
|
||||
instance.api.request = request
|
||||
instance.cleanup_exports()
|
||||
self.assertEqual(deleted, [(instance.repo_api + "/actions/artifacts/1", "DELETE"),
|
||||
(instance.repo_api + "/actions/artifacts/2", "DELETE")])
|
||||
self.assertTrue(instance.version(OLD_IMAGE)["exports"][0]["deleted"])
|
||||
|
||||
def test_signed_download_drops_token_and_rejects_other_origins(self):
|
||||
api = maintenance_module.Api(self.config["api_url"], self.token)
|
||||
destination = self.root / "artifact.zip"
|
||||
seen = []
|
||||
target = ["https://gitea.example.test/api/v1/repos/team/project/actions/artifacts/1/zip/raw?sig=test"]
|
||||
class Opener:
|
||||
def open(self, request, timeout):
|
||||
seen.append(request)
|
||||
if not isinstance(request, str):
|
||||
raise maintenance_module.urllib.error.HTTPError(request.full_url, 302, "Found", {"Location": target[0]}, None)
|
||||
return io.BytesIO(b"archive")
|
||||
with patch.object(maintenance_module.urllib.request, "build_opener", return_value=Opener()):
|
||||
api.download("repos/team/project/actions/artifacts/1/zip", destination)
|
||||
self.assertEqual(destination.read_bytes(), b"archive")
|
||||
self.assertEqual(seen[0].get_header("Authorization"), "token test-token")
|
||||
self.assertIsInstance(seen[1], str) # signed URL request has no Authorization header
|
||||
target[0] = "https://other.example.test/download"
|
||||
with self.assertRaisesRegex(RuntimeError, "configured HTTPS Gitea origin"):
|
||||
api.download("repos/team/project/actions/artifacts/1/zip", self.root / "rejected.zip")
|
||||
self.assertFalse((self.root / "rejected.zip").exists())
|
||||
|
||||
def test_pending_chunk_cleanup_requires_old_terminal_master_run(self):
|
||||
instance = self.maintenance({"versions": [], "current": None, "attempts": [{"run_id": 4}]})
|
||||
instance.config["artifact_storage_dir"] = str(self.root / "storage")
|
||||
run = {"id": 1, "status": "completed", "path": "project-ci.yml@refs/heads/master",
|
||||
"event": "push", "head_branch": "master", "head_sha": SHA,
|
||||
"completed_at": "2020-01-01T00:00:00Z"}
|
||||
rows = [run, {**run, "id": 2, "event": "pull_request"},
|
||||
{**run, "id": 3, "status": "in_progress"}, {**run, "id": 4},
|
||||
{**run, "id": 5, "completed_at": "2999-01-01T00:00:00Z"}]
|
||||
instance.api.pages = lambda *_: iter(rows)
|
||||
with patch.object(maintenance_module, "cleanup_upload_chunks", return_value=0) as cleanup:
|
||||
instance.cleanup_pending_uploads()
|
||||
self.assertEqual(cleanup.call_args.args[1], {1})
|
||||
|
||||
def test_uncertain_or_inflight_gate_never_restarts(self):
|
||||
instance, _, restarts = self.activation_maintenance(idle_values=[True])
|
||||
statuses = 0
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for streamed Gitea sccache snapshot validation and merging."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).with_name("gitea_cache_snapshot.py")
|
||||
SPEC = importlib.util.spec_from_file_location("gitea_cache_snapshot", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
snapshot = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = snapshot
|
||||
SPEC.loader.exec_module(snapshot)
|
||||
|
||||
REPOSITORY = "team/project"
|
||||
SOURCE_SHA = "a" * 40
|
||||
INHERITED_SOURCE_SHA = "c" * 40
|
||||
BASE_IMAGE = "sha256:" + "b" * 64
|
||||
RUSTC = "rustc 1.90.0\nbinary: rustc\n"
|
||||
WORKSPACE = "/workspace/GenarrativeAI/Genarrative"
|
||||
SCCACHE_VERSION = "0.18.0"
|
||||
|
||||
|
||||
def object_path(character: str) -> str:
|
||||
key = character * 64
|
||||
return f"objects/{key[0]}/{key[1]}/{key}"
|
||||
|
||||
|
||||
class SnapshotMergeTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary_directory = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.root = Path(self.temporary_directory.name).resolve()
|
||||
self.base = self.root / "base-objects"
|
||||
self.base.mkdir()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary_directory.cleanup()
|
||||
|
||||
def identity(self, job: str, run_id: int) -> object:
|
||||
return snapshot.ArtifactIdentity(REPOSITORY, run_id, 1, job, SOURCE_SHA)
|
||||
|
||||
def archive(
|
||||
self,
|
||||
name: str,
|
||||
job: str,
|
||||
run_id: int,
|
||||
objects: list[tuple[str, bytes, int]],
|
||||
*,
|
||||
prefix: bool = True,
|
||||
manifest_update=None,
|
||||
extra_member: tuple[str, bytes, str] | None = None,
|
||||
touched: list[tuple[str, int]] | None = None,
|
||||
) -> object:
|
||||
rows = [
|
||||
{
|
||||
"path": path,
|
||||
"size": len(contents),
|
||||
"sha256": hashlib.sha256(contents).hexdigest(),
|
||||
"mtime_ns": mtime_ns,
|
||||
}
|
||||
for path, contents, mtime_ns in objects
|
||||
]
|
||||
manifest = {
|
||||
"schema": 1,
|
||||
"repository": REPOSITORY,
|
||||
"run_id": run_id,
|
||||
"run_attempt": 1,
|
||||
"job": job,
|
||||
"source_sha": SOURCE_SHA,
|
||||
"base_image": BASE_IMAGE,
|
||||
"rustc": RUSTC,
|
||||
"workspace": WORKSPACE,
|
||||
"sccache_version": SCCACHE_VERSION,
|
||||
"mode": "delta",
|
||||
"inherited_source_sha": INHERITED_SOURCE_SHA,
|
||||
"objects": rows,
|
||||
"touched": [
|
||||
{"path": path, "mtime_ns": mtime_ns}
|
||||
for path, mtime_ns in (touched or [])
|
||||
],
|
||||
}
|
||||
if manifest_update is not None:
|
||||
manifest_update(manifest)
|
||||
|
||||
tar_bytes = io.BytesIO()
|
||||
with tarfile.open(fileobj=tar_bytes, mode="w") as bundle:
|
||||
for path, contents, _ in objects:
|
||||
info = tarfile.TarInfo(path)
|
||||
info.size = len(contents)
|
||||
bundle.addfile(info, io.BytesIO(contents))
|
||||
if extra_member is not None:
|
||||
path, contents, kind = extra_member
|
||||
info = tarfile.TarInfo(path)
|
||||
if kind == "symlink":
|
||||
info.type = tarfile.SYMTYPE
|
||||
info.linkname = "manifest.json"
|
||||
bundle.addfile(info)
|
||||
else:
|
||||
info.size = len(contents)
|
||||
bundle.addfile(info, io.BytesIO(contents))
|
||||
manifest_bytes = json.dumps(manifest, separators=(",", ":")).encode()
|
||||
info = tarfile.TarInfo("manifest.json")
|
||||
info.size = len(manifest_bytes)
|
||||
bundle.addfile(info, io.BytesIO(manifest_bytes))
|
||||
|
||||
archive = self.root / f"{name}.zip"
|
||||
member = f"{name}/snapshot.tar" if prefix else "snapshot.tar"
|
||||
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_STORED) as bundle:
|
||||
bundle.writestr(member, tar_bytes.getvalue())
|
||||
return snapshot.ArtifactInput(archive, self.identity(job, run_id))
|
||||
|
||||
def base_object(self, path: str, contents: bytes, mtime_ns: int) -> None:
|
||||
target = self.base.joinpath(*Path(path).parts[1:])
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(contents)
|
||||
os.utime(target, ns=(mtime_ns, mtime_ns))
|
||||
|
||||
def merge(self, inputs, output=None, maximum=1024 ** 3):
|
||||
return snapshot.merge_snapshots(
|
||||
inputs,
|
||||
output or self.root / "merged",
|
||||
base_objects=self.base,
|
||||
expected_inherited_source_sha=INHERITED_SOURCE_SHA,
|
||||
max_combined_bytes=maximum,
|
||||
)
|
||||
|
||||
def test_merges_groups_deduplicates_newest_mtime_and_prunes_to_bound(self) -> None:
|
||||
first_path = object_path("a")
|
||||
second_path = object_path("b")
|
||||
third_path = object_path("c")
|
||||
inherited_path = object_path("d")
|
||||
self.base_object(inherited_path, b"dd", 50)
|
||||
inputs = [
|
||||
self.archive("first", "Backend tests", 41, [
|
||||
(first_path, b"aaaa", 100),
|
||||
(second_path, b"bbbb", 200),
|
||||
]),
|
||||
self.archive("second", "Native shell tests", 41, [
|
||||
(first_path, b"aaaa", 300),
|
||||
(third_path, b"ccc", 250),
|
||||
], prefix=False, touched=[(inherited_path, 400)]),
|
||||
]
|
||||
|
||||
output = self.root / "merged"
|
||||
result = self.merge(inputs, output, maximum=9)
|
||||
|
||||
self.assertEqual((result.object_count, result.total_bytes), (3, 9))
|
||||
self.assertEqual((output / inherited_path).read_bytes(), b"dd")
|
||||
self.assertEqual((output / first_path).read_bytes(), b"aaaa")
|
||||
self.assertEqual((output / third_path).read_bytes(), b"ccc")
|
||||
self.assertFalse((output / second_path).exists())
|
||||
self.assertEqual((output / "rustc.txt").read_text(), RUSTC)
|
||||
self.assertEqual((output / "workspace.txt").read_text(), WORKSPACE + "\n")
|
||||
self.assertEqual((output / "source-commit.txt").read_text(), SOURCE_SHA + "\n")
|
||||
self.assertEqual((output / "base-image.txt").read_text(), BASE_IMAGE + "\n")
|
||||
self.assertFalse((output / "sccache").exists())
|
||||
|
||||
def test_rejects_traversal_member_without_creating_output(self) -> None:
|
||||
item = self.archive(
|
||||
"traversal", "Backend tests", 42, [],
|
||||
extra_member=("../outside", b"bad", "file"),
|
||||
)
|
||||
output = self.root / "merged"
|
||||
with self.assertRaisesRegex(snapshot.SnapshotError, "invalid sccache object path"):
|
||||
self.merge([item], output)
|
||||
self.assertFalse(output.exists())
|
||||
self.assertFalse((self.root / "outside").exists())
|
||||
|
||||
def test_rejects_checksum_mismatch(self) -> None:
|
||||
path = object_path("d")
|
||||
|
||||
def corrupt(manifest):
|
||||
manifest["objects"][0]["sha256"] = "0" * 64
|
||||
|
||||
item = self.archive(
|
||||
"checksum", "Backend tests", 43, [(path, b"content", 1)],
|
||||
manifest_update=corrupt,
|
||||
)
|
||||
with self.assertRaisesRegex(snapshot.SnapshotError, "checksums do not match"):
|
||||
self.merge([item])
|
||||
|
||||
def test_rejects_manifest_identity_mismatch(self) -> None:
|
||||
item = self.archive(
|
||||
"identity", "Backend tests", 44, [],
|
||||
manifest_update=lambda manifest: manifest.update(run_id=999),
|
||||
)
|
||||
with self.assertRaisesRegex(snapshot.SnapshotError, "run_id does not match"):
|
||||
self.merge([item])
|
||||
|
||||
def test_rejects_conflicting_duplicate_key(self) -> None:
|
||||
path = object_path("e")
|
||||
inputs = [
|
||||
self.archive("conflict-one", "Backend tests", 45, [(path, b"one", 1)]),
|
||||
self.archive("conflict-two", "Native shell tests", 45, [(path, b"two", 2)]),
|
||||
]
|
||||
with self.assertRaisesRegex(snapshot.SnapshotError, "conflicting content"):
|
||||
self.merge(inputs)
|
||||
|
||||
def test_rejects_delta_that_conflicts_with_inherited_key(self) -> None:
|
||||
path = object_path("f")
|
||||
self.base_object(path, b"base", 1)
|
||||
item = self.archive("base-conflict", "Backend tests", 46, [(path, b"evil", 2)])
|
||||
with self.assertRaisesRegex(snapshot.SnapshotError, "conflicting content"):
|
||||
self.merge([item])
|
||||
|
||||
def test_accepts_empty_delta(self) -> None:
|
||||
item = self.archive("empty", "Backend tests", 47, [])
|
||||
output = self.root / "merged"
|
||||
result = self.merge([item], output)
|
||||
self.assertEqual((result.object_count, result.total_bytes), (0, 0))
|
||||
self.assertEqual(list((output / "objects").iterdir()), [])
|
||||
|
||||
def test_rejects_noncanonical_base_entry(self) -> None:
|
||||
(self.base / "unexpected").write_bytes(b"not an object")
|
||||
item = self.archive("base-path", "Backend tests", 48, [])
|
||||
with self.assertRaisesRegex(snapshot.SnapshotError, "noncanonical entry"):
|
||||
self.merge([item])
|
||||
|
||||
def test_rejects_tar_links(self) -> None:
|
||||
item = self.archive(
|
||||
"link", "Backend tests", 49, [],
|
||||
extra_member=(object_path("9"), b"", "symlink"),
|
||||
)
|
||||
with self.assertRaisesRegex(snapshot.SnapshotError, "regular file"):
|
||||
self.merge([item])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""缓存上传残块清理只触碰已确认结束的过期自有文件。"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from gitea_cache_upload_cleanup import cleanup_upload_chunks, decode_owner
|
||||
|
||||
|
||||
class UploadCleanupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name).resolve()
|
||||
|
||||
def file(self, name, run=12, age=10):
|
||||
directory = self.root / "tmp-upload" / f"run-{run}-v4"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / name
|
||||
path.write_bytes(b"block data")
|
||||
os.utime(path, (age, age))
|
||||
return path
|
||||
|
||||
def block(self, artifact=34, run=12, age=10, marker="genarrative-rust-cache-v1:backend-tests:1:00000000"):
|
||||
encoded = base64.urlsafe_b64encode(base64.b64encode(marker.encode())).decode()
|
||||
return self.file(f"block-{run}-{artifact}-10-{encoded}", run, age)
|
||||
|
||||
def test_old_owned_blocks_and_associated_blocklist_are_deleted(self):
|
||||
block = self.block()
|
||||
blocklist = self.file("12-34-blocklist")
|
||||
self.assertEqual(cleanup_upload_chunks(self.root, {12}, 20), 2)
|
||||
self.assertFalse(block.exists())
|
||||
self.assertFalse(blocklist.exists())
|
||||
self.assertTrue(block.parent.is_dir())
|
||||
|
||||
def test_other_runs_artifacts_and_unproven_blocklists_remain(self):
|
||||
own = self.block()
|
||||
other_run = self.block(run=13)
|
||||
other_artifact = self.block(artifact=35, marker="unrelated-upload")
|
||||
unproven = self.file("12-36-blocklist")
|
||||
self.assertEqual(cleanup_upload_chunks(self.root, {12}, 20), 1)
|
||||
self.assertFalse(own.exists())
|
||||
self.assertTrue(all(path.exists() for path in (other_run, other_artifact, unproven)))
|
||||
|
||||
def test_young_or_unknown_member_protects_entire_artifact_group(self):
|
||||
own = self.block()
|
||||
young = self.file("12-34-blocklist", age=20)
|
||||
other = self.block(artifact=35)
|
||||
unknown = self.file("block-12-35-invalid-size-unknown")
|
||||
self.assertEqual(cleanup_upload_chunks(self.root, {12}, 20), 0)
|
||||
self.assertTrue(all(path.exists() for path in (own, young, other, unknown)))
|
||||
|
||||
def test_completely_unknown_file_protects_entire_run(self):
|
||||
block = self.block()
|
||||
self.file("unknown")
|
||||
self.assertEqual(cleanup_upload_chunks(self.root, {12}, 20), 0)
|
||||
self.assertTrue(block.exists())
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "symlink fixture requires POSIX")
|
||||
def test_symlinks_never_traversed_or_deleted(self):
|
||||
block = self.block()
|
||||
outside = self.root / "secret"
|
||||
outside.write_text("keep")
|
||||
(block.parent / "12-34-blocklist").symlink_to(outside)
|
||||
(self.root / "tmp-upload" / "run-13-v4").symlink_to(block.parent, target_is_directory=True)
|
||||
self.assertEqual(cleanup_upload_chunks(self.root, {12, 13}, 20), 0)
|
||||
self.assertTrue(block.exists())
|
||||
self.assertEqual(outside.read_text(), "keep")
|
||||
linked = self.root / "root-link"
|
||||
linked.symlink_to(self.root, target_is_directory=True)
|
||||
with self.assertRaises(ValueError):
|
||||
cleanup_upload_chunks(linked, {12}, 20)
|
||||
|
||||
def test_root_missing_or_relative_is_an_error(self):
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
cleanup_upload_chunks(self.root / "missing", {12}, 20)
|
||||
with self.assertRaises(ValueError):
|
||||
cleanup_upload_chunks(Path("relative"), {12}, 20)
|
||||
|
||||
def test_noncanonical_encoding_and_unknown_job_are_not_owned(self):
|
||||
for marker in ("genarrative-rust-cache-v1:other-job:1:00000000", "genarrative-rust-cache-v1:backend-tests:01:00000000"):
|
||||
encoded = base64.urlsafe_b64encode(base64.b64encode(marker.encode())).decode()
|
||||
self.assertIsNone(decode_owner(encoded))
|
||||
self.assertIsNone(decode_owner("not-base64"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user