From 7f465523e11c42f496bcd01baef929abee1c2e44 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Sat, 29 Aug 2026 22:54:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(bat-api):=20=E5=AE=8C=E6=88=90=20issue=20#1?= =?UTF-8?q?9=20=E5=90=8C=E6=9C=BA=20live=20=E8=81=94=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 +- CURRENT_STATUS.md | 10 +- DOCS_INDEX.md | 26 +- Makefile | 6 +- PROJECT_PLAN.md | 8 +- README.md | 1 + core/src/domain/game_client.rs | 60 +-- docs/guides/bat-api-local-live-smoke.md | 40 ++ docs/guides/deployment.md | 8 +- docs/guides/development.md | 5 +- docs/reference/rpc-backend-api.md | 5 +- .../BAT_API_CONTRACT_FIXTURE_HANDOFF.md | 13 +- docs/reports/CURRENT_GAPS.md | 11 +- docs/reports/GO_STATUS.md | 22 +- infrastructure/src/downloader.rs | 8 +- infrastructure/src/lib.rs | 13 +- infrastructure/src/translation_tasks.rs | 60 +-- internal/api/api_test.go | 144 +++++++ internal/api/middleware.go | 27 ++ internal/api/release_index.go | 5 +- internal/api/rpc_release.go | 63 +++ scripts/bat-api-local-live-smoke.sh | 394 ++++++++++++++++++ 22 files changed, 819 insertions(+), 115 deletions(-) create mode 100644 docs/guides/bat-api-local-live-smoke.md create mode 100755 scripts/bat-api-local-live-smoke.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 645d820..bc6b2ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 状态切换和回滚 diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index ab4ccac..75a89f4 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -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` 独立跟踪。 - Web(G-010)。 --- @@ -292,7 +292,7 @@ GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实 下一阶段必须优先完成: 1. Issue #1(P0,主体已实现):`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 循环互斥;任务历史持久化于 `/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,不走 FFI(FFI 降级说明见 `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 path;issue #19 剩余)。 +2. 真实官方网络全量下载长期运行报告(`make official-smoke`);issue #19 的同机 live 联调已完成。 3. 跟进官方同步长期运行测试报告。 4. AssetBundle / Addressables(issue #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 联调已完成。 diff --git a/DOCS_INDEX.md b/DOCS_INDEX.md index 494c23e..d4d9cbe 100644 --- a/DOCS_INDEX.md +++ b/DOCS_INDEX.md @@ -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 引擎级解析。 diff --git a/Makefile b/Makefile index cb120f4..16a6815 100644 --- a/Makefile +++ b/Makefile @@ -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 + # ============================================================================ # 代码质量 # ============================================================================ diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 94f00a4..d76915b 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -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。 diff --git a/README.md b/README.md index b82393b..f66c692 100644 --- a/README.md +++ b/README.md @@ -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) --- diff --git a/core/src/domain/game_client.rs b/core/src/domain/game_client.rs index d77bb10..6a749b7 100644 --- a/core/src/domain/game_client.rs +++ b/core/src/domain/game_client.rs @@ -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 { + 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 { + 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 { + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component.as_os_str()); + match fs::symlink_metadata(¤t) { + 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 { - 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 { - 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 { - let mut current = PathBuf::new(); - for component in path.components() { - current.push(component.as_os_str()); - match fs::symlink_metadata(¤t) { - 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) -} diff --git a/docs/guides/bat-api-local-live-smoke.md b/docs/guides/bat-api-local-live-smoke.md new file mode 100644 index 0000000..9cff2c6 --- /dev/null +++ b/docs/guides/bat-api-local-live-smoke.md @@ -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` 和 manifest;Go 不读取 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-/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 不证明官方网络可达或官方全量资源下载成功。 diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index 5243b66..5a2d8d6 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -356,7 +356,7 @@ sudo -u bat tar -C /var/lib/bluearchive-toolkit/official \ ## 模式 4:bat-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`。 --- diff --git a/docs/guides/development.md b/docs/guides/development.md index dd245b9..5949c69 100644 --- a/docs/guides/development.md +++ b/docs/guides/development.md @@ -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 或应急只读诊断。 ### 常用聚焦命令 diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index 95ea939..4b5b23b 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -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` 的官方网络全量下载验证。 禁止事项: diff --git a/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md b/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md index 1acd3e6..dd5c506 100644 --- a/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md +++ b/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md @@ -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` 独立负责。 diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 20b9cc5..06568ff 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -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 path;SSH 实勘可后置) +- 同机 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-007(P1):Addressables 可校验字段。 7. issue #3 / G-005(P1):UnityFS 容器基础解析已落地;对象级引擎解析继续跟踪 G-005。 8. G-011:翻译任务状态、CAS 诊断和 ResourceRepository 查询面扩展。 diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index 17578a4..3cf44f4 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -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) diff --git a/infrastructure/src/downloader.rs b/infrastructure/src/downloader.rs index 2fb1935..aa0edeb 100644 --- a/infrastructure/src/downloader.rs +++ b/infrastructure/src/downloader.rs @@ -24,6 +24,10 @@ pub trait DownloaderBackend: Send + Sync { fn download(&self, task: T) -> Result; } +/// Results returned by a scheduler for a task type and backend. +pub type DownloadResults = + Vec>::Output, >::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(self, backend: &B, tasks: Vec) -> Vec> + pub fn execute(self, backend: &B, tasks: Vec) -> DownloadResults where T: Send + 'static, B: DownloaderBackend, @@ -78,7 +82,7 @@ impl DownloadScheduler { backend: &B, tasks: Vec, mut observer: F, - ) -> Result>, E> + ) -> Result, E> where T: Send + 'static, B: DownloaderBackend, diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index ded8479..9e83e6b 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -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::{ diff --git a/infrastructure/src/translation_tasks.rs b/infrastructure/src/translation_tasks.rs index 026ab55..0ac0361 100644 --- a/infrastructure/src/translation_tasks.rs +++ b/infrastructure/src/translation_tasks.rs @@ -405,6 +405,25 @@ pub struct PersistedTranslationTask { pub provider_run_id: Option, } +/// 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, + /// 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, + /// Provider-side run identifier, if known. + pub provider_run_id: Option, +} + /// 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, - attempt_count: u32, - created_unix_seconds: u64, - updated_unix_seconds: u64, - completed_unix_seconds: Option, - provider_run_id: Option, - ) -> 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, + }, ) } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index f47a7b2..d9fa8bb 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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") diff --git a/internal/api/middleware.go b/internal/api/middleware.go index e420d81..b7d6355 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -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") diff --git a/internal/api/release_index.go b/internal/api/release_index.go index 8f0aab7..0184843 100644 --- a/internal/api/release_index.go +++ b/internal/api/release_index.go @@ -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), } } diff --git a/internal/api/rpc_release.go b/internal/api/rpc_release.go index 7d0e4ee..10e89c8 100644 --- a/internal/api/rpc_release.go +++ b/internal/api/rpc_release.go @@ -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 diff --git a/scripts/bat-api-local-live-smoke.sh b/scripts/bat-api-local-live-smoke.sh new file mode 100755 index 0000000..3ce20d6 --- /dev/null +++ b/scripts/bat-api-local-live-smoke.sh @@ -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" < \`/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"