fix(repo):统一发布健康与只读质量门禁
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-13 22:09:44 +08:00
parent 32fc64fa83
commit c17904ee1c
25 changed files with 869 additions and 184 deletions
+23
View File
@@ -223,6 +223,16 @@ jobs:
source /var/lib/act_runner/env.sh source /var/lib/act_runner/env.sh
make test-go-api make test-go-api
- name: Check Go formatting
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
files="$(git ls-files '*.go')"
test -n "${files}"
test -z "$(gofmt -l ${files})"
- name: Run Go API vet - name: Run Go API vet
shell: bash shell: bash
run: | run: |
@@ -239,6 +249,19 @@ jobs:
source /var/lib/act_runner/env.sh source /var/lib/act_runner/env.sh
go build -o /tmp/bat-api ./cmd/bat-api go build -o /tmp/bat-api ./cmd/bat-api
- name: Optional Go lint
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
if command -v golangci-lint >/dev/null 2>&1; then
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}"
golangci-lint run ./...
else
printf 'SKIP optional: Go lint (golangci-lint); reason=command not installed\n'
fi
- name: Run documentation status gate - name: Run documentation status gate
shell: bash shell: bash
run: | run: |
+4 -2
View File
@@ -262,7 +262,9 @@ marker 存在时才保持兼容 key,无法证明归属时拒绝 cleanup;完
| 空目录 `api/` `pkg/` 等 | 占位 | 无实现 | | 空目录 `api/` `pkg/` 等 | 占位 | 无实现 |
| Web | `web/` | 内嵌 dashboard MVP;完整协作后台仍未完成 | | Web | `web/` | 内嵌 dashboard MVP;完整协作后台仍未完成 |
默认 Go/docs 门禁:`make test-go-api``make build-go-api``make check-docs`(无 FFI)。 默认 Go/docs 只读门禁:`make ci-check`Rust fmt/check/build/clippy/test、Go API
format/test/vet/build、docs/OpenAPI/RPC contract;无 FFI)。`make format` / `make fmt`
才会修改源码;`golangci-lint` 缺失时作为 optional gate 明确输出 skipped。
--- ---
@@ -306,7 +308,7 @@ cargo run -p bat-infrastructure --bin bat -- \
--watch --watch
``` ```
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status``resource.manifest``official-sync-snapshot.json` 和 Glossary query contract fixtureGo mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh``schedule.*``task.*` 查询/取消、`daemon.logs``parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm``translation.glossary.*``localized.publish``localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。 资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root` 和 Rust `release.status` 的 whole-release `official_distribution_ready`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status``resource.manifest``official-sync-snapshot.json` 和 Glossary query contract fixtureGo mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh``schedule.*``task.*` 查询/取消、`daemon.logs``parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm``translation.glossary.*``localized.publish``localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
生产要求: 生产要求:
+9 -5
View File
@@ -1,4 +1,4 @@
.PHONY: help build build-ffi test clean check check-docs fmt lint install dev docker-build docker-up docker-down official-smoke bat-api-local-live-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all .PHONY: help build build-ffi test clean check check-docs format fmt lint ci ci-check install dev docker-build docker-up docker-down official-smoke bat-api-local-live-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all
# 默认目标 # 默认目标
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
@@ -102,6 +102,8 @@ check-docs: ## 检查权威状态文档与占位目录声明
fmt: fmt-rust fmt-go ## 格式化所有代码 fmt: fmt-rust fmt-go ## 格式化所有代码
format: fmt ## 格式化所有代码(会修改工作树)
fmt-rust: ## 格式化 Rust 代码 fmt-rust: ## 格式化 Rust 代码
@echo "$(BLUE)Formatting Rust code...$(NC)" @echo "$(BLUE)Formatting Rust code...$(NC)"
cargo fmt --all cargo fmt --all
@@ -118,9 +120,9 @@ lint-rust: ## Rust Clippy 检查
lint-go: ## Go Linter 检查 lint-go: ## Go Linter 检查
@echo "$(BLUE)Running golangci-lint...$(NC)" @echo "$(BLUE)Running golangci-lint...$(NC)"
@command -v golangci-lint >/dev/null 2>&1 || { echo "$(YELLOW)golangci-lint not installed, skipping...$(NC)"; exit 0; } @command -v golangci-lint >/dev/null 2>&1 || { echo "$(YELLOW)OPTIONAL gate skipped: golangci-lint is not installed$(NC)"; exit 0; }
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \ @if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
golangci-lint run ./...; \ XDG_CACHE_HOME="$${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}" golangci-lint run ./...; \
else \ else \
echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \ echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \
fi fi
@@ -189,5 +191,7 @@ docs: ## 生成文档
# CI/CD # CI/CD
# ============================================================================ # ============================================================================
ci: fmt lint test ## 运行 CI 检查(本地模拟 ci-check: ## 运行只读 required CI 门禁(可选 lint 会明确标记
@echo "$(GREEN)✓ All CI checks passed!$(NC)" @bash scripts/ci-check.sh
ci: ci-check ## 运行只读 CI 检查(兼容旧命令名)
+4 -8
View File
@@ -60,16 +60,12 @@
运行当前通用验证: 运行当前通用验证:
```bash ```bash
cargo fmt --all -- --check make ci-check
cargo check --workspace
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
make test-go-api
make build-go-api
go vet ./...
make check-docs
``` ```
`make ci-check` 是只读 required 门禁;`make format` / `make fmt` 才会格式化源码。
如果环境中没有 `golangci-lint`,脚本会明确报告 optional gate skipped。
查看官方同步命令: 查看官方同步命令:
```bash ```bash
+19 -6
View File
@@ -52,7 +52,7 @@
# T01 — bat-api 发布完整性与分发健康契约 # T01 — bat-api 发布完整性与分发健康契约
**类型:** P1 **类型:** P1
**状态:** Ready **状态:** Done
**优先级:** 最高 **优先级:** 最高
## 问题 ## 问题
@@ -136,10 +136,17 @@ B 同大小内容损坏,Rust health=false
## 关系 ## 关系
**阻塞** **后续关系**
* T11 正式长期运行验证 * T11 正式长期运行验证使用本任务建立的 health/readiness evidence。
* Production-ready 判定 * Production-ready 判定仍需结合长期运行证据,不由本任务单独宣告。
## 完成记录
Rust `release.status.official_distribution_ready` 已接入 Go refresh snapshot
`readyz`、bootstrap、release summary 和普通 current CDN 共用 whole-release
distribution gate。刷新失败会清空旧 index,恢复后重新建立快照;显式
official/localized/historical 分发仍经 Rust `release.distribution`
--- ---
@@ -507,11 +514,11 @@ T07 → Production-ready
# T08 — CI Gate 与质量门禁整理 # T08 — CI Gate 与质量门禁整理
**类型:** Engineering Baseline **类型:** Engineering Baseline
**状态:** Ready **状态:** Done
## 问题 ## 问题
当前 `make ci` 存在: `make ci` 存在:
* format target 会直接修改文件; * format target 会直接修改文件;
* 部分 lint tool 缺失时可能 skip * 部分 lint tool 缺失时可能 skip
@@ -540,6 +547,12 @@ CI 检查必须:
* optional gate 明确标记 optional * optional gate 明确标记 optional
* 与实际 Gitea CI 尽量保持一致。 * 与实际 Gitea CI 尽量保持一致。
## 完成记录
`make format` / `make fmt` 保留为显式写入命令,`make ci-check` 和兼容的
`make ci` 只执行 read-only required gatesGo lint 在工具缺失时明确输出
optional skippedOpenAPI、RPC contract 和文档一致性由 `make check-docs` 纳入。
建议统一覆盖: 建议统一覆盖:
```text ```text
+7 -7
View File
@@ -20,25 +20,25 @@ paths:
summary: Release readiness summary: Release readiness
responses: responses:
"200": "200":
description: A distributable release is available. description: A release authorized by Rust release.status and fully represented by the local read snapshot is available.
"503": "503":
description: No distributable release is available. description: The Rust whole-release distribution health fact or the local read snapshot is not distributable.
/v1/bootstrap: /v1/bootstrap:
get: get:
summary: Startup resource bootstrap summary: Startup resource bootstrap
responses: responses:
"200": "200":
description: Resource bootstrap response. description: Resource bootstrap response with the same distribution health used by readiness and current CDN serving.
"503": "503":
description: Release is not ready. description: The current release is not distributable.
/v1/launcher/bootstrap: /v1/launcher/bootstrap:
get: get:
summary: Launcher-shaped resource bootstrap summary: Launcher-shaped resource bootstrap
responses: responses:
"200": "200":
description: Launcher bootstrap response. description: Launcher bootstrap response with the current release distribution health.
"503": "503":
description: Release is not ready. description: The current release is not distributable.
/api/launcher/game/config: /api/launcher/game/config:
get: get:
summary: Resource-only launcher game config compatibility summary: Resource-only launcher game config compatibility
@@ -71,7 +71,7 @@ paths:
summary: Current release summary summary: Current release summary
responses: responses:
"200": "200":
description: Release summary. description: Release summary including Rust-owned whole-release distribution health.
/v1/releases: /v1/releases:
get: get:
summary: Rust-owned official and localized release history summary: Rust-owned official and localized release history
@@ -370,8 +370,11 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
- 只读提供 Rust `bat` 已发布 release 中的资源字节(官方 CDN host/path 形态)。 - 只读提供 Rust `bat` 已发布 release 中的资源字节(官方 CDN host/path 形态)。
- CDN path 支持 `GET` / `HEAD` / Range / 条件请求;ETag 优先使用 download - CDN path 支持 `GET` / `HEAD` / Range / 条件请求;ETag 优先使用 download
manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。 manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。
- 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再 - 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再读取
`catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket 文件)。 Rust `release.status` 的 whole-release `official_distribution_ready`,最后
读取 `catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket 文件)。
Go 不重新实现 release verifier;普通 current CDN 只有在该 Rust health fact
和本地只读 manifest 快照都允许时才分发。
- `/v1/releases``/v1/distribution` 和受保护的 `/admin/releases/status` - `/v1/releases``/v1/distribution` 和受保护的 `/admin/releases/status`
`/admin/releases` 只转发 `release.status/list/distribution` 的 Rust typed `/admin/releases` 只转发 `release.status/list/distribution` 的 Rust typed
结果;localized 或历史分发不会绕过 Rust 完整性判断。 结果;localized 或历史分发不会绕过 Rust 完整性判断。
+6 -3
View File
@@ -382,9 +382,12 @@ Addressables 改写后客户端拼接:
1. `daemon.status` 1. `daemon.status`
2. `daemon.doctor` 2. `daemon.doctor`
3. `catalog.status``version.resource_root``addressables_root`、app/bundle 3. `release.status`,消费 Rust 返回的当前 official `official_distribution_ready`
4. `resource.manifest` 分页(url / destination / bytes / blake3 及其 `status` / `status_code` / integrity 事实
5.`resource_root` 上 Lstat 校验 Present / size 4. `catalog.status``version.resource_root``addressables_root`、app/bundle
5. `resource.manifest` 分页(url / destination / bytes / blake3
6.`resource_root` 上 Lstat 校验 Present / size;该检查只验证 Go 读快照,
不替代 Rust release verifier
**不读** `bat-status.json` / `bat-tasks.json` 作为常规路径。 **不读** `bat-status.json` / `bat-tasks.json` 作为常规路径。
+13 -12
View File
@@ -23,29 +23,30 @@
## 2. 当前验证命令 ## 2. 当前验证命令
必须通过: 提交前的只读统一门禁必须通过:
```bash ```bash
make test make ci-check
make check
make lint
``` ```
等价底层命令 该命令等价覆盖
```bash ```bash
cargo test --workspace cargo fmt --all -- --check
cargo check --workspace cargo check --workspace --locked
cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace --all-targets --locked -- -D warnings
go test ./internal/api/... ./internal/backendrpc/... cargo test --workspace --locked
go vet ./... go test ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
go build -o /tmp/bat-api ./cmd/bat-api
make check-docs
``` ```
说明: 说明:
1. 默认 Go 测试只覆盖正式 `bat-api` 依赖的纯 Go 包:`internal/api``internal/backendrpc``make test-go-ffi` / `make test-go-all` 才会包含 FFI 和试验 CLI。 1. 默认 Go 测试只覆盖正式 `bat-api` 依赖的纯 Go 包:`internal/api``internal/backendrpc``make test-go-ffi` / `make test-go-all` 才会包含 FFI 和试验 CLI。
2. `make check` 当前直接执行 `go vet ./...`,因此会检查所有已存在的 Go 包;新增 Go 产品 package 后,必须同时纳入默认测试门禁 2. `golangci-lint` 当前仍是 optional gate;命令可用时执行并失败即阻断,不可用时明确输出 skipped 及原因
3. `golangci-lint` 当前仍是可选补充门禁;Go 的硬性验证是默认 API 测试、全量 `go vet``bat-api` 构建 3. `make format` / `make fmt` 会修改工作树;`make ci-check``make check``make test``make lint` 不应格式化源码
4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture` 4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`
如果构建环境的默认 Go cache 不可写,可将 `GOCACHE` 指向工作区外的临时目录,例如 如果构建环境的默认 Go cache 不可写,可将 `GOCACHE` 指向工作区外的临时目录,例如
+10 -16
View File
@@ -36,7 +36,7 @@ cargo clippy --version
go version go version
``` ```
该 workflow 会用 `GITHUB_SERVER_URL``GITHUB_REPOSITORY``GITHUB_REF``GITHUB_SHA` 手动 `git fetch` 当前提交,再执行 Rust workspace 的格式化、检查、构建、clippy 和测试,以及 Go API 门禁和文档状态门禁。这样可以避免自托管 runner 在准备阶段通过代理克隆第三方 action 仓库。 该 workflow 会用 `GITHUB_SERVER_URL``GITHUB_REPOSITORY``GITHUB_REF``GITHUB_SHA` 手动 `git fetch` 当前提交,再执行 Rust workspace 的只读格式检查、检查、构建、clippy 和测试,以及 Go API 格式、测试、vet、构建、可选 lint 和文档状态门禁。这样可以避免自托管 runner 在准备阶段通过代理克隆第三方 action 仓库。
#### Docker #### Docker
```bash ```bash
@@ -66,14 +66,11 @@ git checkout -b feature/your-feature-name
### 2. 开发 ### 2. 开发
```bash ```bash
# 实时编译检查 # 运行只读 required 门禁(不会格式化或修改源码)
make check make ci-check
# 运行测试 # 需要格式化时才修改工作树
make test make format
# 格式化代码
make fmt
``` ```
开发约束: 开发约束:
@@ -143,20 +140,17 @@ UnityFS / AssetBundle / Addressables / TypeTree 解析当前按路线图继续
### 合并前通用门禁 ### 合并前通用门禁
```bash ```bash
cargo fmt --all -- --check make ci-check
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
make test-go-api
make build-go-api
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
make check-docs
``` ```
`make ci-check` 是只读门禁入口;`make format` / `make fmt` 才会修改源码。缺少
`golangci-lint` 时,Go lint 作为 optional gate 明确输出 skipped,不会伪报全部门禁通过。
Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准: Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准:
- **同步/运维命令行** = Rust `bat`(近乎全自动) - **同步/运维命令行** = Rust `bat`(近乎全自动)
- **资源 bootstrap/分发服务与内嵌 dashboard** = `cmd/bat-api``make build-go-api` - **资源 bootstrap/分发服务与内嵌 dashboard** = `cmd/bat-api``make build-go-api`
- **默认 Go 门禁** = `make test-go-api`(无 FFI - **默认 Go 门禁** = `make ci-check` 中的纯 Go API test/vet/build(无 FFI
- 试验 CLI 产物为 `bin/bat-go``make build-go-cli`),**禁止**与 Rust `bat` 重名 - 试验 CLI 产物为 `bin/bat-go``make build-go-cli`),**禁止**与 Rust `bat` 重名
- 修改 FFI 时再跑 `make test-go-ffi` - 修改 FFI 时再跑 `make test-go-ffi`
+3 -2
View File
@@ -176,7 +176,7 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
| 方法 | 状态 | params | data | | 方法 | 状态 | params | data |
|---|---|---|---| |---|---|---|---|
| `release.status` | 已实现 | `null` | official/localized current、source relation、match、历史 release 和 manifest/artifact/distribution integrity 统一视图。 | | `release.status` | 已实现 | `null` | official/localized current、source relation、match、历史 release 和 manifest/artifact/distribution integrity 统一视图`official_distribution_ready` 是 bat-api 普通 current release readiness/distribution 使用的 Rust-owned whole-release health fact。 |
| `release.list` | 已实现 | `{ "channel": "official" }``{ "channel": "localized" }`,可省略 | 对应 namespace 的历史 release 摘要,包含 stable ID、created/published、current pointer、`rollback_available`、lifecycle、`stale`/`damaged`/`referenced`/`unknown`、legacy 和诊断。 | | `release.list` | 已实现 | `{ "channel": "official" }``{ "channel": "localized" }`,可省略 | 对应 namespace 的历史 release 摘要,包含 stable ID、created/published、current pointer、`rollback_available`、lifecycle、`stale`/`damaged`/`referenced`/`unknown`、legacy 和诊断。 |
| `release.distribution` | 已实现 | `{ "channel": "official", "release_id": "...", "destination": "...", "offset": 0, "limit": 1000 }`,均可省略 | Rust 只选择具有独立 `official-distribution-publication.json` 且 identity 与当前 manifest 一致的 verified `resource_root`;有 `destination` 时是 single-entry lookup,响应固定 `total=1, offset=0, limit=1, entries.length=1`,使用 published mapping identity/destination index,只校验该实际文件的 bytes/BLAKE3,不重新执行全量映射或资源 audit;无 `destination` 时保留管理查询分页语义。localized 还必须匹配 source official 的 published identity,并使用发布时生成的实际字节 metadata,不复用 official size/hash;默认 channel 为 official,选择失败返回 `available=false`,不跨 channel fallback。 | | `release.distribution` | 已实现 | `{ "channel": "official", "release_id": "...", "destination": "...", "offset": 0, "limit": 1000 }`,均可省略 | Rust 只选择具有独立 `official-distribution-publication.json` 且 identity 与当前 manifest 一致的 verified `resource_root`;有 `destination` 时是 single-entry lookup,响应固定 `total=1, offset=0, limit=1, entries.length=1`,使用 published mapping identity/destination index,只校验该实际文件的 bytes/BLAKE3,不重新执行全量映射或资源 audit;无 `destination` 时保留管理查询分页语义。localized 还必须匹配 source official 的 published identity,并使用发布时生成的实际字节 metadata,不复用 official size/hash;默认 channel 为 official,选择失败返回 `available=false`,不跨 channel fallback。 |
| `release.cleanup` | 已实现 | dry-run `{ "execute": false }`;执行 `{ "execute": true, "plan_id": "..." }` | cleanup plan、candidate/retain reasons、blocking references 和 removed paths;执行前会重新生成并比对 `plan_id`。 | | `release.cleanup` | 已实现 | dry-run `{ "execute": false }`;执行 `{ "execute": true, "plan_id": "..." }` | cleanup plan、candidate/retain reasons、blocking references 和 removed paths;执行前会重新生成并比对 `plan_id`。 |
@@ -565,7 +565,8 @@ CLI 对应关系:
| Go 接口 | 允许调用的 RPC | 用途 | | Go 接口 | 允许调用的 RPC | 用途 |
|---|---|---| |---|---|---|
| `Backend` | `daemon.status``daemon.doctor``resource.state``catalog.status``resource.manifest` | 启动发现、周期刷新和资源分发 | | `Backend` | `daemon.status``daemon.doctor``resource.state``catalog.status``resource.manifest` | 启动发现、周期刷新和资源分发基础数据 |
| `ReleaseStatusBackend` | `release.status` | 启动发现和周期刷新使用 Rust-owned whole-release distribution healthGo 不重新实现 verifier |
| `ControlBackend` | `daemon.restart``daemon.reload``daemon.refresh``resource.sync``resource.verify``resource.repair``catalog.refresh` | 鉴权后的管理控制白名单 | | `ControlBackend` | `daemon.restart``daemon.reload``daemon.refresh``resource.sync``resource.verify``resource.repair``catalog.refresh` | 鉴权后的管理控制白名单 |
| `ScheduleBackend` | `schedule.list``schedule.add``schedule.update``schedule.remove``schedule.run` | 鉴权后的 dashboard 调度计划控制 | | `ScheduleBackend` | `schedule.list``schedule.add``schedule.update``schedule.remove``schedule.run` | 鉴权后的 dashboard 调度计划控制 |
| `DaemonLogsBackend` | `daemon.logs` | 鉴权后的 daemon 日志尾部查询 | | `DaemonLogsBackend` | `daemon.logs` | 鉴权后的 daemon 日志尾部查询 |
+4
View File
@@ -75,6 +75,10 @@ provider、bundle name、resource type 和 CRC,并有 fixture/golden 回归。
launcher 资源引导兼容、只读 CDN path、readiness、OpenAPI、鉴权管理入口和内嵌 launcher 资源引导兼容、只读 CDN path、readiness、OpenAPI、鉴权管理入口和内嵌
dashboard;翻译任务和 Rust-owned TM 的 summary/query/confirm 也通过 typed RPC dashboard;翻译任务和 Rust-owned TM 的 summary/query/confirm 也通过 typed RPC
转发。Rust `bat` 继续拥有资源发现、下载、校验、staging、发布、任务和长期状态。 转发。Rust `bat` 继续拥有资源发现、下载、校验、staging、发布、任务和长期状态。
普通 current release 的 readiness、bootstrap、release summary 和 CDN 共用
Rust `release.status.official_distribution_ready` whole-release health factGo
只建立 manifest 的读快照,不复制 Rust verifier。`make ci-check` 是只读统一门禁,
缺少 `golangci-lint` 时明确报告 optional skipped。
仍需完成: 仍需完成:
+5 -8
View File
@@ -61,11 +61,11 @@
| ID | 约定 | | ID | 约定 |
|---|---| |---|---|
| F | 版本/清单经 **`bat.sock` JSON-RPC**`--socket`);不读 daemon 内部状态文件 | | F | 版本/清单经 **`bat.sock` JSON-RPC**`--socket`);不读 daemon 内部状态文件 |
| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再 catalog/manifest | | G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再 **`release.status`**,再 catalog/manifest |
| H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 | | H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 |
| I | 生产中 Rust `bat``bat-api` 在同一主机/容器/共享文件系统;开发用 `/tmp` fixture 和真实本地 `bat.sock` smoke,不依赖远程连接 | | I | 生产中 Rust `bat``bat-api` 在同一主机/容器/共享文件系统;开发用 `/tmp` fixture 和真实本地 `bat.sock` smoke,不依赖远程连接 |
| J | 索引以 **manifest + 磁盘 Present/size** 为准 | | J | Go 索引以 **manifest + 磁盘 Present/size** 建立可读快照,但不将其当作 release integrity |
| J2 | RPC 状态以 Rust 返回的 `status` / `status_code` 为准;`bat-api` 只读消费,不自行推导同步状态 | | J2 | RPC 状态以 Rust 返回的 `status` / `status_code` 为准;`release.status.official_distribution_ready` 是普通 current 分发的 whole-release 授权事实,`bat-api` 只读消费,不自行推导 verifier |
### 进程配置 ### 进程配置
@@ -86,7 +86,7 @@
| O | 权威文档与 `go list` 一致,禁止「API 完全没有」等过时句 | | O | 权威文档与 `go list` 一致,禁止「API 完全没有」等过时句 |
| P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` | | P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` |
| Q | 空目录标明 reserved empty | | Q | 空目录标明 reserved empty |
| R | 默认门禁:`make test-go-api` + `make build-go-api` + `make check-docs`(无 FFI | | R | 默认门禁:`make ci-check`;其中 Go 使用纯 API test/vet/build(无 FFI),`golangci-lint` 为明确报告的 optional gate |
--- ---
@@ -116,10 +116,7 @@
```bash ```bash
# 默认(提交前 / CI 建议) # 默认(提交前 / CI 建议)
make test-go-api make ci-check
make build-go-api
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
make check-docs
# 可选:改 FFI 或试验 CLI 时 # 可选:改 FFI 或试验 CLI 时
make build-ffi make build-ffi
+28
View File
@@ -1697,6 +1697,34 @@ mod tests {
assert!(!localized.unknown); assert!(!localized.unknown);
assert_eq!(report.releases.len(), 2); assert_eq!(report.releases.len(), 2);
let other_path = official_version.join("other.bin");
let other_bytes = fs::read(&other_path).unwrap();
let mut same_size_corruption = other_bytes.clone();
same_size_corruption[0] ^= 0xff;
fs::write(&other_path, &same_size_corruption).unwrap();
let same_size_report =
build_release_status(&official_root, &localized_root, Path::new("unzip")).unwrap();
let same_size_official = same_size_report
.releases
.iter()
.find(|release| release.channel == "official" && release.current)
.unwrap();
assert_eq!(same_size_official.distribution_integrity_status, "invalid");
assert!(!same_size_report.official_distribution_ready);
fs::write(&other_path, &other_bytes).unwrap();
fs::write(&other_path, [other_bytes.as_slice(), b"!"].concat()).unwrap();
let size_report =
build_release_status(&official_root, &localized_root, Path::new("unzip")).unwrap();
assert!(!size_report.official_distribution_ready);
fs::write(&other_path, &other_bytes).unwrap();
fs::remove_file(&other_path).unwrap();
let missing_report =
build_release_status(&official_root, &localized_root, Path::new("unzip")).unwrap();
assert!(!missing_report.official_distribution_ready);
fs::write(&other_path, &other_bytes).unwrap();
fs::remove_file(official_version.join(crate::OFFICIAL_DISTRIBUTION_PUBLICATION_FILE)) fs::remove_file(official_version.join(crate::OFFICIAL_DISTRIBUTION_PUBLICATION_FILE))
.unwrap(); .unwrap();
let missing_publication = select_release_distribution( let missing_publication = select_release_distribution(
+297 -17
View File
@@ -32,6 +32,89 @@ func fixtureRoot(t *testing.T) string {
return abs return abs
} }
func copyFixtureRoot(t *testing.T) string {
t.Helper()
source := fixtureRoot(t)
target := filepath.Join(t.TempDir(), "release")
if err := filepath.Walk(source, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(source, path)
if err != nil {
return err
}
destination := filepath.Join(target, rel)
if info.IsDir() {
return os.MkdirAll(destination, info.Mode().Perm())
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(destination, data, info.Mode().Perm())
}); err != nil {
t.Fatal(err)
}
return target
}
func fixtureRPCBackend(t *testing.T, root string) *fakeBackend {
t.Helper()
idx, err := LoadIndexFromResourceRoot(root)
if err != nil {
t.Fatal(err)
}
manifestEntries := make([]backendrpc.ResourceManifestEntry, 0, len(idx.Entries))
for _, entry := range idx.Entries {
size := entry.Bytes
manifestEntries = append(manifestEntries, backendrpc.ResourceManifestEntry{
URL: entry.URL,
Destination: entry.RelativePath,
Bytes: &size,
BLAKE3: entry.BLAKE3,
})
}
catalog, err := json.Marshal(map[string]any{
"available": true,
"status": "published",
"version": map[string]any{
"id": "official-fixture",
"resource_root": root,
},
})
if err != nil {
t.Fatal(err)
}
return &fakeBackend{
status: &backendrpc.DaemonStatusReport{Status: "ok", Running: true, RPCAvailable: true},
doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"},
catalog: catalog,
manifest: &backendrpc.ResourceManifestPage{
Available: true,
ResourceRoot: root,
ManifestVersion: 1,
TotalEntries: len(manifestEntries),
Entries: manifestEntries,
},
releaseStatus: &backendrpc.ReleaseStatusReport{
Status: "ready",
StatusCode: "distribution.ready",
OfficialCurrentReleaseID: "official-fixture",
DefaultDistributionChannel: "official",
OfficialDistributionReady: true,
Releases: []backendrpc.ReleaseSummary{{
Channel: "official",
ID: "official-fixture",
Current: true,
DistributionIntegrityStatus: "valid",
}},
},
}
}
const fixtureCurrentCDNPath = "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"
func TestLoadIndexFromResourceRoot(t *testing.T) { func TestLoadIndexFromResourceRoot(t *testing.T) {
idx, err := LoadIndexFromResourceRoot(fixtureRoot(t)) idx, err := LoadIndexFromResourceRoot(fixtureRoot(t))
if err != nil { if err != nil {
@@ -89,6 +172,188 @@ func TestReleaseSummaryRequiresCompleteManifest(t *testing.T) {
} }
} }
func TestCurrentCDNRequiresRustWholeReleaseHealth(t *testing.T) {
tests := []struct {
name string
mutate func(t *testing.T, root string)
}{
{
name: "missing entry",
mutate: func(t *testing.T, root string) {
t.Helper()
if err := os.Remove(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash")); err != nil {
t.Fatal(err)
}
},
},
{
name: "size mismatch",
mutate: func(t *testing.T, root string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("too-large"), 0o644); err != nil {
t.Fatal(err)
}
},
},
{
name: "same-size corruption",
mutate: func(t *testing.T, root string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("CORRUPTED!"), 0o644); err != nil {
t.Fatal(err)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := copyFixtureRoot(t)
tt.mutate(t, root)
backend := fixtureRPCBackend(t, root)
backend.releaseStatus = &backendrpc.ReleaseStatusReport{
Status: "blocked",
StatusCode: "distribution.blocked",
OfficialCurrentReleaseID: "official-fixture",
DefaultDistributionChannel: "official",
OfficialDistributionReady: false,
Releases: []backendrpc.ReleaseSummary{{
Channel: "official",
ID: "official-fixture",
Current: true,
DistributionIntegrityStatus: "invalid",
Diagnostics: []string{"fixture integrity failure"},
}},
}
cfg := DefaultConfig()
cfg.RequireIndexed = false
cfg.RefreshInterval = 0
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
server := NewServer(cfg, backend, log.New(io.Discard, "", 0))
if err := server.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
summary := server.index().Summary()
if summary.Ready || summary.Distribution.Ready ||
summary.Distribution.StatusCode != "distribution.blocked" {
t.Fatalf("summary=%+v", summary)
}
for _, path := range []string{"/readyz", "/v1/bootstrap"} {
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("%s status=%d body=%s", path, recorder.Code, recorder.Body.String())
}
}
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, fixtureCurrentCDNPath, nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("current CDN status=%d body=%s", recorder.Code, recorder.Body.String())
}
if recorder.Header().Get("ETag") != "" || recorder.Header().Get("Cache-Control") != "" {
t.Fatalf("unhealthy CDN headers=%v", recorder.Header())
}
})
}
}
func TestRefreshCurrentReleaseHealthTransitionsAndClearsFailure(t *testing.T) {
root := copyFixtureRoot(t)
backend := fixtureRPCBackend(t, root)
cfg := DefaultConfig()
cfg.RefreshInterval = 0
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
server := NewServer(cfg, backend, log.New(io.Discard, "", 0))
if err := server.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
if !server.index().Summary().Ready {
t.Fatal("initial release is not ready")
}
get := func() *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, fixtureCurrentCDNPath, nil))
return recorder
}
if recorder := get(); recorder.Code != http.StatusOK || recorder.Body.String() != "TABLE_CATALOG_FIXTURE" {
t.Fatalf("healthy CDN status=%d body=%q", recorder.Code, recorder.Body.String())
}
// The bytes stay the same size, but Rust's next release.status result
// revokes whole-release distribution authorization.
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("CORRUPTED!"), 0o644); err != nil {
t.Fatal(err)
}
backend.releaseStatus = &backendrpc.ReleaseStatusReport{
Status: "blocked",
StatusCode: "distribution.blocked",
OfficialCurrentReleaseID: "official-fixture",
DefaultDistributionChannel: "official",
Releases: []backendrpc.ReleaseSummary{{
Channel: "official",
ID: "official-fixture",
Current: true,
DistributionIntegrityStatus: "invalid",
}},
}
if err := server.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
if server.index().Summary().Ready || get().Code != http.StatusServiceUnavailable {
t.Fatalf("unhealthy refresh summary=%+v", server.index().Summary())
}
backend.releaseStatusErr = errors.New("release.status transport failure")
if err := server.Refresh(context.Background()); err == nil {
t.Fatal("expected refresh failure")
}
if summary := server.index().Summary(); summary.Ready || summary.ResourceRoot != "" {
t.Fatalf("failed refresh retained snapshot=%+v", summary)
}
healthRecorder := httptest.NewRecorder()
server.Handler().ServeHTTP(healthRecorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
var health map[string]any
if err := json.Unmarshal(healthRecorder.Body.Bytes(), &health); err != nil {
t.Fatal(err)
}
refresh := health["refresh"].(map[string]any)
if refresh["last_error"] == "" {
t.Fatalf("refresh diagnostics=%v", refresh)
}
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("1234567890"), 0o644); err != nil {
t.Fatal(err)
}
backend.releaseStatusErr = nil
backend.releaseStatus = &backendrpc.ReleaseStatusReport{
Status: "ready",
StatusCode: "distribution.ready",
OfficialCurrentReleaseID: "official-fixture",
DefaultDistributionChannel: "official",
OfficialDistributionReady: true,
Releases: []backendrpc.ReleaseSummary{{
Channel: "official",
ID: "official-fixture",
Current: true,
DistributionIntegrityStatus: "valid",
}},
}
if err := server.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
if summary := server.index().Summary(); !summary.Ready || !summary.Distribution.Ready {
t.Fatalf("recovered summary=%+v", summary)
}
if recorder := get(); recorder.Code != http.StatusOK || recorder.Body.String() != "TABLE_CATALOG_FIXTURE" {
t.Fatalf("recovered CDN status=%d body=%q", recorder.Code, recorder.Body.String())
}
}
func TestSplitCDNPathRejectsEscape(t *testing.T) { func TestSplitCDNPathRejectsEscape(t *testing.T) {
if _, _, err := SplitCDNPath("/prod-clientpatch.bluearchiveyostar.com/../etc/passwd"); err == nil { if _, _, err := SplitCDNPath("/prod-clientpatch.bluearchiveyostar.com/../etc/passwd"); err == nil {
t.Fatal("expected error") t.Fatal("expected error")
@@ -553,18 +818,21 @@ func TestServerInfoRewritesAddressablesOnly(t *testing.T) {
} }
type fakeBackend struct { type fakeBackend struct {
statusCalls int statusCalls int
doctorCalls int doctorCalls int
status *backendrpc.DaemonStatusReport releaseStatusCalls int
doctor *backendrpc.DoctorReport status *backendrpc.DaemonStatusReport
catalog json.RawMessage doctor *backendrpc.DoctorReport
resource *backendrpc.ResourceState releaseStatus *backendrpc.ReleaseStatusReport
manifest *backendrpc.ResourceManifestPage releaseStatusErr error
daemonLogs *backendrpc.LogsReport catalog json.RawMessage
taskList *backendrpc.TaskList resource *backendrpc.ResourceState
taskStatus *backendrpc.TaskRecord manifest *backendrpc.ResourceManifestPage
taskLogs *backendrpc.TaskLogs daemonLogs *backendrpc.LogsReport
taskCancel *backendrpc.TaskCancelResult taskList *backendrpc.TaskList
taskStatus *backendrpc.TaskRecord
taskLogs *backendrpc.TaskLogs
taskCancel *backendrpc.TaskCancelResult
} }
func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) { func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) {
@@ -573,11 +841,23 @@ func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatu
} }
func (f *fakeBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) { func (f *fakeBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) {
f.doctorCalls++ f.doctorCalls++
if f.statusCalls == 0 {
// status must be called first in real DiscoverAndIndex; this is asserted by call order.
}
return f.doctor, nil return f.doctor, nil
} }
func (f *fakeBackend) ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error) {
f.releaseStatusCalls++
if f.releaseStatusErr != nil {
return nil, f.releaseStatusErr
}
if f.releaseStatus != nil {
return f.releaseStatus, nil
}
return &backendrpc.ReleaseStatusReport{
Status: "ready",
StatusCode: "distribution.ready",
DefaultDistributionChannel: "official",
OfficialDistributionReady: true,
}, nil
}
func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) { func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
if f.resource != nil { if f.resource != nil {
return f.resource, nil return f.resource, nil
@@ -1243,8 +1523,8 @@ func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if fb.statusCalls != 1 || fb.doctorCalls != 1 { if fb.statusCalls != 1 || fb.doctorCalls != 1 || fb.releaseStatusCalls != 1 {
t.Fatalf("status=%d doctor=%d", fb.statusCalls, fb.doctorCalls) t.Fatalf("status=%d doctor=%d release_status=%d", fb.statusCalls, fb.doctorCalls, fb.releaseStatusCalls)
} }
if !result.RPCAvailable || result.Index == nil || !result.Index.Summary().Ready { if !result.RPCAvailable || result.Index == nil || !result.Index.Summary().Ready {
t.Fatalf("result=%+v summary=%+v", result, result.Index.Summary()) t.Fatalf("result=%+v summary=%+v", result, result.Index.Summary())
+16 -14
View File
@@ -50,16 +50,20 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
http.Error(w, "resource root not ready", http.StatusServiceUnavailable) http.Error(w, "resource root not ready", http.StatusServiceUnavailable)
return return
} }
resourceRoot = idx.ResourceRoot summary := idx.Summary()
if s.cfg.RequireIndexed { if !summary.Ready {
entry, hasEntry = idx.Lookup(rel) // The cached health fact covers the whole current release. A
} // locally present target is not enough to serve it as a healthy
} // immutable artifact.
if explicitRelease || s.cfg.RequireIndexed { http.Error(w, "current release is not distributable", http.StatusServiceUnavailable)
if !hasEntry || !entry.Present || !entry.SizeMatch {
http.NotFound(w, r)
return return
} }
resourceRoot = idx.ResourceRoot
entry, hasEntry = idx.Lookup(rel)
}
if !hasEntry || !entry.Present || !entry.SizeMatch {
http.NotFound(w, r)
return
} }
abs, err := ResolveUnderRoot(resourceRoot, rel, true) abs, err := ResolveUnderRoot(resourceRoot, rel, true)
@@ -76,11 +80,9 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
http.Error(w, "size mismatch with release index", http.StatusConflict) http.Error(w, "size mismatch with release index", http.StatusConflict)
return return
} }
if !explicitRelease && s.cfg.RequireIndexed && s.cfg.VerifySize { if !explicitRelease && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes {
if hasEntry && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes { http.Error(w, "size mismatch with release index", http.StatusConflict)
http.Error(w, "size mismatch with release index", http.StatusConflict) return
return
}
} }
file, err := os.Open(abs) file, err := os.Open(abs)
@@ -88,7 +90,7 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
defer file.Close() defer func() { _ = file.Close() }()
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("ETag", cdnETag(entry, hasEntry, info)) w.Header().Set("ETag", cdnETag(entry, hasEntry, info))
+1
View File
@@ -111,6 +111,7 @@ func (s *Server) launcherBootstrapBody(sum ReleaseSummary) LauncherBootstrapResp
}, },
Resource: LauncherResource{ Resource: LauncherResource{
Release: sum.Snapshot, Release: sum.Snapshot,
Distribution: sum.Distribution,
ServerInfoURL: s.serverInfoURL(), ServerInfoURL: s.serverInfoURL(),
ClientPatchBaseURL: s.clientPatchBaseURL(), ClientPatchBaseURL: s.clientPatchBaseURL(),
}, },
+7 -7
View File
@@ -27,25 +27,25 @@ paths:
summary: Release readiness summary: Release readiness
responses: responses:
"200": "200":
description: A distributable release is available. description: A release authorized by Rust release.status and fully represented by the local read snapshot is available.
"503": "503":
description: No distributable release is available. description: The Rust whole-release distribution health fact or the local read snapshot is not distributable.
/v1/bootstrap: /v1/bootstrap:
get: get:
summary: Startup resource bootstrap summary: Startup resource bootstrap
responses: responses:
"200": "200":
description: Resource bootstrap response. description: Resource bootstrap response with the same distribution health used by readiness and current CDN serving.
"503": "503":
description: Release is not ready. description: The current release is not distributable.
/v1/launcher/bootstrap: /v1/launcher/bootstrap:
get: get:
summary: Launcher-shaped resource bootstrap summary: Launcher-shaped resource bootstrap
responses: responses:
"200": "200":
description: Launcher bootstrap response. description: Launcher bootstrap response with the current release distribution health.
"503": "503":
description: Release is not ready. description: The current release is not distributable.
/api/launcher/game/config: /api/launcher/game/config:
get: get:
summary: Resource-only launcher game config compatibility summary: Resource-only launcher game config compatibility
@@ -78,7 +78,7 @@ paths:
summary: Current release summary summary: Current release summary
responses: responses:
"200": "200":
description: Release summary. description: Release summary including Rust-owned whole-release distribution health.
/v1/releases: /v1/releases:
get: get:
summary: Rust-owned official and localized release history summary: Rust-owned official and localized release history
+68 -23
View File
@@ -55,17 +55,33 @@ type GameMainConfigSummary struct {
DefaultConnectionGroup string `json:"default_connection_group,omitempty"` DefaultConnectionGroup string `json:"default_connection_group,omitempty"`
} }
// DistributionHealth is the release-level authorization used by read paths.
//
// In RPC mode Ready is copied from Rust's release.status
// official_distribution_ready fact. The local manifest checks only establish
// that this process has a usable read snapshot; they do not replace Rust's
// release verifier.
type DistributionHealth struct {
Ready bool `json:"ready"`
Source string `json:"source"`
Status string `json:"status,omitempty"`
StatusCode string `json:"status_code,omitempty"`
IntegrityStatus string `json:"integrity_status,omitempty"`
Diagnostics []string `json:"diagnostics,omitempty"`
}
// ReleaseIndex is the in-memory view of a published resource root. // ReleaseIndex is the in-memory view of a published resource root.
type ReleaseIndex struct { type ReleaseIndex struct {
mu sync.RWMutex mu sync.RWMutex
ResourceRoot string `json:"resource_root"` ResourceRoot string `json:"resource_root"`
Source string `json:"source"` // "rpc" | "resource_root" | "empty" Source string `json:"source"` // "rpc" | "resource_root" | "empty"
RPCAvailable bool `json:"rpc_available"` RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"` DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
Snapshot *SnapshotSummary `json:"snapshot,omitempty"` Distribution DistributionHealth `json:"distribution"`
ManifestVersion int `json:"manifest_version,omitempty"` Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
Entries []ResourceEntry `json:"entries"` ManifestVersion int `json:"manifest_version,omitempty"`
Entries []ResourceEntry `json:"entries"`
// byRel maps relative path (host/path...) to entry index. // byRel maps relative path (host/path...) to entry index.
byRel map[string]int byRel map[string]int
// MissingOnDisk lists relative paths present in the index but absent on disk. // MissingOnDisk lists relative paths present in the index but absent on disk.
@@ -74,16 +90,17 @@ type ReleaseIndex struct {
// Summary returns a JSON-serializable overview without the full entry list. // Summary returns a JSON-serializable overview without the full entry list.
type ReleaseSummary struct { type ReleaseSummary struct {
ResourceRoot string `json:"resource_root"` ResourceRoot string `json:"resource_root"`
Source string `json:"source"` Source string `json:"source"`
RPCAvailable bool `json:"rpc_available"` RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"` DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
Snapshot *SnapshotSummary `json:"snapshot,omitempty"` Distribution DistributionHealth `json:"distribution"`
ManifestVersion int `json:"manifest_version,omitempty"` Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
EntryCount int `json:"entry_count"` ManifestVersion int `json:"manifest_version,omitempty"`
PresentCount int `json:"present_count"` EntryCount int `json:"entry_count"`
MissingCount int `json:"missing_count"` PresentCount int `json:"present_count"`
Ready bool `json:"ready"` MissingCount int `json:"missing_count"`
Ready bool `json:"ready"`
} }
// Summary builds a compact release overview. // Summary builds a compact release overview.
@@ -96,20 +113,32 @@ func (idx *ReleaseIndex) Summary() ReleaseSummary {
present++ present++
} }
} }
distribution := idx.Distribution
distribution.Diagnostics = append([]string(nil), idx.Distribution.Diagnostics...)
localComplete := idx.ResourceRoot != "" && len(idx.Entries) > 0 && present == len(idx.Entries)
// Hand-built indexes are retained for compatibility with local tests and
// diagnostics. Any index explicitly sourced from RPC must carry the Rust
// health fact; an RPC index without it is never considered distributable.
if distribution.Source == "" {
distribution.Ready = localComplete && idx.Source != "rpc" && idx.Source != "rpc+local_manifest"
} else {
distribution.Ready = distribution.Ready && localComplete
}
return ReleaseSummary{ return ReleaseSummary{
ResourceRoot: idx.ResourceRoot, ResourceRoot: idx.ResourceRoot,
Source: idx.Source, Source: idx.Source,
RPCAvailable: idx.RPCAvailable, RPCAvailable: idx.RPCAvailable,
DoctorHealthy: idx.DoctorHealthy, DoctorHealthy: idx.DoctorHealthy,
Distribution: distribution,
Snapshot: idx.Snapshot, Snapshot: idx.Snapshot,
ManifestVersion: idx.ManifestVersion, ManifestVersion: idx.ManifestVersion,
EntryCount: len(idx.Entries), EntryCount: len(idx.Entries),
PresentCount: present, PresentCount: present,
MissingCount: len(idx.MissingOnDisk), MissingCount: len(idx.MissingOnDisk),
// A release is distributable only when every manifest entry is present // A release is distributable only when Rust authorizes it and every
// and has the expected size. Serving a partial release can leave clients // entry in this process's read snapshot is usable. Serving a partial
// with an apparently valid bootstrap and an unrecoverable download set. // release can leave clients with an unrecoverable download set.
Ready: idx.ResourceRoot != "" && len(idx.Entries) > 0 && present == len(idx.Entries), Ready: distribution.Ready,
} }
} }
@@ -158,6 +187,7 @@ func BuildIndexFromManifestEntries(
snapshot *SnapshotSummary, snapshot *SnapshotSummary,
manifestVersion int, manifestVersion int,
entries []manifestEntry, entries []manifestEntry,
distribution DistributionHealth,
) (*ReleaseIndex, error) { ) (*ReleaseIndex, error) {
rootAbs, err := filepath.Abs(resourceRoot) rootAbs, err := filepath.Abs(resourceRoot)
if err != nil { if err != nil {
@@ -168,7 +198,8 @@ func BuildIndexFromManifestEntries(
Source: source, Source: source,
RPCAvailable: rpcAvailable, RPCAvailable: rpcAvailable,
DoctorHealthy: doctorHealthy, DoctorHealthy: doctorHealthy,
Snapshot: snapshot, Distribution: distribution,
Snapshot: snapshotWithDistributionHealth(snapshot, distribution),
ManifestVersion: manifestVersion, ManifestVersion: manifestVersion,
byRel: make(map[string]int), byRel: make(map[string]int),
} }
@@ -258,7 +289,21 @@ func LoadIndexFromResourceRoot(resourceRoot string) (*ReleaseIndex, error) {
} }
} }
} }
return BuildIndexFromManifestEntries(rootAbs, "resource_root", false, nil, snapshot, manifest.Version, entries) return BuildIndexFromManifestEntries(
rootAbs,
"resource_root",
false,
nil,
snapshot,
manifest.Version,
entries,
DistributionHealth{
Ready: true,
Source: "resource_root_override",
Status: "ready",
StatusCode: "resource_root.ready",
},
)
} }
type manifestEntry struct { type manifestEntry struct {
+15 -13
View File
@@ -51,16 +51,17 @@ type BootstrapAPI struct {
} }
type BootstrapResource struct { type BootstrapResource struct {
Release *SnapshotSummary `json:"release,omitempty"` Release *SnapshotSummary `json:"release,omitempty"`
ResourceRoot string `json:"resource_root"` ResourceRoot string `json:"resource_root"`
Source string `json:"source"` Source string `json:"source"`
ManifestVersion int `json:"manifest_version,omitempty"` Distribution DistributionHealth `json:"distribution"`
EntryCount int `json:"entry_count"` ManifestVersion int `json:"manifest_version,omitempty"`
PresentCount int `json:"present_count"` EntryCount int `json:"entry_count"`
MissingCount int `json:"missing_count"` PresentCount int `json:"present_count"`
ServerInfoURL string `json:"server_info_url"` MissingCount int `json:"missing_count"`
ClientPatchBaseURL string `json:"client_patch_base_url"` ServerInfoURL string `json:"server_info_url"`
AddressablesCatalogURLRoot string `json:"addressables_catalog_url_root,omitempty"` ClientPatchBaseURL string `json:"client_patch_base_url"`
AddressablesCatalogURLRoot string `json:"addressables_catalog_url_root,omitempty"`
} }
type BootstrapPolicy struct { type BootstrapPolicy struct {
@@ -113,9 +114,10 @@ type LauncherPolicy struct {
} }
type LauncherResource struct { type LauncherResource struct {
Release *SnapshotSummary `json:"release,omitempty"` Release *SnapshotSummary `json:"release,omitempty"`
ServerInfoURL string `json:"server_info_url"` Distribution DistributionHealth `json:"distribution"`
ClientPatchBaseURL string `json:"client_patch_base_url"` ServerInfoURL string `json:"server_info_url"`
ClientPatchBaseURL string `json:"client_patch_base_url"`
} }
type LauncherEndpointSet struct { type LauncherEndpointSet struct {
+152 -28
View File
@@ -113,10 +113,17 @@ type LocalizedBackend interface {
LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error) LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error)
} }
// ReleaseStatusBackend exposes the Rust-owned release health fact used during
// discovery. It is kept separate so lightweight test/diagnostic backends do
// not have to implement the administrative release surface.
type ReleaseStatusBackend interface {
ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error)
}
// ReleaseBackend exposes Rust-owned dual-release queries, distribution // ReleaseBackend exposes Rust-owned dual-release queries, distribution
// selection and the explicit cleanup operation. // selection and the explicit cleanup operation.
type ReleaseBackend interface { type ReleaseBackend interface {
ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error) ReleaseStatusBackend
ReleaseList(ctx context.Context, params backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) ReleaseList(ctx context.Context, params backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error)
ReleaseDistribution(ctx context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) ReleaseDistribution(ctx context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error)
ReleaseCleanup(ctx context.Context, params backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) ReleaseCleanup(ctx context.Context, params backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error)
@@ -303,14 +310,18 @@ type DiscoverResult struct {
DoctorHealthy *bool DoctorHealthy *bool
Status *backendrpc.DaemonStatusReport Status *backendrpc.DaemonStatusReport
Doctor *backendrpc.DoctorReport Doctor *backendrpc.DoctorReport
ReleaseStatus *backendrpc.ReleaseStatusReport
Distribution DistributionHealth
Snapshot *SnapshotSummary Snapshot *SnapshotSummary
ResourceRoot string ResourceRoot string
Index *ReleaseIndex Index *ReleaseIndex
Warnings []string Warnings []string
} }
// DiscoverAndIndex contacts the daemon (status first, then doctor) and builds // DiscoverAndIndex contacts the daemon (status first, then doctor, then
// a release index from paginated resource.manifest plus on-disk checks. // release.status) and builds a release index from paginated resource.manifest
// plus on-disk checks. Rust's release.status is the only release-level
// integrity authorization used for the production RPC path.
// //
// If resourceRootOverride is non-empty, it wins over RPC-reported roots after // If resourceRootOverride is non-empty, it wins over RPC-reported roots after
// RPC health probes (still preferred for production to call status/doctor). // RPC health probes (still preferred for production to call status/doctor).
@@ -326,6 +337,7 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
return nil, err return nil, err
} }
out.ResourceRoot = idx.ResourceRoot out.ResourceRoot = idx.ResourceRoot
out.Distribution = idx.Distribution
out.Index = idx out.Index = idx
return out, nil return out, nil
} }
@@ -340,6 +352,7 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
return out, fmt.Errorf("daemon.status failed (%v) and resource-root load failed: %w", err, loadErr) return out, fmt.Errorf("daemon.status failed (%v) and resource-root load failed: %w", err, loadErr)
} }
out.ResourceRoot = idx.ResourceRoot out.ResourceRoot = idx.ResourceRoot
out.Distribution = idx.Distribution
out.Index = idx out.Index = idx
return out, nil return out, nil
} }
@@ -359,6 +372,45 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
out.DoctorHealthy = &h out.DoctorHealthy = &h
} }
// An explicit root is a fixture/emergency read-only override. Keep it
// outside the production RPC release-health contract, while still probing
// daemon status and doctor first.
if resourceRootOverride != "" {
idx, loadErr := LoadIndexFromResourceRoot(resourceRootOverride)
if loadErr != nil {
return out, fmt.Errorf("resource-root override load failed: %w", loadErr)
}
idx.Source = "resource_root"
idx.RPCAvailable = true
idx.DoctorHealthy = out.DoctorHealthy
out.ResourceRoot = idx.ResourceRoot
out.Snapshot = idx.Snapshot
out.Distribution = idx.Distribution
out.Index = idx
return out, nil
}
// 3) release.status is the Rust-owned whole-release distribution gate.
releaseStatusBackend, ok := backend.(ReleaseStatusBackend)
if !ok {
out.Warnings = append(out.Warnings, "release.status: backend does not expose Rust release health")
return emptyRPCResult(out, nil, "Rust release health is unavailable"),
fmt.Errorf("rust release health is unavailable")
}
releaseStatus, err := releaseStatusBackend.ReleaseStatus(ctx)
if err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("release.status: %v", err))
return emptyRPCResult(out, nil, "Rust release health query failed"),
fmt.Errorf("release.status failed: %w", err)
}
if releaseStatus == nil {
out.Warnings = append(out.Warnings, "release.status: empty response")
return emptyRPCResult(out, nil, "Rust release health query returned no response"),
fmt.Errorf("release.status returned an empty response")
}
out.ReleaseStatus = releaseStatus
out.Distribution = rustDistributionHealth(releaseStatus)
// Catalog / resource discovery // Catalog / resource discovery
var snapshot *SnapshotSummary var snapshot *SnapshotSummary
var resourceRoot string var resourceRoot string
@@ -375,6 +427,20 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
resourceRoot = root resourceRoot = root
} }
} }
if snapshot != nil &&
snapshot.VersionID != "" &&
out.ReleaseStatus.OfficialCurrentReleaseID != "" &&
snapshot.VersionID != out.ReleaseStatus.OfficialCurrentReleaseID {
return emptyRPCResult(
out,
snapshot,
fmt.Sprintf(
"release.status current ID %q does not match catalog current ID %q",
out.ReleaseStatus.OfficialCurrentReleaseID,
snapshot.VersionID,
),
), nil
}
if catalogAvailabilityKnown && !catalogAvailable { if catalogAvailabilityKnown && !catalogAvailable {
return emptyRPCResult(out, snapshot, "catalog.status available=false; no published release"), nil return emptyRPCResult(out, snapshot, "catalog.status available=false; no published release"), nil
} }
@@ -393,12 +459,13 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
resourceRoot = resourceRootOverride resourceRoot = resourceRootOverride
} }
if resourceRoot == "" { if resourceRoot == "" {
out.Snapshot = snapshot out.Snapshot = snapshotWithDistributionHealth(snapshot, out.Distribution)
out.Index = &ReleaseIndex{ out.Index = &ReleaseIndex{
Source: "rpc", Source: "rpc",
RPCAvailable: true, RPCAvailable: true,
DoctorHealthy: out.DoctorHealthy, DoctorHealthy: out.DoctorHealthy,
Snapshot: snapshot, Distribution: out.Distribution,
Snapshot: snapshotWithDistributionHealth(snapshot, out.Distribution),
byRel: map[string]int{}, byRel: map[string]int{},
} }
out.Warnings = append(out.Warnings, "no resource root from RPC; set --resource-root or publish a version") out.Warnings = append(out.Warnings, "no resource root from RPC; set --resource-root or publish a version")
@@ -421,28 +488,14 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend) entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend)
if err != nil { if err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err)) out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err))
// Fallback: load local manifest file under root. // Without the RPC manifest there is no evidence that the local
idx, loadErr := LoadIndexFromResourceRoot(resourceRoot) // snapshot matches the Rust health fact. Do not pair a fresh health
if loadErr != nil { // result with a potentially stale on-disk manifest.
if resourceRootOverride == "" { return emptyRPCResult(
return emptyRPCResult( out,
out, snapshot,
snapshot, fmt.Sprintf("published release cannot be indexed: %v", err),
fmt.Sprintf("published release cannot be indexed: %v", loadErr), ), nil
), nil
}
return out, fmt.Errorf("manifest RPC and local load failed: rpc=%v local=%w", err, loadErr)
}
idx.Source = "rpc+local_manifest"
idx.RPCAvailable = true
idx.DoctorHealthy = out.DoctorHealthy
if snapshot != nil {
idx.Snapshot = snapshot
}
out.ResourceRoot = idx.ResourceRoot
out.Snapshot = idx.Snapshot
out.Index = idx
return out, nil
} }
if rootFromManifest != "" { if rootFromManifest != "" {
resourceRoot = rootFromManifest resourceRoot = rootFromManifest
@@ -459,6 +512,7 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
snapshot, snapshot,
manifestVersion, manifestVersion,
entries, entries,
out.Distribution,
) )
if err != nil { if err != nil {
if resourceRootOverride == "" { if resourceRootOverride == "" {
@@ -471,18 +525,25 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
return out, err return out, err
} }
out.ResourceRoot = idx.ResourceRoot out.ResourceRoot = idx.ResourceRoot
out.Snapshot = snapshot out.Snapshot = idx.Snapshot
out.Index = idx out.Index = idx
return out, nil return out, nil
} }
func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning string) *DiscoverResult { func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning string) *DiscoverResult {
distribution := out.Distribution
if distribution.Source == "" {
distribution = unavailableRustDistributionHealth()
out.Distribution = distribution
}
snapshot = snapshotWithDistributionHealth(snapshot, distribution)
out.ResourceRoot = "" out.ResourceRoot = ""
out.Snapshot = snapshot out.Snapshot = snapshot
out.Index = &ReleaseIndex{ out.Index = &ReleaseIndex{
Source: "rpc", Source: "rpc",
RPCAvailable: true, RPCAvailable: true,
DoctorHealthy: out.DoctorHealthy, DoctorHealthy: out.DoctorHealthy,
Distribution: distribution,
Snapshot: snapshot, Snapshot: snapshot,
byRel: map[string]int{}, byRel: map[string]int{},
} }
@@ -492,6 +553,69 @@ func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning stri
return out return out
} }
func rustDistributionHealth(report *backendrpc.ReleaseStatusReport) DistributionHealth {
health := DistributionHealth{
Source: "rust_release_status",
Status: "blocked",
StatusCode: "distribution.blocked",
IntegrityStatus: "unknown",
}
if report == nil {
return health
}
health.Ready = report.OfficialDistributionReady
health.Status = report.Status
health.StatusCode = report.StatusCode
if health.Status == "" {
if health.Ready {
health.Status = "ready"
} else {
health.Status = "blocked"
}
}
if health.StatusCode == "" {
if health.Ready {
health.StatusCode = "distribution.ready"
} else {
health.StatusCode = "distribution.blocked"
}
}
for _, release := range report.Releases {
if release.Channel == "official" && release.Current {
health.IntegrityStatus = release.DistributionIntegrityStatus
health.Diagnostics = append([]string(nil), release.Diagnostics...)
break
}
}
if health.IntegrityStatus == "valid" && !health.Ready {
health.IntegrityStatus = "invalid"
}
return health
}
func unavailableRustDistributionHealth() DistributionHealth {
return DistributionHealth{
Source: "rust_release_status",
Status: "unavailable",
StatusCode: "distribution.health_unavailable",
IntegrityStatus: "unknown",
}
}
func snapshotWithDistributionHealth(snapshot *SnapshotSummary, health DistributionHealth) *SnapshotSummary {
if snapshot == nil {
return nil
}
updated := *snapshot
if health.Status != "" {
updated.DistributionStatus = health.Status
}
if health.StatusCode != "" {
updated.DistributionStatusCode = health.StatusCode
}
return &updated
}
func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) { func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) {
if len(raw) == 0 || string(raw) == "null" { if len(raw) == 0 || string(raw) == "null" {
return nil, "", false return nil, "", false
+45 -10
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
@@ -18,9 +19,10 @@ type Server struct {
logger *log.Logger logger *log.Logger
limiter *tokenBucketLimiter limiter *tokenBucketLimiter
mu sync.RWMutex refreshMu sync.Mutex
idx *ReleaseIndex mu sync.RWMutex
meta DiscoverResult idx *ReleaseIndex
meta DiscoverResult
refreshInProgress bool refreshInProgress bool
lastRefreshStart time.Time lastRefreshStart time.Time
@@ -95,10 +97,46 @@ func (s *Server) Handler() http.Handler {
// Refresh rebuilds the release index via RPC (and optional resource-root override). // Refresh rebuilds the release index via RPC (and optional resource-root override).
func (s *Server) Refresh(ctx context.Context) error { func (s *Server) Refresh(ctx context.Context) error {
// Serialize refreshes so an older, slower RPC response cannot replace a
// newer snapshot and so refresh diagnostics describe one attempt at a time.
s.refreshMu.Lock()
defer s.refreshMu.Unlock()
started := s.beginRefresh() started := s.beginRefresh()
result, err := DiscoverAndIndex(ctx, s.backend, s.cfg.ResourceRoot) result, err := DiscoverAndIndex(ctx, s.backend, s.cfg.ResourceRoot)
if err != nil { if err != nil {
s.finishRefresh(started, err, nil) s.mu.Lock()
warnings := []string(nil)
if result != nil {
s.meta = *result
warnings = append(warnings, result.Warnings...)
} else {
s.meta = DiscoverResult{}
}
warnings = append(warnings, fmt.Sprintf("refresh: %v", err))
s.idx = &ReleaseIndex{
Source: "empty",
Distribution: unavailableRustDistributionHealth(),
byRel: map[string]int{},
}
s.meta.Index = s.idx
s.meta.ResourceRoot = ""
s.meta.Distribution = s.idx.Distribution
s.finishRefreshLocked(started, err, warnings)
s.mu.Unlock()
return err
}
if result == nil || result.Index == nil {
err := fmt.Errorf("refresh returned no release index")
s.mu.Lock()
s.idx = &ReleaseIndex{
Source: "empty",
Distribution: unavailableRustDistributionHealth(),
byRel: map[string]int{},
}
s.meta = DiscoverResult{Index: s.idx, Distribution: s.idx.Distribution}
s.finishRefreshLocked(started, err, []string{err.Error()})
s.mu.Unlock()
return err return err
} }
s.mu.Lock() s.mu.Lock()
@@ -229,6 +267,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
Release: sum.Snapshot, Release: sum.Snapshot,
ResourceRoot: sum.ResourceRoot, ResourceRoot: sum.ResourceRoot,
Source: sum.Source, Source: sum.Source,
Distribution: sum.Distribution,
ManifestVersion: sum.ManifestVersion, ManifestVersion: sum.ManifestVersion,
EntryCount: sum.EntryCount, EntryCount: sum.EntryCount,
PresentCount: sum.PresentCount, PresentCount: sum.PresentCount,
@@ -287,6 +326,7 @@ func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
"missing_count": sum.MissingCount, "missing_count": sum.MissingCount,
"source": sum.Source, "source": sum.Source,
"warnings": meta.Warnings, "warnings": meta.Warnings,
"distribution": sum.Distribution,
// Database/redis are reserved config surface for a normal API process. // Database/redis are reserved config surface for a normal API process.
"database_configured": s.cfg.DatabaseURL != "", "database_configured": s.cfg.DatabaseURL != "",
"redis_configured": s.cfg.RedisURL != "", "redis_configured": s.cfg.RedisURL != "",
@@ -316,6 +356,7 @@ func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
"present_count": sum.PresentCount, "present_count": sum.PresentCount,
"missing_count": sum.MissingCount, "missing_count": sum.MissingCount,
"source": sum.Source, "source": sum.Source,
"distribution": sum.Distribution,
"refresh": s.refreshSnapshot(), "refresh": s.refreshSnapshot(),
} }
if r.Method == http.MethodHead { if r.Method == http.MethodHead {
@@ -430,12 +471,6 @@ func (s *Server) beginRefresh() time.Time {
return now return now
} }
func (s *Server) finishRefresh(started time.Time, err error, warnings []string) {
s.mu.Lock()
defer s.mu.Unlock()
s.finishRefreshLocked(started, err, warnings)
}
func (s *Server) finishRefreshLocked(started time.Time, err error, warnings []string) { func (s *Server) finishRefreshLocked(started time.Time, err error, warnings []string) {
now := time.Now() now := time.Now()
s.refreshInProgress = false s.refreshInProgress = false
+40 -1
View File
@@ -38,7 +38,7 @@ func newTestClient(t *testing.T, handler func(t *testing.T, req testRequest) tes
client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) { client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
clientConn, serverConn := net.Pipe() clientConn, serverConn := net.Pipe()
go func(conn net.Conn) { go func(conn net.Conn) {
defer conn.Close() defer func() { _ = conn.Close() }()
line, err := bufio.NewReader(conn).ReadBytes('\n') line, err := bufio.NewReader(conn).ReadBytes('\n')
if err != nil { if err != nil {
return return
@@ -137,6 +137,45 @@ func TestReleaseDistributionUsesTypedRPCContract(t *testing.T) {
} }
} }
func TestReleaseStatusUsesWholeReleaseHealthFact(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "release.status" {
t.Fatalf("method = %s", req.Method)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
Data: map[string]any{
"status": "blocked",
"status_code": "distribution.blocked",
"default_distribution_channel": "official",
"official_current_release_id": "official-1",
"official_distribution_ready": false,
"releases": []any{map[string]any{
"channel": "official",
"id": "official-1",
"current": true,
"distribution_integrity_status": "invalid",
}},
},
},
}
})
report, err := client.ReleaseStatus(context.Background())
if err != nil {
t.Fatalf("ReleaseStatus error: %v", err)
}
if report.StatusCode != "distribution.blocked" ||
report.OfficialDistributionReady ||
report.OfficialCurrentReleaseID != "official-1" ||
len(report.Releases) != 1 ||
report.Releases[0].DistributionIntegrityStatus != "invalid" {
t.Fatalf("report = %#v", report)
}
}
func TestDaemonRestartSendsControlMethod(t *testing.T) { func TestDaemonRestartSendsControlMethod(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "daemon.restart" { if req.Method != "daemon.restart" {
+13
View File
@@ -141,12 +141,25 @@ require_contains "docs/reports/CURRENT_GAPS.md" "daemon.clean-stable"
require_contains "docs/reference/rpc-backend-api.md" "daemon.restart" require_contains "docs/reference/rpc-backend-api.md" "daemon.restart"
require_contains "docs/reference/rpc-backend-api.md" "daemon.clean-stable" require_contains "docs/reference/rpc-backend-api.md" "daemon.clean-stable"
require_contains "docs/reference/rpc-backend-api.md" "localized_release_status" require_contains "docs/reference/rpc-backend-api.md" "localized_release_status"
require_contains "docs/reference/rpc-backend-api.md" "official_distribution_ready"
require_contains "docs/architecture/resource-release-layout.md" "release.status"
require_contains "Makefile" "check-docs:" require_contains "Makefile" "check-docs:"
require_contains "Makefile" "format: fmt"
require_contains "Makefile" "ci-check:"
require_contains "Makefile" "ci: ci-check"
require_file "scripts/ci-check.sh"
require_contains "scripts/ci-check.sh" "RUN required:"
require_contains "scripts/ci-check.sh" "SKIP optional:"
if grep -Fq "ci: fmt" Makefile; then
fail "Makefile ci target must not run the mutating fmt target"
fi
require_contains ".gitea/workflows/bat.yml" "make check-docs" require_contains ".gitea/workflows/bat.yml" "make check-docs"
require_contains ".gitea/workflows/bat.yml" "make test-go-api" require_contains ".gitea/workflows/bat.yml" "make test-go-api"
require_contains ".gitea/workflows/bat.yml" "go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/..." require_contains ".gitea/workflows/bat.yml" "go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/..."
require_contains ".gitea/workflows/bat.yml" "go build -o /tmp/bat-api ./cmd/bat-api" require_contains ".gitea/workflows/bat.yml" "go build -o /tmp/bat-api ./cmd/bat-api"
require_contains ".gitea/workflows/bat.yml" "Check Go formatting"
require_contains ".gitea/workflows/bat.yml" "SKIP optional: Go lint"
require_contains "Makefile" "cargo clippy --workspace --all-targets -- -D warnings" require_contains "Makefile" "cargo clippy --workspace --all-targets -- -D warnings"
openapi_tmp="$(mktemp)" openapi_tmp="$(mktemp)"
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
: "${GOCACHE:=/tmp/bat-go-cache}"
export GOCACHE
: "${XDG_CACHE_HOME:=/tmp/bat-xdg-cache}"
export XDG_CACHE_HOME
run_required() {
local name="$1"
shift
printf 'RUN required: %s\n' "${name}"
"$@"
printf 'PASS required: %s\n' "${name}"
}
check_gofmt() {
local -a go_dirs=()
local -a go_files=()
local dir file formatted dirs_file
dirs_file="$(mktemp)"
if ! go list -f '{{.Dir}}' ./... >"${dirs_file}"; then
rm -f "${dirs_file}"
return 1
fi
mapfile -t go_dirs <"${dirs_file}"
rm -f "${dirs_file}"
for dir in "${go_dirs[@]}"; do
for file in "${dir}"/*.go; do
[[ -f "${file}" ]] && go_files+=("${file}")
done
done
if ((${#go_files[@]} == 0)); then
return
fi
formatted="$(gofmt -l "${go_files[@]}")"
if [[ -n "${formatted}" ]]; then
printf 'gofmt required; files need formatting:\n%s\n' "${formatted}" >&2
return 1
fi
}
run_required "Rust formatting" cargo fmt --all -- --check
run_required "Go formatting" check_gofmt
run_required "Rust check" cargo check --workspace --locked
run_required "Rust release build" cargo build --workspace --release --locked
run_required "Rust clippy" cargo clippy --workspace --all-targets --locked -- -D warnings
run_required "Rust tests" cargo test --workspace --locked
run_required "Go API tests" go test ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
run_required "Go API vet" go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
run_required "Go API build" go build -o /tmp/bat-api ./cmd/bat-api
run_required "Documentation, OpenAPI, and contract checks" make check-docs
optional_failed=0
if command -v golangci-lint >/dev/null 2>&1; then
printf 'RUN optional: Go lint (golangci-lint)\n'
if golangci-lint run ./...; then
printf 'PASS optional: Go lint (golangci-lint)\n'
else
printf 'FAIL optional: Go lint (golangci-lint); reason=lint findings\n' >&2
optional_failed=1
fi
else
printf 'SKIP optional: Go lint (golangci-lint); reason=command not installed\n'
fi
if ((optional_failed)); then
printf 'required check-only gates passed; optional gates failed\n' >&2
exit 1
fi
printf 'required check-only gates passed; optional gates are reported above\n'