mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
fix(bat-api): 完成 issue #19 同机 live 联调
This commit is contained in:
+3
-2
@@ -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
@@ -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 循环互斥;任务历史持久化于 `<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,不走 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 联调已完成。
|
||||
|
||||
+15
-11
@@ -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 引擎级解析。
|
||||
|
||||
@@ -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
@@ -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。
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -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(¤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<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(¤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)
|
||||
}
|
||||
|
||||
@@ -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-<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 不证明官方网络可达或官方全量资源下载成功。
|
||||
@@ -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`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 或应急只读诊断。
|
||||
|
||||
### 常用聚焦命令
|
||||
|
||||
@@ -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` 独立负责。
|
||||
|
||||
@@ -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 查询面扩展。
|
||||
|
||||
+10
-12
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+394
@@ -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"
|
||||
Reference in New Issue
Block a user