fix(bat-api): 完成 issue #19 同机 live 联调
bat-rust / Build and test Go API (push) Canceled after 0s
bat-rust / Build and test Rust (push) Canceled after 0s

This commit is contained in:
2026-08-29 22:58:01 +08:00
parent 90083302a2
commit 7f465523e1
22 changed files with 819 additions and 115 deletions
+3 -2
View File
@@ -13,7 +13,8 @@
- 新增 `cmd/bat-api` 资源 bootstrap/分发 HTTP 服务(issue #19 / G-009 资源面):与 Rust `bat` 同环境运行,经 `bat.sock` RPC`daemon.status``daemon.doctor` → catalog/manifest)发现已发布 release 和 `resource_root`,按官方 CDN host/path 只读提供资源;管理面 `/healthz` `/v1/release` `/v1/resources`;可选 server-info 仅改写 Addressables root`.env` 配置监听端口/RPC socket/刷新周期/预留数据库键。资源自动拉取仍由 Rust `bat` 负责
- `cmd/bat-api` 新增 `/v1/bootstrap`:把 Rust `bat` 的 RPC 健康、已发布 release 摘要、server-info URL、client-patch base 和改写后的 Addressables root 组织成启动前资源发现响应,固定 `bat` 是资源生产者、`bat-api` 是只读 bootstrap/分发层的关系
- `bat-api` CDN 分发补齐 Range / HEAD / 条件请求语义:基于 manifest BLAKE3 生成 ETag,返回 Last-Modified、Accept-Ranges 和长期缓存头,`.hash``text/plain` 返回
- `bat-api` 增加 RPC 周期刷新(`BAT_API_REFRESH_INTERVAL` / `--refresh-interval`),用于跟随远程长期运行的 Rust `bat` 发布新 release;生产不应写死 `BAT_API_RESOURCE_ROOT`
- `bat-api` 增加 RPC 周期刷新(`BAT_API_REFRESH_INTERVAL` / `--refresh-interval`),用于跟随同机 Rust `bat` 发布新 release;生产不应写死 `BAT_API_RESOURCE_ROOT`
- `bat-api` 增加同机 live smoke`make bat-api-local-live-smoke`),在隔离 `/tmp` 目录验证真实 `bat.sock`、release 切换、未 ready、恢复和 CDN 读路径;失效 resource root、manifest 不完整和编码 dot-segment 不会继续分发旧索引
- `bat-api` 增加 refresh 诊断和 `/readyz``/healthz` 暴露最近一次 RPC refresh 的时间、耗时、warning 和错误,`/readyz` 在无可分发 release 时返回 `503`
- `bat-api` 增加 launcher 资源引导兼容:`/v1/launcher/bootstrap``/api/launcher/game/config``/api/launcher/game/config/json``/api/launcher/advanced/game/download/cdn``/api-launcher-jp.yo-star.com/...` host 形状入口,响应来自 Rust `bat` 已发布 snapshot/RPC,明确不提供登录、网关、鉴权或完整 package update manifest
- `bat-api` 增加玩家-facing HTTP 控制面:token 鉴权、进程内限流、访问日志、反代 IP 适配、安全响应头、动态 JSON `Cache-Control: no-store``/v1/resources` 分页上限、统一 JSON error、OpenAPI (`/openapi.yaml`) 和 `/admin/` 管理面板预留
@@ -26,7 +27,7 @@
- 官方下载失败重试之间加入指数退避(网络类失败 200ms→400ms→800ms…,上限 5s
### 计划
- [ ] `bat-api` 后续:全量 release 联调、refresh mtime/size 增量缓存、完整 launcher 安装包更新链(若需要,新 issue)、API 持久化层接入预留 database/redis 配置;Rust/Go snapshot contract fixture 已落仓库
- [ ] `bat-api` 后续:refresh mtime/size 增量缓存、完整 launcher 安装包更新链(若需要,新 issue)、API 持久化层接入预留 database/redis 配置;Rust/Go snapshot contract fixture 与同机 live smoke 已完成
- [ ] 官方同步结果接入 CAS + ResourceRepository 的用户级工作流
- [ ] 官方下载/导入路径接入 CRC/size 校验(复用 `verify_downloaded_bytes`
- [ ] 汉化 Patch 发布:维护 `localized-output/current``localized-version-state``localized` 状态切换和回滚
+5 -5
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 当前工作区状态
- **更新时间**2026-08-03
- **更新时间**2026-08-29
- **状态来源**:本地工作区盘点、代码验证和最新提交
- **状态分支**`experiment`
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
@@ -255,7 +255,7 @@ make check-docs
未执行 / 后置:
- 真实官方全量 smoke 长期运行报告(G-018 命令已固化)。
- `bat-api` 对远程长期运行 `bat` / 全量 release 的 SSH 联调(等连接信息)
- `bat-api` 同机 live 联调:已由 `make bat-api-local-live-smoke``/tmp` 隔离目录完成;真实官方网络全量下载仍由 `make official-smoke` 独立跟踪
- WebG-010)。
---
@@ -292,7 +292,7 @@ GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实
下一阶段必须优先完成:
1. Issue #1P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope`ok``status``error``data``request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`status/logs/stop/restart/reload/refresh/doctor)、`resource.*`state/sync/verify/repair/manifest/list/index)、`schedule.*`list/add/update/remove/run)、`parse.*`status/text_units/errors)、`translation.*`tasks/handoff/task.update/proofread)、`localized.*`status)、`catalog.*`status/refresh/diff/versions)、`task.*`status/list/cancel/logs)、文件级 `patch.apply``unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:发布级 `patch build` / `patch rollback`、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFIFFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap``/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理控制白名单、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;仓库内 Rust/Go snapshot contract fixture 已落地,剩余为远程服务器全量 release 联调、refresh mtime/size 增量缓存和可选持久化。
2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap``/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理控制白名单、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;仓库内 Rust/Go snapshot contract fixture 与同机 live smoke 已落地。失效 `resource_root` 或 manifest 不完整时会清空旧索引并使 `/readyz` 返回 `503`;编码 dot-segment 会在路由规范化前拒绝。refresh mtime/size 增量缓存和可选持久化另议
3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json` 已生成;TextUnit 明细、解析错误和离线翻译任务状态/失败原因已可查询,`translation.task.update` 已提供 Rust CLI、Go typed helper 和 bat-api dashboard 状态回写入口,`translation.proofread` 已提供人工校对状态标记入口。剩余为真实 Crowdin/provider worker、翻译记忆和 Patch 构建。
4. Issue #3(已完成当前目标):AssetBundle UnityFS 基础解析校验已覆盖 header、block、directory、LZ4/LZMA、alignment、总大小、计数、路径安全、重复 directory、边界和隔离 UnityPy 真实样本;复杂对象解析与发布级重打包继续跟踪 G-005。
5. Issue #2(已完成当前目标):Addressables JSON/compact catalog 已提取 bundle provider、name、hash/size/CRC、资源类型和依赖关系,并贯通 ResourceEntry、SQLite 与 resource.index 输出;独立二进制格式仍明确拒绝。
@@ -307,7 +307,7 @@ GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实
立即任务:
1. Issue #1 收尾:协议基础设施、最小方法集、`catalog.*``parse.*``localized.*``task.*``resource.repair``daemon.restart`、文件级 `patch.apply` / `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field`、任务持久化、错误码模型与文档均已完成;剩余发布级 `patch build`/`rollback`、复杂 `unityfs.*` 语义编辑以及 `task.create``daemon.clean-stable` 的设计边界确认。
2. `bat-api` 与远程长期运行的 `bat` / 全量 release 联调(含 `/v1/bootstrap``/v1/launcher/bootstrap`、server-info 和 CDN pathissue #19 剩余)
2. 真实官方网络全量下载长期运行报告(`make official-smoke`);issue #19 的同机 live 联调已完成
3. 跟进官方同步长期运行测试报告。
4. AssetBundle / Addressablesissue #3 / #2);CAS 用户级导入(G-011)。
@@ -315,4 +315,4 @@ GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实
- **当前总体完成度**:不再固定写单一百分比,以各模块状态、`GO_STATUS.md` 和 issue 为准。
- **当前基线状态**Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP + 玩家-facing HTTP 控制面 + launcher 资源引导兼容 + RPC 周期刷新/诊断 + readiness + `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、通用 Binary/JSON/Text Patch 基础和 UnityFS TextAsset patch 发布前置可用;完整 AssetBundle 重打包未完成。
- **下一工程里程碑**bat-api 联调、真实 Crowdin/provider worker / 翻译记忆、复杂 AssetBundle 解析和重打包。
- **下一工程里程碑**:真实 Crowdin/provider worker / 翻译记忆、复杂 AssetBundle 解析和重打包`bat-api` 同机 live 联调已完成
+15 -11
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 文档索引
- **更新时间**2026-08-03
- **更新时间**2026-08-29
- **说明**:本索引用于快速定位当前权威文档和历史资料。
---
@@ -14,6 +14,7 @@
- `docs/reports/GO_STATUS.md`:Go 侧边界、约定与组件进度(权威)。
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。
- `docs/guides/bat-api-local-live-smoke.md`:同机 Rust `bat` / Go `bat-api` live socket 联调 runbook。
- `docs/guides/bat-workflows.md`Rust `bat``res` / `parse` / `i18n` 工作流、调度计划和 `bat-api` 调度接口。
- `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。
- `docs/architecture/resource-release-layout.md`release 布局、URL 映射、seed 规则、bat-api 分发契约(资源侧逆向权威)。
@@ -37,6 +38,7 @@
- `deployments/systemd/`:官方资源同步生产 systemd unit 和环境文件示例。
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。
- `docs/guides/bat-api-local-live-smoke.md`:同机 Rust `bat` / Go `bat-api` live socket 联调。
- `docs/guides/baseline.md`:稳定工程基线指南。
- `docs/architecture/adr/0001-engine-and-application-boundaries.md`Rust/Go 边界决策。
- `docs/architecture/adr/0002-cas-v1-design-boundary.md`CAS V1 边界决策。
@@ -83,15 +85,16 @@
2. `PROJECT_PLAN.md`
3. `docs/guides/official-resource-test-pull.md`
4. `docs/guides/official-full-pull-smoke.md`
5. `docs/guides/bat-workflows.md`
6. `docs/architecture/official-resource-backend.md`
7. `docs/reference/rpc-backend-api.md`
8. `docs/reports/CURRENT_GAPS.md`
9. `docs/guides/baseline.md`
10. `docs/architecture/README.md`
11. `docs/guides/development.md`
12. `CONTRIBUTING.md`
13. `AGENTS.md`
5. `docs/guides/bat-api-local-live-smoke.md`
6. `docs/guides/bat-workflows.md`
7. `docs/architecture/official-resource-backend.md`
8. `docs/reference/rpc-backend-api.md`
9. `docs/reports/CURRENT_GAPS.md`
10. `docs/guides/baseline.md`
11. `docs/architecture/README.md`
12. `docs/guides/development.md`
13. `CONTRIBUTING.md`
14. `AGENTS.md`
---
@@ -111,6 +114,7 @@
- `bat-api/internal/backendrpc` typed Unix socket JSON-RPC client。
- `cmd/bat-api` 资源分发 HTTP MVP(进度见 `docs/reports/GO_STATUS.md`)。
- 真实官方网络全量拉取 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`,默认写入 `/tmp` 隔离目录并输出本地运行报告。
- `bat-api` 同机 live smoke 已固化为 `scripts/bat-api-local-live-smoke.sh``make bat-api-local-live-smoke`,覆盖真实 `bat.sock`、release 切换、未 ready、恢复和 CDN 读路径。
- `bat` 运行时 progress log 已覆盖下载已完成计数、单文件下载进度和校验结果摘要。
- Addressables 当前真实形态 fixture/golden 覆盖。
- 解析补全路线图已固化到 `docs/architecture/assetbundle.md`:解析缓存、Addressables、UnityFS、Serialized 字段级解析、文本提取、CAS 接入和 Patch 发布前置。
@@ -118,6 +122,6 @@
优先待办:
- `bat-api`全量 release / 服务器 daemon 联调(issue #19 剩余)。
- `bat-api`完整官方网络长期运行(独立于 issue #19`make official-smoke` 跟踪)。
- 将官方同步结果接入 CAS + ResourceRepository 的用户级流程。
- 推进 AssetBundle UnityFS 引擎级解析。
+5 -1
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 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 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
# 默认目标
.DEFAULT_GOAL := help
@@ -82,6 +82,10 @@ official-smoke: ## 运行真实官方全量拉取 smoke(默认写入 /tmp 隔
@echo "$(BLUE)Running official full pull smoke...$(NC)"
./scripts/official-full-pull-smoke.sh
bat-api-local-live-smoke: ## 在同一临时主机环境联调 Rust bat.sock 与 Go bat-api
@echo "$(BLUE)Running local bat/bat-api live smoke...$(NC)"
./scripts/bat-api-local-live-smoke.sh
# ============================================================================
# 代码质量
# ============================================================================
+4 -4
View File
@@ -384,7 +384,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 5. 推荐执行顺序
近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是资源解析、增量变更集进入文本提取/翻译队列、`bat-api` 与全量 release 联调,以及真实端到端验证
近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是资源解析、增量变更集进入文本提取/翻译队列、真实官方资源长期运行验证,以及 provider worker
建议顺序:
@@ -402,7 +402,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
1. issue #17 已关闭(顺序下载 + 指数退避)。
2. G-008 已决策关闭:同步/运维命令行 = 近乎全自动的 Rust `bat`
3. G-009 / issue #19`bat-api` 资源分发 MVP 已落地;优先服务器联调;拉取仍在 Rust `bat`
3. G-009 / issue #19`bat-api` 资源分发和同机 `bat.sock` live 联调已完成;拉取仍在 Rust `bat`
4. 继续 Addressables 结构变体与 UnityFS 复杂对象能力(issue #3 / G-005 的后续阶段)。
5.`crowdin-textunit-queue.json` 接入真实 Crowdin worker、翻译记忆和 Patch 构建。
6. 继续扩展 G-011 剩余查询面:真实 provider worker 集成与状态落库验证、`doctor cas` 诊断入口和面向大索引的查询优化。
@@ -459,9 +459,9 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
按最终目标计算,当前总体完成度不再固定写单一百分比,以模块状态和 issue 收敛情况为准。
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、通用 Binary/JSON/Text Patch 基础、UnityFS TextAsset patch 发布前置,以及 Go `bat-api` 资源分发 MVP。下一阶段的关键是真实 Crowdin worker、翻译记忆、复杂 AssetBundle 解析/重打包,以及 bat-api 与全量 release 联调
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、通用 Binary/JSON/Text Patch 基础、UnityFS TextAsset patch 发布前置,以及 Go `bat-api` 资源分发和同机 live 联调。下一阶段的关键是真实 Crowdin worker、翻译记忆、复杂 AssetBundle 解析/重打包和官方资源长期运行报告
---
- **下一份应更新文档**:真实官方网络 smoke 记录
- **下一项工程任务**执行官方同步端到端 smoke推进真实 Crowdin worker / 翻译记忆、复杂 AssetBundle 解析和 bat-api 全量 release 联调
- **下一项工程任务**:推进真实 Crowdin worker / 翻译记忆、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke
+1
View File
@@ -42,6 +42,7 @@
- [当前缺口清单](docs/reports/CURRENT_GAPS.md)
- [官方资源拉取与自动更新指南](docs/guides/official-resource-test-pull.md)
- [官方全量拉取 Smoke Runbook](docs/guides/official-full-pull-smoke.md)
- [bat-api 同机 Live Smoke Runbook](docs/guides/bat-api-local-live-smoke.md)
- [官方资源后端说明](docs/architecture/official-resource-backend.md)
---
+30 -30
View File
@@ -138,7 +138,7 @@ impl GameClient {
if !is_real_directory(&self.install_path)? || has_symlink_component(&self.install_path)? {
return Ok(false);
}
Ok(client_layout_is_present(&self.install_path)?)
client_layout_is_present(&self.install_path)
}
/// 获取 StreamingAssets 目录路径
@@ -154,6 +154,35 @@ impl GameClient {
}
}
fn client_layout_is_present(path: &Path) -> crate::Result<bool> {
Ok(is_real_directory(&path.join("BlueArchive_Data"))?
&& is_real_directory(&path.join("BlueArchive_Data/StreamingAssets"))?
&& is_real_directory(&path.join("BlueArchive_Data/StreamingAssets/AssetBundles"))?
&& !has_symlink_component(path)?)
}
fn is_real_directory(path: &Path) -> crate::Result<bool> {
match fs::symlink_metadata(path) {
Ok(metadata) => Ok(metadata.is_dir() && !metadata.file_type().is_symlink()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
fn has_symlink_component(path: &Path) -> crate::Result<bool> {
let mut current = PathBuf::new();
for component in path.components() {
current.push(component.as_os_str());
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_symlink() => return Ok(true),
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()),
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -244,32 +273,3 @@ mod tests {
.unwrap());
}
}
fn client_layout_is_present(path: &Path) -> crate::Result<bool> {
Ok(is_real_directory(&path.join("BlueArchive_Data"))?
&& is_real_directory(&path.join("BlueArchive_Data/StreamingAssets"))?
&& is_real_directory(&path.join("BlueArchive_Data/StreamingAssets/AssetBundles"))?
&& !has_symlink_component(path)?)
}
fn is_real_directory(path: &Path) -> crate::Result<bool> {
match fs::symlink_metadata(path) {
Ok(metadata) => Ok(metadata.is_dir() && !metadata.file_type().is_symlink()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
fn has_symlink_component(path: &Path) -> crate::Result<bool> {
let mut current = PathBuf::new();
for component in path.components() {
current.push(component.as_os_str());
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_symlink() => return Ok(true),
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()),
}
}
Ok(false)
}
+40
View File
@@ -0,0 +1,40 @@
# bat-api 同机 live 联调
## 目的
该 runbook 验证生产拓扑的本地形态:Rust `bat` 与 Go `bat-api` 在同一主机上运行,二者通过同一个 `bat.sock` Unix socket 和同一个已发布资源文件系统协作。
测试使用仓库内完整 release fixture,并把所有 daemon、HTTP 服务、release 目录和报告写入一个新的 `/tmp` 隔离目录。它不访问官方网络,不读取现有客户端目录,也不写入开发机生产资源目录。
## 命令
```bash
make bat-api-local-live-smoke
```
脚本会按需构建 `bat``bat-api`,然后在同一临时目录中:
1. 创建版本化 release、`official-version-state.json``current` symlink。
2. 启动真实 Rust `bat --daemon`,验证 live `bat.sock` RPC。
3. 启动 Go `bat-api`,通过 RPC 发现 `resource_root` 和 manifestGo 不读取 daemon 状态文件。
4. 验证 `/healthz``/readyz``/v1/bootstrap`、server-info 和 launcher resource bootstrap。
5. 验证 CDN `GET``HEAD``Range`、ETag、Last-Modified、缓存头、未索引路径和编码 dot-segment 越界路径。
6. 切换 `current` 到下一个已发布版本,确认 API 索引跟随 RPC 返回的版本变化。
7. 清空已发布版本,确认旧索引不会继续分发,`/readyz` 返回 `503`
8. 停止并重启 Rust daemon,确认 RPC 断开时 API 返回未 ready,重连后恢复 ready。
成功时脚本输出 `LOCAL_BAT_API_LIVE_SMOKE_OK`,并打印类似以下报告路径:
```text
/tmp/bat-api-local-live-<UTC timestamp>/report/SMOKE_REPORT.md
```
报告目录不提交 Git;需要审阅时应保存该次命令输出和报告目录位置。
## 生产边界
- Rust `bat` 负责官方发现、下载、校验、发布、版本状态和 `bat.sock` RPC。
- Go `bat-api` 只通过 RPC 发现已发布 `resource_root` 和 manifest,并提供 HTTP bootstrap/CDN 读服务。
- 生产中两者必须使用同一主机、同一容器或同一共享文件系统;`bat.sock` 不应暴露到公网。
- `--resource-root` / `BAT_API_RESOURCE_ROOT` 只用于 fixture 或应急只读诊断,不能替代生产 RPC 发现。
- `make official-smoke` 是独立的官方网络全量拉取 runbook;本文件的本地 fixture smoke 不证明官方网络可达或官方全量资源下载成功。
+4 -4
View File
@@ -356,7 +356,7 @@ sudo -u bat tar -C /var/lib/bluearchive-toolkit/official \
## 模式 4bat-api 资源 bootstrap / 分发服务
适用场景:真实 Rust `bat` 长期运行在远程服务器,并且同一服务器/容器环境内运行 Go `bat-api`,给客户端、补丁器或上层工具提供启动前资源入口和 CDN path 只读分发。
适用场景:真实 Rust `bat` 长期运行在生产主机,并且同一主机/容器环境内运行 Go `bat-api`,给客户端、补丁器或上层工具提供启动前资源入口和 CDN path 只读分发。
核心约束:
@@ -364,7 +364,7 @@ sudo -u bat tar -C /var/lib/bluearchive-toolkit/official \
2. 当前资源目录由 `bat.sock` RPC 返回的 `resource_root` 决定;生产不要在 `bat-api` 配置里写死 `BAT_API_RESOURCE_ROOT`
3. `BAT_API_RESOURCE_ROOT` 只用于本地 fixture、临时只读诊断或 RPC 不可用时的应急验证。
4. `bat.sock` 只在服务器本机使用,不通过公网暴露;对外只发布 HTTP `bat-api`,生产建议放在反向代理和 TLS 后面。
5. 本地开发环境不需要、也不应全量运行 `bat`;使用 Go 单测、fixture release 远程服务器联调
5. 本地开发环境不需要官方全量下载;使用 Go 单测、fixture release `make bat-api-local-live-smoke`。该 smoke 在本地 `/tmp` 隔离目录启动真实 Rust daemon,不连接远程服务器。
### 构建和安装 bat-api
@@ -388,7 +388,7 @@ sudo ln -sfn \
### bat 侧前置条件
`bat-api` 依赖 live RPC,而不是直接读取 daemon 状态文件。部署 `bat-api` 前,远程服务器上应已有 socket 形态的 Rust `bat`
`bat-api` 依赖 live RPC,而不是直接读取 daemon 状态文件。部署 `bat-api` 前,部署所在生产主机上应已有 socket 形态的 Rust `bat`
```bash
sudo -u bat /opt/bluearchive-toolkit/bin/bat \
@@ -492,7 +492,7 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
--refresh-interval 0
```
这条本地命令只验证 HTTP 形态、server-info 改写、CDN path、Range/缓存语义和管理接口;真实全量 release 联调应在远程长期运行的 `bat` 环境里执行
这条本地命令只验证 HTTP 形态、server-info 改写、CDN path、Range/缓存语义和管理接口;同机 live 联调使用 `make bat-api-local-live-smoke`,真实官方网络下载则使用独立的 `make official-smoke`
---
+4 -1
View File
@@ -160,10 +160,11 @@ Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准:
- 试验 CLI 产物为 `bin/bat-go``make build-go-cli`),**禁止**与 Rust `bat` 重名
- 修改 FFI 时再跑 `make test-go-ffi`
开发环境不能本地全量运行 Rust `bat` 时,`bat-api` 不需要真实生产资源目录。用 fixture 或 mock RPC 验证服务面;生产联调再连接远程服务器上同环境运行的 `bat.sock`
`bat-api` 与 Rust `bat` 的生产拓扑是同一主机、同一容器或同一共享文件系统。开发时优先使用隔离 fixture 和本地 `bat.sock` live smoke,不连接远程服务器,也不读取现有客户端目录
```bash
make test-go-api
make bat-api-local-live-smoke
BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
--listen 127.0.0.1:18080 \
--public-base-url http://127.0.0.1:18080 \
@@ -171,6 +172,8 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
--refresh-interval 0
```
其中 `make bat-api-local-live-smoke` 会在 `/tmp` 中启动真实 Rust daemon 和 Go API,覆盖 release 切换、清单不完整、无 release、RPC 断开、server-info、CDN Range/缓存和路径越界;报告保留在该次 smoke 的临时目录。单独的 `--resource-root` 命令只验证 fixture HTTP 形态,不替代 live socket 联调。
生产默认路径仍是 `--socket` / `BAT_API_SOCKET`,资源根由 Rust `bat` RPC 返回;`--resource-root` 只用于上述 fixture 或应急只读诊断。
### 常用聚焦命令
+3 -2
View File
@@ -447,8 +447,9 @@ Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
Go mirror contract fixture 固化在 `internal/api/testdata/contract/`,覆盖
`catalog.status` available/unavailable、`resource.manifest` page0 和对应
`official-sync-snapshot.json`。这些 fixture 由 Rust 输出归一化而来,只用于
schema / mirror 回归;live daemon socket 和完整 release 切换仍需在允许 smoke 的
隔离环境中验证。
schema / mirror 回归;live daemon socket 和完整 fixture release 切换
`make bat-api-local-live-smoke` 在同机 `/tmp` 隔离环境中验证。该 smoke 不替代
`make official-smoke` 的官方网络全量下载验证。
禁止事项:
@@ -1,6 +1,6 @@
# bat-api / Rust bat Contract Fixture Handoff
更新时间:2026-08-03
更新时间:2026-08-29
本文用于两个 Codex 窗口之间间接联调 `bat-api` 与 Rust `bat` 的跨语言 contract fixture。
仓库内归一化 fixture 已交付;本文保留生成、审核和后续扩展的协作协议。
@@ -18,8 +18,8 @@
- 联调不要求本地运行全量长期服务端 `bat`;允许 Rust 侧使用 fixture root 或临时目录走真实代码路径导出 JSON。
- fixture 审核前只能放在 `/tmp/bat-contract-fixture/`,不能直接提交到仓库。
- Go 侧已经实现 player-facing HTTP 鉴权、限流、访问日志、反代适配、OpenAPI 和
`/admin/` 控制入口;仓库内 contract fixture 已完成,剩余是 live daemon socket
和完整 release 切换联调。
`/admin/` 控制入口;仓库内 contract fixture 和同机 live daemon socket / 完整
fixture release 切换联调均已完成,命令为 `make bat-api-local-live-smoke`
- Go 侧当前相关代码入口:
- `internal/api/rpc_release.go`
- `internal/api/release_index.go`
@@ -206,6 +206,9 @@ contract fixture 工作只有在以下条件同时满足时才算完成:
- `internal/api/testdata/contract/catalog-status.unavailable.json`
- `internal/api/testdata/contract/resource-manifest.page0.json`
- `internal/api/testdata/contract/official-sync-snapshot.json`
- 原始交接产物仍位于 `/tmp/bat-contract-fixture/`当前受本地 sandbox 限制,live socket daemon 无法启动,原始 JSON 通过临时 Rust 测试调用同一 dispatch/report 代码路径生成。
- 原始交接产物仍位于 `/tmp/bat-contract-fixture/`仓库内归一化 fixture 用于 schema/mirror 回归,
同机 live socket 验证使用 `make bat-api-local-live-smoke`,不依赖该交接目录。
- Go contract 测试读取仓库内归一化 fixture,不依赖 `/tmp/bat-contract-fixture/`、开发机资源目录或远端长期运行的 `bat`
- 仍未覆盖真实长期 daemon socket 的端到端调用和完整发布切换;该项需要在允许 live daemon / smoke 的隔离环境中单独验证。
- 真实长期 daemon 的生产部署仍需由部署环境持续运行;仓库已在本地隔离环境通过真实
daemon socket 完成端到端调用、版本切换、无 release、RPC 断线和恢复验证。真实官方
网络全量下载仍由 `make official-smoke` 独立负责。
+6 -5
View File
@@ -1,6 +1,6 @@
# 当前实现缺口清单
- **更新时间**2026-08-03
- **更新时间**2026-08-29
- **Go 进度权威**`GO_STATUS.md`
- **资源布局 / 逆向契约**`../architecture/resource-release-layout.md`
- **用途**:集中跟踪当前代码中的占位实现、设计缺口和下一步验收项。
@@ -248,11 +248,12 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ
- `cmd/bat-api``internal/api``/v1/bootstrap``/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、HTTP token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON `no-store``/v1/resources` 分页上限、OpenAPI、`/admin/` 管理控制白名单、RPC 周期刷新/诊断、`/readyz`、CDN Range/缓存头、fixture 单测、USERGUIDE 基础章节、systemd bat-api 模板、`make build-go-api` / `test-go-api`
- 进度权威:`docs/reports/GO_STATUS.md`
- Rust snapshot schema 与 Go mirror struct 的仓库内 contract fixture 已完成,文件位于
`internal/api/testdata/contract/`剩余是 live daemon socket 和完整 release 切换联调。
`internal/api/testdata/contract/`同机 live daemon socket 和完整 fixture release 切换已由
`make bat-api-local-live-smoke` 验证。
验收(剩余):
后续跟踪(不影响 issue #19 关闭):
- 与远程长期运行的 `bat` / 全量 release 联调(覆盖 bootstrap、server-info、CDN pathSSH 实勘可后置)
- 同机 live 联调已完成(覆盖 bootstrap、server-info、CDN path、release 切换、无 release、RPC 断线/恢复);真实官方网络全量下载由 `make official-smoke` 独立跟踪
- refresh 中 manifest 磁盘校验的 mtime/size 增量缓存优化(真实全量 release 观测后决定)
- 文档与 GO_STATUS 持续一致
@@ -565,7 +566,7 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ
scheduler 和默认并发 8,范围 `1..=256`,worker 完成后立即领取下一个任务,
进度即时按完成数上报,最终 report 保持 plan 顺序。
4. **G-008:已决策关闭**(同步 CLI = Rust `bat`;见 `GO_STATUS.md`)。
5. **G-009 / issue #19**:资源 bootstrap/分发 MVP 已编码;优先服务器联调与索引实勘,非「从零实现」
5. **G-009 / issue #19**:资源 bootstrap/分发和同机 live 联调已完成;非「从零实现」。后续真实官方网络长期运行、持久化和完整 launcher/业务链不属于本 issue 关闭条件
6. issue #2 / G-007P1):Addressables 可校验字段。
7. issue #3 / G-005P1):UnityFS 容器基础解析已落地;对象级引擎解析继续跟踪 G-005。
8. G-011:翻译任务状态、CAS 诊断和 ResourceRepository 查询面扩展。
+10 -12
View File
@@ -1,8 +1,8 @@
# Go 侧进度与边界(权威)
- **更新时间**2026-08-03
- **更新时间**2026-08-29
- **用途**:统一 Go module `bat-api` 的产品边界、既有约定和组件进度;其他文档与此冲突时以本文为准。
- **关联**issue #19 / G-009(资源 bootstrap/分发)、G-008(已决策关闭)、`docs/architecture/official-resource-backend.md` §7
- **关联**issue #19 / G-009(资源 bootstrap/分发)、G-008(已决策关闭)、`docs/architecture/official-resource-backend.md` §7`docs/guides/bat-api-local-live-smoke.md`
---
@@ -40,7 +40,7 @@
1. **G-008 决策关闭(wontfix**:不另做产品级 Go 同步/运维 CLI。
2. **G-009**:资源 bootstrap/分发 MVP 部分完成;非完整游戏业务 API。
3. **USERGUIDE 的 bat-api 基础章节已补**全量 release 联调后继续补充生产参数和排障样例
3. **USERGUIDE 的 bat-api 基础章节已补**同机 live 联调 runbook 已补,真实官方网络下载仍由独立 smoke 负责
---
@@ -54,7 +54,7 @@
| B | **`bat-api` 只读分发**已发布 release,不实现下载器,不写 staging/version-state |
| C | 仿真范围 = **资源拉取相关**resource bootstrap + CDN path + 可选 server-info);**不是**完整游戏业务 API |
| D | launcher 资源 metadata 可作为 bootstrap 输入/输出兼容;账号、登录、网关和鉴权全链 **非 G-009 关闭条件** |
| E | USERGUIDE bat-api 基础章节已补;联调后补充实战样例 |
| E | USERGUIDE bat-api 基础章节和同机 live smoke 实战样例已补 |
### 发现与数据
@@ -63,7 +63,7 @@
| F | 版本/清单经 **`bat.sock` JSON-RPC**`--socket`);不读 daemon 内部状态文件 |
| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再 catalog/manifest |
| H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 |
| I | 真数据在**已全量拉取且长期运行 Rust `bat` 的远程服务器**;开发机不跑全量 `bat`,用 fixture、mock RPC 和 Go 门禁验证;远程联调等连接信息 |
| I | 生产中 Rust `bat``bat-api` 在同一主机/容器/共享文件系统;开发用 `/tmp` fixture 和真实本地 `bat.sock` smoke,不依赖远程连接 |
| J | 索引以 **manifest + 磁盘 Present/size** 为准 |
| J2 | RPC 状态以 Rust 返回的 `status` / `status_code` 为准;`bat-api` 只读消费,不自行推导同步状态 |
@@ -134,8 +134,8 @@ make build-go-cli # 产出 bin/bat-go
| 项 | 状态 |
|---|---|
| G-008 Go 同步 CLI | **已决策关闭**(正式同步 CLI = Rust `bat` |
| G-009 bat-api 资源 bootstrap/分发 | **部分完成**MVP+生产控制面);已含资源 bootstrap 关系面、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*` dashboard 代理、`translation.task.update` 状态回写代理、`translation.proofread` 状态标记代理和部署模板,后续远程服务器联调/可选持久化 |
| issue #19 | 资源面 MVP 与 USERGUIDE 基础章节已编码;真机联调后继续补充实战样例;**未自动关 issue** |
| G-009 bat-api 资源 bootstrap/分发 | **资源面完成(非完整官方游戏 API**;已含资源 bootstrap、launcher resource metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*` dashboard 代理、翻译状态回写代理、同机 live smoke 和部署模板;持久化仍另议 |
| issue #19 | **验收完成,本提交关闭**`make bat-api-local-live-smoke` 已在同机隔离环境覆盖 live RPC、release 切换、清单不完整、未 ready、server-info 和 CDN path |
| G-010 Web | 未开始 |
---
@@ -147,8 +147,6 @@ make build-go-cli # 产出 bin/bat-go
## 7. 后续(不在进度统一范围内)
1. 服务器 SSH 只读实勘(连接信息到位后)
2. bat-api 与远程长期运行的 `bat` / 全量 release 联调(含 `/v1/bootstrap`、server-info 和 CDN path
3. 预留 database/redis 的接入时机另议
4. USERGUIDE bat-api 联调排障样例(全量 release 验证后)
5. launcher 完整安装包更新链 / 登录网关链(若需要,新 issue)
1. 预留 database/redis 的接入时机另议
2. 真实官方网络全量下载长期运行报告(使用 `make official-smoke`,与 issue #19 同机 live 联调独立
3. launcher 完整安装包更新链 / 登录网关链(若需要,新 issue)
+6 -2
View File
@@ -24,6 +24,10 @@ pub trait DownloaderBackend<T>: Send + Sync {
fn download(&self, task: T) -> Result<Self::Output, Self::Error>;
}
/// Results returned by a scheduler for a task type and backend.
pub type DownloadResults<T, B> =
Vec<Result<<B as DownloaderBackend<T>>::Output, <B as DownloaderBackend<T>>::Error>>;
/// A bounded worker scheduler.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DownloadScheduler {
@@ -54,7 +58,7 @@ impl DownloadScheduler {
/// order. A failed task does not cause additional tasks to be scheduled
/// after it, because already-started bounded work must be joined cleanly;
/// callers decide whether a failed result invalidates the whole release.
pub fn execute<T, B>(self, backend: &B, tasks: Vec<T>) -> Vec<Result<B::Output, B::Error>>
pub fn execute<T, B>(self, backend: &B, tasks: Vec<T>) -> DownloadResults<T, B>
where
T: Send + 'static,
B: DownloaderBackend<T>,
@@ -78,7 +82,7 @@ impl DownloadScheduler {
backend: &B,
tasks: Vec<T>,
mut observer: F,
) -> Result<Vec<Result<B::Output, B::Error>>, E>
) -> Result<DownloadResults<T, B>, E>
where
T: Send + 'static,
B: DownloaderBackend<T>,
+7 -6
View File
@@ -38,8 +38,8 @@ pub use curl_transfer::{
redact_proxy_url, resolve_curl_proxy, CurlProxyConfig, CurlProxyMode, ResolvedCurlProxy,
};
pub use downloader::{
DownloadScheduler, DownloaderBackend, DEFAULT_DOWNLOAD_CONCURRENCY, MAX_DOWNLOAD_CONCURRENCY,
MIN_DOWNLOAD_CONCURRENCY,
DownloadResults, DownloadScheduler, DownloaderBackend, DEFAULT_DOWNLOAD_CONCURRENCY,
MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY,
};
pub use import::{
BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport,
@@ -136,10 +136,11 @@ pub use release_flow::ReleaseFlowStatusCode;
pub use resources::{InMemoryResourceRepository, SqliteResourceRepository};
pub use translation_tasks::{
build_translation_handoff, read_translation_handoff_at, sync_translation_task_repository_at,
write_translation_handoff_at, PersistedTranslationTask, ProviderRun, ProviderRunStatus,
SqliteTranslationTaskRepository, TranslationHandoff, TranslationJob, TranslationJobStatus,
TranslationTaskStatus, TranslationTaskSyncReport, TranslationUnit, TranslationUnitStatus,
TRANSLATION_HANDOFF_FILE, TRANSLATION_HANDOFF_SCHEMA_VERSION, TRANSLATION_TASK_REPOSITORY_FILE,
write_translation_handoff_at, PersistedTranslationTask, PersistedTranslationTaskState,
ProviderRun, ProviderRunStatus, SqliteTranslationTaskRepository, TranslationHandoff,
TranslationJob, TranslationJobStatus, TranslationTaskStatus, TranslationTaskSyncReport,
TranslationUnit, TranslationUnitStatus, TRANSLATION_HANDOFF_FILE,
TRANSLATION_HANDOFF_SCHEMA_VERSION, TRANSLATION_TASK_REPOSITORY_FILE,
TRANSLATION_TASK_SCHEMA_VERSION,
};
pub use translation_workflow::{
+36 -24
View File
@@ -405,6 +405,25 @@ pub struct PersistedTranslationTask {
pub provider_run_id: Option<String>,
}
/// Mutable persistence metadata associated with an official TextUnit task.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersistedTranslationTaskState {
/// Current worker state.
pub task_status: TranslationTaskStatus,
/// Provider failure reason, if the worker recorded one.
pub failure_reason: Option<String>,
/// Number of provider attempts.
pub attempt_count: u32,
/// First persistence time as Unix seconds.
pub created_unix_seconds: u64,
/// Last state or metadata update time as Unix seconds.
pub updated_unix_seconds: u64,
/// Completion time as Unix seconds, if completed.
pub completed_unix_seconds: Option<u64>,
/// Provider-side run identifier, if known.
pub provider_run_id: Option<String>,
}
/// Result of synchronizing an immutable release queue into SQLite.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TranslationTaskSyncReport {
@@ -860,25 +879,16 @@ type TranslationTaskRow = (
impl PersistedTranslationTask {
/// Builds a persisted translation task from an immutable queue task.
pub fn from_task(
task: OfficialTextUnitTask,
task_status: TranslationTaskStatus,
failure_reason: Option<String>,
attempt_count: u32,
created_unix_seconds: u64,
updated_unix_seconds: u64,
completed_unix_seconds: Option<u64>,
provider_run_id: Option<String>,
) -> Self {
pub fn from_task(task: OfficialTextUnitTask, state: PersistedTranslationTaskState) -> Self {
Self {
task,
task_status,
failure_reason,
attempt_count,
created_unix_seconds,
updated_unix_seconds,
completed_unix_seconds,
provider_run_id,
task_status: state.task_status,
failure_reason: state.failure_reason,
attempt_count: state.attempt_count,
created_unix_seconds: state.created_unix_seconds,
updated_unix_seconds: state.updated_unix_seconds,
completed_unix_seconds: state.completed_unix_seconds,
provider_run_id: state.provider_run_id,
}
}
@@ -892,13 +902,15 @@ impl PersistedTranslationTask {
};
Self::from_task(
task,
task_status,
failure_reason,
0,
generated_unix_seconds,
generated_unix_seconds,
None,
None,
PersistedTranslationTaskState {
task_status,
failure_reason,
attempt_count: 0,
created_unix_seconds: generated_unix_seconds,
updated_unix_seconds: generated_unix_seconds,
completed_unix_seconds: None,
provider_run_id: None,
},
)
}
+144
View File
@@ -66,6 +66,29 @@ func TestLoadIndexFromResourceRoot(t *testing.T) {
}
}
func TestReleaseSummaryRequiresCompleteManifest(t *testing.T) {
idx := &ReleaseIndex{
ResourceRoot: "/tmp/release",
Entries: []ResourceEntry{
{RelativePath: "host/ready.bin", Present: true, SizeMatch: true},
{RelativePath: "host/missing.bin", Present: false, SizeMatch: false},
},
MissingOnDisk: []string{"host/missing.bin"},
byRel: map[string]int{},
}
summary := idx.Summary()
if summary.PresentCount != 1 || summary.MissingCount != 1 || summary.Ready {
t.Fatalf("partial release summary=%+v", summary)
}
idx.Entries[1] = ResourceEntry{RelativePath: "host/missing.bin", Present: true, SizeMatch: false}
idx.MissingOnDisk = []string{"host/missing.bin#size_mismatch"}
summary = idx.Summary()
if summary.Ready {
t.Fatalf("size-mismatched release summary=%+v", summary)
}
}
func TestSplitCDNPathRejectsEscape(t *testing.T) {
if _, _, err := SplitCDNPath("/prod-clientpatch.bluearchiveyostar.com/../etc/passwd"); err == nil {
t.Fatal("expected error")
@@ -124,6 +147,17 @@ func TestCDNServesIndexedFile(t *testing.T) {
if rr.Code != http.StatusNotFound {
t.Fatalf("missing status=%d", rr.Code)
}
req = httptest.NewRequest(
http.MethodGet,
"/prod-clientpatch.bluearchiveyostar.com/%2e%2e/yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json",
nil,
)
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("dot-segment status=%d body=%s", rr.Code, rr.Body.String())
}
}
func TestCDNSupportsRangeHeadAndConditionalRequests(t *testing.T) {
@@ -524,6 +558,7 @@ type fakeBackend struct {
status *backendrpc.DaemonStatusReport
doctor *backendrpc.DoctorReport
catalog json.RawMessage
resource *backendrpc.ResourceState
manifest *backendrpc.ResourceManifestPage
}
@@ -539,6 +574,9 @@ func (f *fakeBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorRepor
return f.doctor, nil
}
func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
if f.resource != nil {
return f.resource, nil
}
return &backendrpc.ResourceState{}, nil
}
func (f *fakeBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
@@ -780,6 +818,112 @@ func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
}
}
func TestRefreshClearsPublishedIndexWhenCurrentReleaseDisappears(t *testing.T) {
root := fixtureRoot(t)
info, err := os.Stat(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"))
if err != nil {
t.Fatal(err)
}
size := uint64(info.Size())
catalogRaw, err := json.Marshal(map[string]any{
"available": true,
"status": "published",
"version": map[string]any{
"id": "v1",
"resource_root": root,
},
})
if err != nil {
t.Fatal(err)
}
backend := &fakeBackend{
status: &backendrpc.DaemonStatusReport{Status: "ok", Running: true, RPCAvailable: true},
doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"},
catalog: catalogRaw,
manifest: &backendrpc.ResourceManifestPage{
Available: true,
ResourceRoot: root,
ManifestVersion: 1,
TotalEntries: 1,
Entries: []backendrpc.ResourceManifestEntry{{
URL: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
Destination: "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
Bytes: &size,
BLAKE3: strings.Repeat("0", 64),
}},
},
}
cfg := DefaultConfig()
cfg.RefreshInterval = 0
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
s := NewServer(cfg, backend, log.New(io.Discard, "", 0))
if err := s.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
initial := s.index()
if initial == nil {
t.Fatal("initial index missing")
}
if summary := initial.Summary(); !summary.Ready || summary.ResourceRoot != root {
t.Fatalf("initial summary=%+v", summary)
}
backend.catalog = json.RawMessage(`{"available":false,"status":"waiting"}`)
outputRoot := filepath.Join(t.TempDir(), "official")
backend.resource = &backendrpc.ResourceState{ResourceOutputRoot: &outputRoot}
if err := s.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
current := s.index()
if current == nil {
t.Fatal("current index missing")
}
summary := current.Summary()
if summary.Ready || summary.ResourceRoot != "" {
t.Fatalf("stale summary=%+v", summary)
}
if len(s.meta.Warnings) == 0 {
t.Fatal("missing disappearance warning")
}
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("readyz status=%d body=%s", rr.Code, rr.Body.String())
}
}
func TestDiscoverTreatsCatalogUnavailableAsNoRelease(t *testing.T) {
fixture := fixtureRoot(t)
outputRoot := t.TempDir()
if err := os.Symlink(fixture, filepath.Join(outputRoot, "current")); err != nil {
t.Fatal(err)
}
backend := &fakeBackend{
status: &backendrpc.DaemonStatusReport{Status: "ok", Running: true, RPCAvailable: true},
doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"},
catalog: json.RawMessage(`{"available":false,"status":"unavailable"}`),
resource: &backendrpc.ResourceState{ResourceOutputRoot: &outputRoot},
}
result, err := DiscoverAndIndex(context.Background(), backend, "")
if err != nil {
t.Fatal(err)
}
if result.Index == nil {
t.Fatal("index missing")
}
summary := result.Index.Summary()
if summary.Ready || summary.ResourceRoot != "" || summary.EntryCount != 0 {
t.Fatalf("catalog unavailable summary=%+v", summary)
}
if len(result.Warnings) == 0 {
t.Fatal("missing catalog unavailable warning")
}
}
func TestLoadEnvFileDoesNotOverride(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".env")
+27
View File
@@ -4,6 +4,7 @@ import (
"crypto/subtle"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -15,9 +16,35 @@ func (s *Server) wrapHandler(next http.Handler) http.Handler {
handler = s.rateLimitMiddleware(handler)
handler = s.securityHeadersMiddleware(handler)
handler = s.accessLogMiddleware(handler)
// Check before ServeMux can clean dot segments and route an escaped path
// to a different host-shaped endpoint.
handler = rejectDotSegmentsMiddleware(handler)
return handler
}
func rejectDotSegmentsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if hasDotPathSegment(r.URL.Path) || (r.URL.RawPath != "" && hasDotPathSegment(r.URL.RawPath)) {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
func hasDotPathSegment(rawPath string) bool {
decoded, err := url.PathUnescape(rawPath)
if err != nil {
return true
}
for _, segment := range strings.Split(decoded, "/") {
if segment == "." || segment == ".." {
return true
}
}
return false
}
func (s *Server) securityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
+4 -1
View File
@@ -106,7 +106,10 @@ func (idx *ReleaseIndex) Summary() ReleaseSummary {
EntryCount: len(idx.Entries),
PresentCount: present,
MissingCount: len(idx.MissingOnDisk),
Ready: idx.ResourceRoot != "" && present > 0,
// A release is distributable only when every manifest entry is present
// and has the expected size. Serving a partial release can leave clients
// with an apparently valid bootstrap and an unrecoverable download set.
Ready: idx.ResourceRoot != "" && len(idx.Entries) > 0 && present == len(idx.Entries),
}
}
+63
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"bat-api/internal/backendrpc"
@@ -206,16 +207,22 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
// Catalog / resource discovery
var snapshot *SnapshotSummary
var resourceRoot string
catalogAvailabilityKnown := false
catalogAvailable := false
if raw, err := backend.CatalogStatus(ctx); err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("catalog.status: %v", err))
} else {
catalogAvailable, catalogAvailabilityKnown = parseCatalogAvailability(raw)
snap, root, ok := parseCatalogStatus(raw)
if ok {
snapshot = snap
resourceRoot = root
}
}
if catalogAvailabilityKnown && !catalogAvailable {
return emptyRPCResult(out, snapshot, "catalog.status available=false; no published release"), nil
}
if resourceRoot == "" {
if state, err := backend.ResourceState(ctx); err != nil {
@@ -243,12 +250,32 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
return out, nil
}
if resourceRootOverride == "" {
info, statErr := os.Stat(resourceRoot)
if statErr != nil || !info.IsDir() {
warning := fmt.Sprintf("published resource root is unavailable: %s", resourceRoot)
if statErr != nil {
warning = fmt.Sprintf("%s (%v)", warning, statErr)
} else {
warning = fmt.Sprintf("%s (not a directory)", warning)
}
return emptyRPCResult(out, snapshot, warning), nil
}
}
entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend)
if err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err))
// Fallback: load local manifest file under root.
idx, loadErr := LoadIndexFromResourceRoot(resourceRoot)
if loadErr != nil {
if resourceRootOverride == "" {
return emptyRPCResult(
out,
snapshot,
fmt.Sprintf("published release cannot be indexed: %v", loadErr),
), nil
}
return out, fmt.Errorf("manifest RPC and local load failed: rpc=%v local=%w", err, loadErr)
}
idx.Source = "rpc+local_manifest"
@@ -279,6 +306,13 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
entries,
)
if err != nil {
if resourceRootOverride == "" {
return emptyRPCResult(
out,
snapshot,
fmt.Sprintf("published release failed local validation: %v", err),
), nil
}
return out, err
}
out.ResourceRoot = idx.ResourceRoot
@@ -287,6 +321,22 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
return out, nil
}
func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning string) *DiscoverResult {
out.ResourceRoot = ""
out.Snapshot = snapshot
out.Index = &ReleaseIndex{
Source: "rpc",
RPCAvailable: true,
DoctorHealthy: out.DoctorHealthy,
Snapshot: snapshot,
byRel: map[string]int{},
}
if warning != "" {
out.Warnings = append(out.Warnings, warning)
}
return out
}
func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) {
if len(raw) == 0 || string(raw) == "null" {
return nil, "", false
@@ -333,6 +383,19 @@ func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) {
return snap, root, true
}
func parseCatalogAvailability(raw json.RawMessage) (bool, bool) {
if len(raw) == 0 || string(raw) == "null" {
return false, false
}
var payload struct {
Available *bool `json:"available"`
}
if err := json.Unmarshal(raw, &payload); err != nil || payload.Available == nil {
return false, false
}
return *payload.Available, true
}
func fetchAllManifestEntries(ctx context.Context, backend Backend) ([]manifestEntry, int, string, error) {
const pageSize = 500
offset := 0
+394
View File
@@ -0,0 +1,394 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
SMOKE_ROOT="${BAT_API_LIVE_SMOKE_ROOT:-/tmp/bat-api-local-live-${timestamp}}"
OUTPUT_DIR="${SMOKE_ROOT}/official"
LOCALIZED_DIR="${SMOKE_ROOT}/localized"
STATE_DIR="${SMOKE_ROOT}/state"
REPORT_DIR="${SMOKE_ROOT}/report"
FIXTURE_ROOT="${REPO_ROOT}/internal/api/testdata/release"
BAT_BIN="${BAT_BIN:-${REPO_ROOT}/target/debug/bat}"
BAT_API_BIN="${BAT_API_BIN:-${SMOKE_ROOT}/bat-api}"
PORT="${BAT_API_LIVE_PORT:-18181}"
BASE_URL="http://127.0.0.1:${PORT}"
SOCKET_PATH="${STATE_DIR}/bat.sock"
API_PID=""
fail() {
printf 'error: %s\n' "$*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "required command is missing: $1"
}
require_command cargo
require_command curl
require_command go
require_command jq
require_command realpath
[[ -d "${FIXTURE_ROOT}" ]] || fail "release fixture directory is missing: ${FIXTURE_ROOT}"
[[ "${SMOKE_ROOT}" == /tmp/* ]] || fail "BAT_API_LIVE_SMOKE_ROOT must be under /tmp"
[[ ! -e "${SMOKE_ROOT}" ]] || fail "smoke root already exists: ${SMOKE_ROOT}"
[[ -x "${BAT_BIN}" || "${BAT_API_LIVE_SKIP_BUILD:-}" != "1" ]] || fail "bat binary is not executable: ${BAT_BIN}"
mkdir -p "${OUTPUT_DIR}" "${LOCALIZED_DIR}" "${STATE_DIR}" "${REPORT_DIR}"
if [[ "${BAT_API_LIVE_SKIP_BUILD:-}" != "1" ]]; then
cargo build -p bat-infrastructure --bin bat
GOCACHE="${GOCACHE:-/tmp/bat-go-cache}" go build -o "${BAT_API_BIN}" ./cmd/bat-api
fi
[[ -x "${BAT_BIN}" ]] || fail "bat binary is not executable: ${BAT_BIN}"
[[ -x "${BAT_API_BIN}" ]] || fail "bat-api binary is not executable: ${BAT_API_BIN}"
write_version_state() {
local id="$1"
local version_dir="$2"
local snapshot_path="${version_dir}/official-sync-snapshot.json"
local state_path="${OUTPUT_DIR}/official-version-state.json"
local temp_path="${state_path}.tmp.$$"
jq -n \
--arg id "${id}" \
--arg app_version "1.70.0" \
--arg bundle_version "s8tloc7lo3" \
--arg addressables_root "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture" \
--arg resource_root "${version_dir}" \
--arg snapshot_path "${snapshot_path}" \
'{
state_version: 1,
current_completed_version: {
id: $id,
app_version: $app_version,
bundle_version: $bundle_version,
addressables_root: $addressables_root,
resource_root: $resource_root,
snapshot_path: $snapshot_path,
staging_path: null,
version_path: $resource_root,
started_unix_seconds: null,
completed_unix_seconds: 1
},
in_progress_version: null,
previous_available_version: null,
failed_versions: [],
updated_unix_seconds: 1
}' >"${temp_path}"
mv -f "${temp_path}" "${state_path}"
}
write_empty_version_state() {
local state_path="${OUTPUT_DIR}/official-version-state.json"
local temp_path="${state_path}.tmp.$$"
jq -n '{
state_version: 1,
current_completed_version: null,
in_progress_version: null,
previous_available_version: null,
failed_versions: [],
updated_unix_seconds: 1
}' >"${temp_path}"
mv -f "${temp_path}" "${state_path}"
}
switch_current() {
local id="$1"
local link_path="${OUTPUT_DIR}/current"
local temp_link="${OUTPUT_DIR}/.current.$$"
ln -s "versions/${id}" "${temp_link}"
mv -Tf "${temp_link}" "${link_path}"
}
stop_daemon() {
if [[ -S "${SOCKET_PATH}" || -f "${STATE_DIR}/bat.pid" ]]; then
"${BAT_BIN}" stop \
--state-dir "${STATE_DIR}" \
--json \
--no-progress \
--no-banner \
>"${REPORT_DIR}/daemon-stop.json" 2>"${REPORT_DIR}/daemon-stop.log" || true
fi
}
cleanup() {
if [[ -n "${API_PID}" ]] && kill -0 "${API_PID}" 2>/dev/null; then
kill "${API_PID}" 2>/dev/null || true
wait "${API_PID}" 2>/dev/null || true
fi
stop_daemon
printf 'Smoke artifacts: %s\n' "${SMOKE_ROOT}" >&2
}
trap cleanup EXIT
mkdir -p "${OUTPUT_DIR}/versions/v-current"
cp -a "${FIXTURE_ROOT}/." "${OUTPUT_DIR}/versions/v-current/"
write_version_state "v-current" "${OUTPUT_DIR}/versions/v-current"
switch_current "v-current"
# The daemon is real and owns the live RPC socket, but this smoke must never
# reach official endpoints. Its sync loop can fail and retry while the seeded
# published release remains available to bat-api.
LOCAL_CURL="${SMOKE_ROOT}/curl-network-blocked"
printf '%s\n' \
'#!/usr/bin/env bash' \
'printf "local live smoke blocks network access: curl %q\n" "$*" >&2' \
'exit 7' >"${LOCAL_CURL}"
chmod +x "${LOCAL_CURL}"
printf '==> start Rust bat daemon\n'
BAT_AUTO_DISCOVER=0 "${BAT_BIN}" \
--server-info-path "${FIXTURE_ROOT}/yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" \
--connection-group Prod \
--app-version 1.70.0 \
--output "${OUTPUT_DIR}" \
--localized-output "${LOCALIZED_DIR}" \
--state-dir "${STATE_DIR}" \
--interval 1h \
--error-retry 1s \
--platforms Windows \
--curl "${LOCAL_CURL}" \
--daemon \
--json \
--no-progress \
--no-banner \
>"${REPORT_DIR}/daemon-start.json" 2>"${REPORT_DIR}/daemon-start.log"
for _ in $(seq 1 100); do
[[ -S "${SOCKET_PATH}" ]] && break
sleep 0.1
done
[[ -S "${SOCKET_PATH}" ]] || fail "Rust bat did not create ${SOCKET_PATH}; see ${REPORT_DIR}/daemon-start.log"
printf '==> start bat-api on the same host\n'
BAT_API_SKIP_ENV_FILE=1 "${BAT_API_BIN}" \
--listen "127.0.0.1:${PORT}" \
--public-base-url "${BASE_URL}" \
--state-dir "${STATE_DIR}" \
--socket "${SOCKET_PATH}" \
--server-info-file "${FIXTURE_ROOT}/yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" \
--refresh-interval 100ms \
>"${REPORT_DIR}/bat-api.log" 2>&1 &
API_PID="$!"
wait_http_status() {
local path="$1"
local expected="$2"
local response_file="$3"
shift 3
local curl_args=("$@")
local status=""
for _ in $(seq 1 100); do
status="$(curl -sS "${curl_args[@]}" -o "${response_file}" -w '%{http_code}' "${BASE_URL}${path}" || true)"
if [[ "${status}" == "${expected}" ]]; then
return 0
fi
sleep 0.1
done
fail "${path} expected HTTP ${expected}, got ${status}; response: $(tr '\n' ' ' <"${response_file}")"
}
wait_json() {
local path="$1"
local expression="$2"
local response_file="$3"
for _ in $(seq 1 100); do
curl -sS "${BASE_URL}${path}" >"${response_file}" 2>/dev/null || true
if jq -e "${expression}" "${response_file}" >/dev/null 2>&1; then
return 0
fi
sleep 0.1
done
fail "${path} did not satisfy ${expression}; response: $(tr '\n' ' ' <"${response_file}")"
}
wait_json_status() {
local path="$1"
local expected="$2"
local expression="$3"
local response_file="$4"
local status=""
for _ in $(seq 1 100); do
status="$(curl -sS -o "${response_file}" -w '%{http_code}' "${BASE_URL}${path}" || true)"
if [[ "${status}" == "${expected}" ]] &&
jq -e "${expression}" "${response_file}" >/dev/null 2>&1; then
return 0
fi
sleep 0.1
done
fail "${path} expected HTTP ${expected} and ${expression}, got ${status}; response: $(tr '\n' ' ' <"${response_file}")"
}
wait_json "/readyz" '.ready == true and (.resource_root | endswith("/versions/v-current"))' \
"${REPORT_DIR}/ready-initial.json"
curl -fsS "${BASE_URL}/v1/bootstrap" >"${REPORT_DIR}/bootstrap-initial.json"
jq -e '
.ready == true and
.policy.pull_owner == "rust_bat" and
.policy.resource_root_discovery == "bat_sock_rpc" and
.resource.release.version_id == "v-current"
' "${REPORT_DIR}/bootstrap-initial.json" >/dev/null
curl -fsS "${BASE_URL}/v1/launcher/bootstrap" >"${REPORT_DIR}/launcher-bootstrap-initial.json"
jq -e '
.ready == true and
.policy.package_update_manifest == false and
.policy.downloads_launcher_package == false and
.addressables_catalog_url_root ==
"http://127.0.0.1:'"${PORT}"'/prod-clientpatch.bluearchiveyostar.com/r93_fixture"
' "${REPORT_DIR}/launcher-bootstrap-initial.json" >/dev/null
curl -fsS "${BASE_URL}/api-launcher-jp.yo-star.com/api/launcher/game/config/json" \
>"${REPORT_DIR}/launcher-config-json-initial.json"
jq -e '
.code == 200 and
.data.package_update_manifest == false and
.data.scope == "resource_bootstrap_only"
' "${REPORT_DIR}/launcher-config-json-initial.json" >/dev/null
curl -fsS "${BASE_URL}/v1/server-info" >"${REPORT_DIR}/server-info-initial.json"
jq -e '
.ConnectionGroups[0].AddressablesCatalogUrlRoot ==
"http://127.0.0.1:'"${PORT}"'/prod-clientpatch.bluearchiveyostar.com/r93_fixture"
' "${REPORT_DIR}/server-info-initial.json" >/dev/null
CDN_PATH="/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"
curl -fsS "${BASE_URL}${CDN_PATH}" >"${REPORT_DIR}/cdn-full.bytes"
[[ "$(cat "${REPORT_DIR}/cdn-full.bytes")" == "TABLE_CATALOG_FIXTURE" ]] || fail "CDN full body mismatch"
curl -fsSI "${BASE_URL}${CDN_PATH}" >"${REPORT_DIR}/cdn-head.headers"
grep -Fqi 'content-length: 21' "${REPORT_DIR}/cdn-head.headers" || fail "CDN HEAD content length missing"
grep -Fqi 'cache-control: public, max-age=31536000, immutable' "${REPORT_DIR}/cdn-head.headers" ||
fail "CDN cache header missing"
curl -fsS -D "${REPORT_DIR}/cdn-range.headers" \
-H 'Range: bytes=0-3' \
-o "${REPORT_DIR}/cdn-range.bytes" \
"${BASE_URL}${CDN_PATH}"
grep -Fq '206 Partial Content' "${REPORT_DIR}/cdn-range.headers" || fail "CDN range did not return 206"
[[ "$(cat "${REPORT_DIR}/cdn-range.bytes")" == "TABL" ]] || fail "CDN range body mismatch"
wait_http_status "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/missing.bin" "404" \
"${REPORT_DIR}/cdn-missing.response"
wait_http_status \
"/prod-clientpatch.bluearchiveyostar.com/%2e%2e/yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" \
"404" \
"${REPORT_DIR}/cdn-escape.response" \
--path-as-is
printf '==> switch current release through the local published state\n'
mkdir -p "${OUTPUT_DIR}/versions/v-next"
cp -a "${FIXTURE_ROOT}/." "${OUTPUT_DIR}/versions/v-next/"
write_version_state "v-next" "${OUTPUT_DIR}/versions/v-next"
switch_current "v-next"
wait_json "/readyz" '.ready == true and (.resource_root | endswith("/versions/v-next"))' \
"${REPORT_DIR}/ready-after-switch.json"
curl -fsS "${BASE_URL}/v1/bootstrap" >"${REPORT_DIR}/bootstrap-after-switch.json"
jq -e '.ready == true and .resource.release.version_id == "v-next"' \
"${REPORT_DIR}/bootstrap-after-switch.json" >/dev/null
printf '==> publish a size-mismatched release and require not-ready\n'
mkdir -p "${OUTPUT_DIR}/versions/v-broken"
cp -a "${FIXTURE_ROOT}/." "${OUTPUT_DIR}/versions/v-broken/"
printf 'intentional size mismatch\n' >"${OUTPUT_DIR}/versions/v-broken/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"
write_version_state "v-broken" "${OUTPUT_DIR}/versions/v-broken"
switch_current "v-broken"
wait_json_status "/readyz" "503" \
'.ready == false and .present_count == 1 and .missing_count == 1 and (.resource_root | endswith("/versions/v-broken"))' \
"${REPORT_DIR}/ready-size-mismatch.json"
wait_http_status "${CDN_PATH}" "404" "${REPORT_DIR}/cdn-size-mismatch.response"
printf '==> publish a manifest-missing release and require not-ready\n'
mkdir -p "${OUTPUT_DIR}/versions/v-no-manifest"
cp -a "${FIXTURE_ROOT}/." "${OUTPUT_DIR}/versions/v-no-manifest/"
mv "${OUTPUT_DIR}/versions/v-no-manifest/official-download-manifest.json" \
"${OUTPUT_DIR}/versions/v-no-manifest/official-download-manifest.json.disabled"
write_version_state "v-no-manifest" "${OUTPUT_DIR}/versions/v-no-manifest"
switch_current "v-no-manifest"
wait_json_status "/readyz" "503" \
'.ready == false and .resource_root == ""' \
"${REPORT_DIR}/ready-manifest-missing.json"
printf '==> publish no-release state and require not-ready\n'
write_empty_version_state
wait_json_status "/readyz" "503" '.ready == false and .resource_root == ""' \
"${REPORT_DIR}/ready-no-release.json"
switch_current "v-next"
write_version_state "v-next" "${OUTPUT_DIR}/versions/v-next"
wait_json "/readyz" '.ready == true and (.resource_root | endswith("/versions/v-next"))' \
"${REPORT_DIR}/ready-after-restore.json"
printf '==> disconnect and reconnect bat.sock\n'
stop_daemon
for _ in $(seq 1 100); do
[[ ! -S "${SOCKET_PATH}" ]] && break
sleep 0.1
done
wait_json "/healthz" '.rpc_available == false and .ready == false' \
"${REPORT_DIR}/health-rpc-disconnected.json"
wait_http_status "/readyz" "503" "${REPORT_DIR}/ready-rpc-disconnected.json"
BAT_AUTO_DISCOVER=0 "${BAT_BIN}" \
--server-info-path "${FIXTURE_ROOT}/yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" \
--connection-group Prod \
--app-version 1.70.0 \
--output "${OUTPUT_DIR}" \
--localized-output "${LOCALIZED_DIR}" \
--state-dir "${STATE_DIR}" \
--interval 1h \
--error-retry 1s \
--platforms Windows \
--curl "${LOCAL_CURL}" \
--daemon \
--json \
--no-progress \
--no-banner \
>"${REPORT_DIR}/daemon-restart.json" 2>"${REPORT_DIR}/daemon-restart.log"
for _ in $(seq 1 100); do
[[ -S "${SOCKET_PATH}" ]] && break
sleep 0.1
done
[[ -S "${SOCKET_PATH}" ]] || fail "Rust bat did not recreate ${SOCKET_PATH}"
wait_json "/readyz" '.ready == true and (.resource_root | endswith("/versions/v-next"))' \
"${REPORT_DIR}/ready-after-reconnect.json"
curl -fsS "${BASE_URL}/healthz" >"${REPORT_DIR}/health-final.json"
jq -e '.ok == true and .rpc_available == true and .ready == true' \
"${REPORT_DIR}/health-final.json" >/dev/null
cat >"${REPORT_DIR}/SMOKE_REPORT.md" <<EOF
# bat-api Local Live Smoke Report
- generated_at_utc: ${timestamp}
- bat: ${BAT_BIN}
- bat_api: ${BAT_API_BIN}
- state_dir: ${STATE_DIR}
- official_output: ${OUTPUT_DIR}
- socket: ${SOCKET_PATH}
- listen: ${BASE_URL}
## Covered
- same-host Rust \`bat --daemon\` and Go \`bat-api\` over \`bat.sock\`
- initial ready and resource bootstrap
- server-info Addressables root rewrite
- CDN GET, HEAD, Range, ETag/cache semantics and missing/escape paths
- atomic \`current\` release switch
- size-mismatched or manifest-missing release -> \`/readyz\` 503 and no stale index
- no published release -> \`/readyz\` 503 and empty index
- RPC disconnect -> \`/healthz\` reports unavailable and \`/readyz\` 503
- daemon restart and release rediscovery
All inputs are isolated under \`${SMOKE_ROOT}\`; no official network or client
directory is used.
EOF
printf 'LOCAL_BAT_API_LIVE_SMOKE_OK\n'
printf 'Smoke report: %s\n' "${REPORT_DIR}/SMOKE_REPORT.md"