feat(status): derive `backend` from the request Host header, not a startup env
The previous round (88f0923) threaded a `backend_id` from `BACKEND_ID`/`HOSTNAME`
env vars into `AppState`. That required the operator to manually align each
instance's `BACKEND_ID` with the Cloudflare Worker's `BACKEND_ORIGINS_JSON`, which
is exactly the kind of manual cross-config sync that drifts.
The request's own `Host` header already carries the answer: the Worker load
balancer (`ARCH-LB-WORKER`) preserves the inbound `Host` while rewriting the target
to a backend origin (`joinUrl` builds `origin + path + query`), so `Host` is the
public address the LB routed this instance as. `backend_label_from_headers` reads
it per-request in `setup_status`, trims the default https/http port
(`example.com:443` -> `example.com`) so the label mirrors a bare origin, and returns
`None` when the request carries no usable `Host`.
This removes the startup env entirely — `backend_id_from_env`, `AppState::backend_id`,
all five router-builder `backend_id` parameters, the `.env.example` `BACKEND_ID`
entry, and the `BACKEND_ID` env documentation are all gone. Handlers still never
touch the environment: `Host` is request-scoped, not process-global, so there is no
test race (the `debug_surface_requested` rationale was about env, not headers).
Tests: `setup_status_reports_backend_label` now sends `Host: edge-1.example.com:443`
and asserts the label is the bare origin `edge-1.example.com` (port trimmed). A new
`setup_status_reports_null_backend_without_host` asserts `null` when no Host is sent.
GATE-P0 106 passed / 0 failed / 4 ignored; GATE-DOCS 6/6 clean (49 line-refs).
This commit is contained in:
parent
26e916b79d
commit
34efae84ff
|
|
@ -10,10 +10,5 @@ CONFIG_ENCRYPTION_KEY=REPLACE_WITH_64_HEX_CHAR_RANDOM_KEY
|
||||||
|
|
||||||
# PORT is an optional platform override (default: 8080). There is no RUN_MODE: the
|
# PORT is an optional platform override (default: 8080). There is no RUN_MODE: the
|
||||||
# legacy env parser was removed, and webhook plus reconcile traffic share one router.
|
# legacy env parser was removed, and webhook plus reconcile traffic share one router.
|
||||||
# BACKEND_ID is an optional per-instance observability label echoed as `backend` in
|
|
||||||
# /api/status, so a probe through the load balancer can tell which origin answered
|
|
||||||
# (the baked build `version` is identical across instances of one image). Falls back
|
|
||||||
# to HOSTNAME. Not a secret, not a connection setting, never persisted.
|
|
||||||
BACKEND_ID=
|
|
||||||
# Business secrets (Telegram/JMAP/LLM/auth) must be supplied once via HTTPS /api/bootstrap;
|
# Business secrets (Telegram/JMAP/LLM/auth) must be supplied once via HTTPS /api/bootstrap;
|
||||||
# they are then persisted in Redis and are never echoed by the API.
|
# they are then persisted in Redis and are never echoed by the API.
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ if 带 verificationCode:
|
||||||
- **回调 URL**: `https://messageweave.motofans.club/push/jmap`
|
- **回调 URL**: `https://messageweave.motofans.club/push/jmap`
|
||||||
- **后端仓库**: `/home/okabe/Repo/messageweave`
|
- **后端仓库**: `/home/okabe/Repo/messageweave`
|
||||||
- **双 remote**: `origin`(motofans.club)+ `upstream`(GitHub fork)
|
- **双 remote**: `origin`(motofans.club)+ `upstream`(GitHub fork)
|
||||||
- **测试基线**: Rust 105 passed / 0 failed / 4 ignored,GATE-DOCS 6/6 PASS
|
- **测试基线**: Rust 106 passed / 0 failed / 4 ignored,GATE-DOCS 6/6 PASS
|
||||||
- **最新 commit**: `f9160c0`(已 push 双 remote)
|
- **最新 commit**: `f9160c0`(已 push 双 remote)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \
|
||||||
bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12'
|
bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Current baseline: 105 passed / 0 failed / 4 ignored** (Add 4 cursor/walk tests + 1 `/api/status` backend-label self-report test, retested 2026-10-14; ignored: `real_server_tests::session_list_and_read_smoke` and `debug::tests::debug_config_reports_timezone_of_business_configured_app` require external real JMAP Server credentials, `real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` and `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` require `REDIS_TEST_URL`).
|
**Current baseline: 106 passed / 0 failed / 4 ignored** (4 cursor/walk tests + 2 `/api/status` Host-header tests (label echo + null fallback)), retested 2026-10-14; ignored: `real_server_tests::session_list_and_read_smoke` and `debug::tests::debug_config_reports_timezone_of_business_configured_app` require external real JMAP Server credentials, `real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` and `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` require `REDIS_TEST_URL`).
|
||||||
|
|
||||||
**Note**: The bash `-lc` script of `docker run` must be wrapped in **single quotes**. Using double quotes will first expand `$PWD` / `$PATH` on the host, and cargo will not be found in the container.
|
**Note**: The bash `-lc` script of `docker run` must be wrapped in **single quotes**. Using double quotes will first expand `$PWD` / `$PATH` on the host, and cargo will not be found in the container.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \
|
||||||
bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12'
|
bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12'
|
||||||
```
|
```
|
||||||
|
|
||||||
**当前基线:105 passed / 0 failed / 4 ignored**(新增 4 个游标解析与 walk 分页单测 + 1 个 `/api/status` backend 标签自报单测后复测,2026-10-14;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app` 需外部真实 JMAP 服务器凭据,`real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` 与 `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` 需 `REDIS_TEST_URL`)。
|
**当前基线:106 passed / 0 failed / 4 ignored**(4 个游标解析与 walk 分页单测 + 2 个 `/api/status` Host 头单测(标签回显 + null 回退))后复测,2026-10-14;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app` 需外部真实 JMAP 服务器凭据,`real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` 与 `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` 需 `REDIS_TEST_URL`)。
|
||||||
|
|
||||||
**注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。
|
**注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -366,7 +366,7 @@ Testing: **No integrated test directory**, and no stand-alone util tool module.
|
||||||
> Compatibility instructions.
|
> Compatibility instructions.
|
||||||
|
|
||||||
> **Actual implementation** (`ARCH-CONFIG-ENV`): process startup read-only **2** process-level credentials plus listening port (all via `std::env::var` in `main`, keyed by `REDIS_URL` / `CONFIG_ENCRYPTION_KEY` / `PORT`,
|
> **Actual implementation** (`ARCH-CONFIG-ENV`): process startup read-only **2** process-level credentials plus listening port (all via `std::env::var` in `main`, keyed by `REDIS_URL` / `CONFIG_ENCRYPTION_KEY` / `PORT`,
|
||||||
> plus optional `DEBUG_TOKEN` and the `/api/status` label `BACKEND_ID`), **do not use figment, do not use TOML/configuration file**.
|
> plus optional `DEBUG_TOKEN`), **do not use figment, do not use TOML/configuration file**.
|
||||||
> **Business configuration is no longer read from environment variables**: The fields in the following table are all loaded by the `config` layer using serde deserialization through the Redis business configuration.
|
> **Business configuration is no longer read from environment variables**: The fields in the following table are all loaded by the `config` layer using serde deserialization through the Redis business configuration.
|
||||||
> And do fail-closed verification through `validate_business_wire` / `validate_nonblank`.
|
> And do fail-closed verification through `validate_business_wire` / `validate_nonblank`.
|
||||||
> Historically existing `Config::from_env()` full env parsing path **removed** (see `docs/retired.md` for registration),
|
> Historically existing `Config::from_env()` full env parsing path **removed** (see `docs/retired.md` for registration),
|
||||||
|
|
@ -420,7 +420,7 @@ Testing: **No integrated test directory**, and no stand-alone util tool module.
|
||||||
|
|
||||||
### 7.3.1 Configuration Management API
|
### 7.3.1 Configuration Management API
|
||||||
- `GET /` provides a SPA embedded in the Rust binary; `/assets/config.js` and `/assets/styles.css` provide page resources. The service does not read or write local files while running (`C-NO-LOCAL-WRITE`).
|
- `GET /` provides a SPA embedded in the Rust binary; `/assets/config.js` and `/assets/styles.css` provide page resources. The service does not read or write local files while running (`C-NO-LOCAL-WRITE`).
|
||||||
- `GET /api/status` publicly returns `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string, "backend": string|null }`, which only lists the missing environment variable names. When `REDIS_URL` or `CONFIG_ENCRYPTION_KEY` is missing, SPA only displays the configuration boot status and missing variables; the management session authorization area is displayed only after the service status is confirmed to be ready=true. `version` is the `BUILD_VERSION` (`<git-sha or nogit>+<UTC build time>`) baked by `build.rs` during compilation. The SPA no longer renders it, so the API is the only way to confirm a deployment landed. `backend` is the per-instance label read once at startup from `BACKEND_ID` (falling back to `HOSTNAME`); `version` is identical across instances built from the same image, so `backend` is what lets a probe through the load balancer tell which origin answered. `null` means no label was configured (both env vars unset); two such instances are indistinguishable by this field by design.
|
- `GET /api/status` publicly returns `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string, "backend": string|null }`, which only lists the missing environment variable names. When `REDIS_URL` or `CONFIG_ENCRYPTION_KEY` is missing, SPA only displays the configuration boot status and missing variables; the management session authorization area is displayed only after the service status is confirmed to be ready=true. `version` is the `BUILD_VERSION` (`<git-sha or nogit>+<UTC build time>`) baked by `build.rs` during compilation. The SPA no longer renders it, so the API is the only way to confirm a deployment landed. `backend` echoes the request `Host` header (trimmed of the default https/http port); when the request comes through the Cloudflare Worker load balancer (`ARCH-LB-WORKER`), that header is the public address the LB routed this instance as — which is what lets a probe through the load balancer tell which origin answered. The label is derived per-request, not from any startup env, so it never drifts from the LB config. `null` means the request carried no usable `Host`.
|
||||||
- `POST /api/admin/session` accepts `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>` and returns `{ "session": "<opaque>", "expires_in": 1800 }`; the admin session only saves the digest in Redis and expires after 1,800 seconds. `POST /api/admin/session/revoke` revokes the current session and returns `204` successfully.
|
- `POST /api/admin/session` accepts `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>` and returns `{ "session": "<opaque>", "expires_in": 1800 }`; the admin session only saves the digest in Redis and expires after 1,800 seconds. `POST /api/admin/session/revoke` revokes the current session and returns `204` successfully.
|
||||||
- The admin page only saves the session in JavaScript memory. Request settings `credentials: omit`, `cache: no-store`, without using cookies, localStorage or sessionStorage. The admin API accepts a valid admin session; the compatibility path also accepts `WORKER_TOKEN`. Worker transparently transmits the authentication header (`SAF-LB-PASSTHRU`) as it is.
|
- The admin page only saves the session in JavaScript memory. Request settings `credentials: omit`, `cache: no-store`, without using cookies, localStorage or sessionStorage. The admin API accepts a valid admin session; the compatibility path also accepts `WORKER_TOKEN`. Worker transparently transmits the authentication header (`SAF-LB-PASSTHRU`) as it is.
|
||||||
- `GET /api/config` and `PUT /api/config` only read and write non-sensitive running parameters:
|
- `GET /api/config` and `PUT /api/config` only read and write non-sensitive running parameters:
|
||||||
|
|
@ -560,7 +560,7 @@ The design target form once included six additional variants: `Jmap(#[from] jmap
|
||||||
- axum adopts **0.8** (`ARCH-AXUM-08`); if subsequent review decides to adjust the version, Cargo.toml will prevail and this section will be synchronized.
|
- axum adopts **0.8** (`ARCH-AXUM-08`); if subsequent review decides to adjust the version, Cargo.toml will prevail and this section will be synchronized.
|
||||||
- Currently SSE/WebSocket/long polling/SQLite/local volumes (`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`) are not implemented; entrance authentication (`R1`/`SAF-AUTH-*`) is implemented as a fail-closed hard access control, and the Push, Streams worker and `/reconcile` business paths after authentication have been implemented.
|
- Currently SSE/WebSocket/long polling/SQLite/local volumes (`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`) are not implemented; entrance authentication (`R1`/`SAF-AUTH-*`) is implemented as a fail-closed hard access control, and the Push, Streams worker and `/reconcile` business paths after authentication have been implemented.
|
||||||
|
|
||||||
**Phase 0 P0 Gate (`GATE-P0`) - Passed. It was 45 passed / 0 failed / 1 ignored when it was first frozen, and has continued to grow in subsequent rounds ④–⑥. The current baseline is 105 passed / 0 failed / 4 ignored (2026-10-14 retest; see the `TEST_BASELINE` line of `docs/charter.md` for the authoritative value). The following are the criteria at the time of stage 0, which are retained for historical records: **
|
**Phase 0 P0 Gate (`GATE-P0`) - Passed. It was 45 passed / 0 failed / 1 ignored when it was first frozen, and has continued to grow in subsequent rounds ④–⑥. The current baseline is 106 passed / 0 failed / 4 ignored (2026-10-14 retest; see the `TEST_BASELINE` line of `docs/charter.md` for the authoritative value). The following are the criteria at the time of stage 0, which are retained for historical records: **
|
||||||
1. `cargo fmt --check` passes (no format difference).
|
1. `cargo fmt --check` passes (no format difference).
|
||||||
2. `cargo clippy --all-targets -- -D warnings` passes (zero warnings; crate-level `allow` is disabled).
|
2. `cargo clippy --all-targets -- -D warnings` passes (zero warnings; crate-level `allow` is disabled).
|
||||||
3. `cargo test` passed (including routing/configuration minimum test).
|
3. `cargo test` passed (including routing/configuration minimum test).
|
||||||
|
|
|
||||||
|
|
@ -366,7 +366,7 @@ message-weave/
|
||||||
> 兼容说明。
|
> 兼容说明。
|
||||||
|
|
||||||
> **实际实现**(`ARCH-CONFIG-ENV`):进程启动只读 **2 个**进程级凭据加监听端口(均经 `main` 里的 `std::env::var`,键名 `REDIS_URL` / `CONFIG_ENCRYPTION_KEY` / `PORT`,
|
> **实际实现**(`ARCH-CONFIG-ENV`):进程启动只读 **2 个**进程级凭据加监听端口(均经 `main` 里的 `std::env::var`,键名 `REDIS_URL` / `CONFIG_ENCRYPTION_KEY` / `PORT`,
|
||||||
> 另加可选 `DEBUG_TOKEN` 与 `/api/status` 标签 `BACKEND_ID`),**不使用 figment、不使用 TOML/配置文件**。
|
> 另加可选 `DEBUG_TOKEN`),**不使用 figment、不使用 TOML/配置文件**。
|
||||||
> **业务配置不再从环境变量读取**:下表字段全部经 Redis 业务配置由 `config` 层以 serde 反序列化装载,
|
> **业务配置不再从环境变量读取**:下表字段全部经 Redis 业务配置由 `config` 层以 serde 反序列化装载,
|
||||||
> 并经 `validate_business_wire` / `validate_nonblank` 做 fail-closed 校验。
|
> 并经 `validate_business_wire` / `validate_nonblank` 做 fail-closed 校验。
|
||||||
> 历史上存在的 `Config::from_env()` 完整 env 解析路径**已删除**(登记见 `docs/retired.md`),
|
> 历史上存在的 `Config::from_env()` 完整 env 解析路径**已删除**(登记见 `docs/retired.md`),
|
||||||
|
|
@ -420,7 +420,7 @@ message-weave/
|
||||||
|
|
||||||
### 7.3.1 配置管理 API
|
### 7.3.1 配置管理 API
|
||||||
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
|
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
|
||||||
- `GET /api/status` 公开返回 `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string, "backend": string|null }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。`version` 是 `build.rs` 在编译期烘焙的 `BUILD_VERSION`(`<git-sha 或 nogit>+<UTC 构建时间>`)。SPA 已不再渲染它,确认部署落地只能查这个接口。`backend` 是启动时一次性读取 `BACKEND_ID`(回退到 `HOSTNAME`)的每实例标签;同一镜像构建的实例 `version` 相同,所以 `backend` 才是让穿过负载均衡器的探针分辨命中的是哪个 origin 的字段。`null` 表示未配置标签(两个 env 都未设置);两个都不设的实例在该字段上不可区分,这是设计如此。
|
- `GET /api/status` 公开返回 `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string, "backend": string|null }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。`version` 是 `build.rs` 在编译期烘焙的 `BUILD_VERSION`(`<git-sha 或 nogit>+<UTC 构建时间>`)。SPA 已不再渲染它,确认部署落地只能查这个接口。`backend` 回显请求的 `Host` 头(去掉默认 https/http 端口);请求穿过 Cloudflare Worker 负载均衡器(`ARCH-LB-WORKER`)时,该头就是 LB 把该实例路由为的公开地址——让穿过负载均衡器的探针分辨命中的是哪个 origin。标签逐请求推导,不来自任何启动期 env,永远不会与 LB 配置漂移。`null` 表示请求未携带可用的 `Host`。
|
||||||
- `POST /api/admin/session` 接受 `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>`,返回 `{ "session": "<opaque>", "expires_in": 1800 }`;Redis 中保存的是会话令牌的摘要,30 分钟后过期。`POST /api/admin/session/revoke` 可立即撤销会话,成功返回 `204`。
|
- `POST /api/admin/session` 接受 `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>`,返回 `{ "session": "<opaque>", "expires_in": 1800 }`;Redis 中保存的是会话令牌的摘要,30 分钟后过期。`POST /api/admin/session/revoke` 可立即撤销会话,成功返回 `204`。
|
||||||
- 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。
|
- 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。
|
||||||
- `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数:
|
- `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数:
|
||||||
|
|
@ -560,7 +560,7 @@ pub enum BotError {
|
||||||
- axum 采用 **0.8**(`ARCH-AXUM-08`);如后续审核决定调整版本,以 Cargo.toml 为准并同步本节。
|
- axum 采用 **0.8**(`ARCH-AXUM-08`);如后续审核决定调整版本,以 Cargo.toml 为准并同步本节。
|
||||||
- 当前不实现 SSE/WebSocket/长轮询/SQLite/本地卷(`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`);入口鉴权(`R1`/`SAF-AUTH-*`)作为 fail-closed 硬门禁落地,鉴权之后的 Push、Streams worker 和 `/reconcile` 业务路径已实现。
|
- 当前不实现 SSE/WebSocket/长轮询/SQLite/本地卷(`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`);入口鉴权(`R1`/`SAF-AUTH-*`)作为 fail-closed 硬门禁落地,鉴权之后的 Push、Streams worker 和 `/reconcile` 业务路径已实现。
|
||||||
|
|
||||||
**阶段0 P0 门禁(`GATE-P0`)——已通过。首次冻结时为 45 passed / 0 failed / 1 ignored,此后随 ④–⑥ 轮实现持续增长,当前基线为 105 passed / 0 failed / 4 ignored(2026-10-14 复测;权威值见 `docs/charter.md` 的 `TEST_BASELINE` 行)。以下为阶段0 当时的判据,保留作历史记录:**
|
**阶段0 P0 门禁(`GATE-P0`)——已通过。首次冻结时为 45 passed / 0 failed / 1 ignored,此后随 ④–⑥ 轮实现持续增长,当前基线为 106 passed / 0 failed / 4 ignored(2026-10-14 复测;权威值见 `docs/charter.md` 的 `TEST_BASELINE` 行)。以下为阶段0 当时的判据,保留作历史记录:**
|
||||||
1. `cargo fmt --check` 通过(无格式差异)。
|
1. `cargo fmt --check` 通过(无格式差异)。
|
||||||
2. `cargo clippy --all-targets -- -D warnings` 通过(零告警;禁 crate 级 `allow`)。
|
2. `cargo clippy --all-targets -- -D warnings` 通过(零告警;禁 crate 级 `allow`)。
|
||||||
3. `cargo test` 通过(含路由/配置最小测试)。
|
3. `cargo test` 通过(含路由/配置最小测试)。
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ The TTL green light for real machines has already been passed using the Upstash
|
||||||
| 3 | Telegram inbound / Stalwart `PushSubscription` joint debugging | Closed (real traffic has been driven, please see `README.md` §3.7 for operation steps) |
|
| 3 | Telegram inbound / Stalwart `PushSubscription` joint debugging | Closed (real traffic has been driven, please see `README.md` §3.7 for operation steps) |
|
||||||
| 4 | `/worker` is not in the deployment document | Closed (`README.md` §3.10 has been added) |
|
| 4 | `/worker` is not in the deployment document | Closed (`README.md` §3.10 has been added) |
|
||||||
|
|
||||||
Access control: `cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` all green; `cargo test --locked` **105 passed / 0 failed / 4 ignored** (2 TTL assertions out of 4 `#[ignore]` have been run with real Upstash: `REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**; the remaining 2 require real JMAP servers, this capability has been verified in staging joint debugging); documentation gate is 6/6 PASS, including 49 line number anchor non-null verifications.
|
Access control: `cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` all green; `cargo test --locked` **106 passed / 0 failed / 4 ignored** (2 TTL assertions out of 4 `#[ignore]` have been run with real Upstash: `REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**; the remaining 2 require real JMAP servers, this capability has been verified in staging joint debugging); documentation gate is 6/6 PASS, including 49 line number anchor non-null verifications.
|
||||||
|
|
||||||
Item 5 is not within the stage goal: multi-account product decision-making, which has been determined not to be done by the user on 2026-09-28. The decision boundary and future changes will be discussed in §3.
|
Item 5 is not within the stage goal: multi-account product decision-making, which has been determined not to be done by the user on 2026-09-28. The decision boundary and future changes will be discussed in §3.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1),
|
||||||
| 3 | Telegram 入站 / Stalwart `PushSubscription` 联调 | 已关闭(真实流量已驱动,操作步骤见 `README.md` §3.7) |
|
| 3 | Telegram 入站 / Stalwart `PushSubscription` 联调 | 已关闭(真实流量已驱动,操作步骤见 `README.md` §3.7) |
|
||||||
| 4 | `/worker` 未在部署文档里 | 已关闭(`README.md` §3.10 已补) |
|
| 4 | `/worker` 未在部署文档里 | 已关闭(`README.md` §3.10 已补) |
|
||||||
|
|
||||||
门禁:`cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` 全绿;`cargo test --locked` **105 passed / 0 failed / 4 ignored**(4 个 `#[ignore]` 里 2 个 TTL 断言已用真实 Upstash 跑过:`REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**;剩 2 个需要真实 JMAP 服务器,该能力已在 staging 联调验证);文档门 6/6 全绿,含 49 处行号锚点非空校验。
|
门禁:`cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` 全绿;`cargo test --locked` **106 passed / 0 failed / 4 ignored**(4 个 `#[ignore]` 里 2 个 TTL 断言已用真实 Upstash 跑过:`REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**;剩 2 个需要真实 JMAP 服务器,该能力已在 staging 联调验证);文档门 6/6 全绿,含 49 处行号锚点非空校验。
|
||||||
|
|
||||||
第 5 项不在阶段目标内:多账号产品决策,2026-09-28 已由用户定为不做,决定边界与将来要做的改动面见 §3。
|
第 5 项不在阶段目标内:多账号产品决策,2026-09-28 已由用户定为不做,决定边界与将来要做的改动面见 §3。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -281,12 +281,13 @@ Response conventions:
|
||||||
safe to poll. `version` is the `BUILD_VERSION` string baked in by `build.rs`
|
safe to poll. `version` is the `BUILD_VERSION` string baked in by `build.rs`
|
||||||
(`<git-sha-or-nogit>+<UTC build time>`) — identical across every instance built from the same
|
(`<git-sha-or-nogit>+<UTC build time>`) — identical across every instance built from the same
|
||||||
image, so it answers "did this deploy land?" but not "which instance answered?". `backend`
|
image, so it answers "did this deploy land?" but not "which instance answered?". `backend`
|
||||||
answers that: a per-instance label read once at startup from `BACKEND_ID` (falling back to
|
answers that: the `Host` header of the request itself, trimmed of the default https/http
|
||||||
`HOSTNAME`, which container runtimes set), threaded into `AppState::backend_id` and echoed
|
port (`example.com:443` -> `example.com`). When the request comes through the Cloudflare
|
||||||
here. It is an observability label, not a connection secret and not persisted. `null` means
|
Worker load balancer (`ARCH-LB-WORKER`), that header is the public address the LB routed
|
||||||
neither env var was set; two such instances are indistinguishable by this field, so a probe
|
this instance as — which is exactly what a probe needs to tell origins apart. No startup
|
||||||
that needs to tell origins apart must set `BACKEND_ID` per instance at deploy time. The SPA
|
env var is involved: the label is derived per-request, so it never drifts from the LB
|
||||||
no longer renders `version` — the API is now the only way to confirm a deploy landed.
|
config. `null` means the request carried no usable `Host` (e.g. a bare health check). The
|
||||||
|
SPA no longer renders `version` — the API is now the only way to confirm a deploy landed.
|
||||||
- `/debug/*` returns **503** in exactly one place: `POST /debug/notify` when the business
|
- `/debug/*` returns **503** in exactly one place: `POST /debug/notify` when the business
|
||||||
config is not loaded or there is no outbound client — `service_unavailable` with
|
config is not loaded or there is no outbound client — `service_unavailable` with
|
||||||
`Retry-After: 30`. The three probe endpoints instead report failure
|
`Retry-After: 30`. The three probe endpoints instead report failure
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,7 @@ env var — 并且“DEBUG_TOKEN”非空**(“SAF-DEBUG-GATE”;门被读
|
||||||
`{"ready": <bool>, "mode": "configured"|"configuration-setup", "missing": [...], "version": "<build-fingerprint>", "backend": "<id>"|null}`。
|
`{"ready": <bool>, "mode": "configured"|"configuration-setup", "missing": [...], "version": "<build-fingerprint>", "backend": "<id>"|null}`。
|
||||||
`ready` 为 `false`、`missing` 列出缺少的必需变量名(`REDIS_URL`、
|
`ready` 为 `false`、`missing` 列出缺少的必需变量名(`REDIS_URL`、
|
||||||
`CONFIG_ENCRYPTION_KEY`)——路由本身永不报错,可安全轮询。`version` 是 `build.rs` 烘焙的 `BUILD_VERSION` 字符串
|
`CONFIG_ENCRYPTION_KEY`)——路由本身永不报错,可安全轮询。`version` 是 `build.rs` 烘焙的 `BUILD_VERSION` 字符串
|
||||||
(`<git-sha 或 nogit>+<UTC 构建时间>`)——同一镜像构建的每个实例都相同,因此它回答「本次部署是否落地」,但不回答「命中的是哪个实例」。`backend` 回答后者:每实例标签,启动时一次性读取 `BACKEND_ID`(回退容器运行时设置的 `HOSTNAME`),穿入 `AppState::backend_id` 并在此回显。它是观测标签,不是连接密钥,也不持久化。`null` 表示两个 env 都未设置;两个都不设的实例在该字段上不可区分,所以需要分辨 origin 的探针必须在部署时为每个实例设置 `BACKEND_ID`。SPA 已不再渲染 `version`,确认部署落地只能查这个接口。
|
(`<git-sha 或 nogit>+<UTC 构建时间>`)——同一镜像构建的每个实例都相同,因此它回答「本次部署是否落地」,但不回答「命中的是哪个实例」。`backend` 回答后者:请求本身的 `Host` 头,去掉默认 https/http 端口(`example.com:443` -> `example.com`)。请求穿过 Cloudflare Worker 负载均衡器(`ARCH-LB-WORKER`)时,该头就是 LB 把该实例路由为的公开地址——正是探针分辨 origin 所需。不涉及启动期 env:标签逐请求推导,永远不会与 LB 配置漂移。`null` 表示请求未携带可用的 `Host`(如裸健康检查)。SPA 已不再渲染 `version`,确认部署落地只能查这个接口。
|
||||||
- `/debug/*` 在一个地方返回 **503**:业务执行时`POST /debug/notify`
|
- `/debug/*` 在一个地方返回 **503**:业务执行时`POST /debug/notify`
|
||||||
配置未加载或没有出站客户端 — `service_unavailable`
|
配置未加载或没有出站客户端 — `service_unavailable`
|
||||||
`重试时间:30`。三个探测端点反而报告失败
|
`重试时间:30`。三个探测端点反而报告失败
|
||||||
|
|
|
||||||
|
|
@ -313,7 +313,6 @@ mod tests {
|
||||||
state,
|
state,
|
||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
Arc::new(NoopWorker),
|
Arc::new(NoopWorker),
|
||||||
None,
|
|
||||||
runtime_provider(OutboundConfig::default()),
|
runtime_provider(OutboundConfig::default()),
|
||||||
SecretString::new(String::new()),
|
SecretString::new(String::new()),
|
||||||
debug_token,
|
debug_token,
|
||||||
|
|
|
||||||
21
src/main.rs
21
src/main.rs
|
|
@ -26,23 +26,6 @@ fn debug_surface_requested(debug_arg: bool, debug_enabled_var: Option<&str>) ->
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-instance observability label for `/api/status` (see `AppState::backend_id`). Read once at
|
|
||||||
/// startup so request handlers never touch the environment. `BACKEND_ID` wins; otherwise
|
|
||||||
/// `HOSTNAME` (set by container runtimes) gives out-of-the-box distinction between instances.
|
|
||||||
/// `None` only when both are unset, so a probe honestly reports "not labeled" instead of a
|
|
||||||
/// misleading default — two instances that both skip it are indistinguishable by design until an
|
|
||||||
/// operator sets `BACKEND_ID`, which is a deploy-time concern, not a connection secret.
|
|
||||||
fn backend_id_from_env() -> Option<String> {
|
|
||||||
std::env::var("BACKEND_ID")
|
|
||||||
.ok()
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.or_else(|| {
|
|
||||||
std::env::var("HOSTNAME")
|
|
||||||
.ok()
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
use crate::channel::telegram::TelegramClient;
|
use crate::channel::telegram::TelegramClient;
|
||||||
use crate::domain::jmap::{client::JmapClientBackend, JmapService};
|
use crate::domain::jmap::{client::JmapClientBackend, JmapService};
|
||||||
use config::{encryption_key_from_env, Config};
|
use config::{encryption_key_from_env, Config};
|
||||||
|
|
@ -67,7 +50,6 @@ async fn main() -> Result<(), error::BotError> {
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|e| error::BotError::Config(format!("invalid PORT: {e}")))?;
|
.map_err(|e| error::BotError::Config(format!("invalid PORT: {e}")))?;
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
let backend_id = backend_id_from_env();
|
|
||||||
let mut missing = Vec::new();
|
let mut missing = Vec::new();
|
||||||
let redis_value = match std::env::var("REDIS_URL") {
|
let redis_value = match std::env::var("REDIS_URL") {
|
||||||
Ok(value) if !value.trim().is_empty() => value,
|
Ok(value) if !value.trim().is_empty() => value,
|
||||||
|
|
@ -91,7 +73,7 @@ async fn main() -> Result<(), error::BotError> {
|
||||||
?missing,
|
?missing,
|
||||||
"required bootstrap environment is incomplete; serving setup mode"
|
"required bootstrap environment is incomplete; serving setup mode"
|
||||||
);
|
);
|
||||||
let app = router_configuration_setup(missing, backend_id).merge(web::router());
|
let app = router_configuration_setup(missing).merge(web::router());
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -216,7 +198,6 @@ async fn main() -> Result<(), error::BotError> {
|
||||||
redis,
|
redis,
|
||||||
allowlist,
|
allowlist,
|
||||||
Arc::new(WorkerHandle::new(worker)),
|
Arc::new(WorkerHandle::new(worker)),
|
||||||
backend_id,
|
|
||||||
runtime,
|
runtime,
|
||||||
admin_token,
|
admin_token,
|
||||||
debug_token,
|
debug_token,
|
||||||
|
|
|
||||||
|
|
@ -104,11 +104,6 @@ pub(crate) struct AppState {
|
||||||
/// `--debug` AND `DEBUG_TOKEN` was set. `debug_router` is mounted iff this is `Some`, so the
|
/// `--debug` AND `DEBUG_TOKEN` was set. `debug_router` is mounted iff this is `Some`, so the
|
||||||
/// `/debug/*` surface is absent by default. `SAF-DEBUG-AUTH`: compared in constant time.
|
/// `/debug/*` surface is absent by default. `SAF-DEBUG-AUTH`: compared in constant time.
|
||||||
pub(crate) debug_token: Option<SecretString>,
|
pub(crate) debug_token: Option<SecretString>,
|
||||||
/// Per-instance observability label echoed in `/api/status`, so a probe through the load
|
|
||||||
/// balancer can tell which origin answered — `version` alone is identical across instances
|
|
||||||
/// built from the same image. Read once at startup from `BACKEND_ID` (falls back to
|
|
||||||
/// `HOSTNAME`); not a connection secret, not persisted. `None` only when both are unset.
|
|
||||||
pub(crate) backend_id: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<AuthSecrets> for AuthState {
|
impl From<AuthSecrets> for AuthState {
|
||||||
|
|
@ -218,18 +213,45 @@ async fn ready(State(app): State<AppState>) -> Response {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn setup_status(State(app): State<AppState>) -> Response {
|
async fn setup_status(State(app): State<AppState>, headers: HeaderMap) -> Response {
|
||||||
let ready = app.setup_missing.is_empty();
|
let ready = app.setup_missing.is_empty();
|
||||||
axum::Json(serde_json::json!({
|
axum::Json(serde_json::json!({
|
||||||
"ready": ready,
|
"ready": ready,
|
||||||
"mode": if ready { "configured" } else { "configuration-setup" },
|
"mode": if ready { "configured" } else { "configuration-setup" },
|
||||||
"missing": app.setup_missing.as_ref(),
|
"missing": app.setup_missing.as_ref(),
|
||||||
"version": BUILD_VERSION,
|
"version": BUILD_VERSION,
|
||||||
"backend": &app.backend_id,
|
"backend": backend_label_from_headers(&headers),
|
||||||
}))
|
}))
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The host this request arrived for, echoed as `backend` in `/api/status`.
|
||||||
|
///
|
||||||
|
/// When the request comes through the Cloudflare Worker load balancer
|
||||||
|
/// (`ARCH-LB-WORKER`), that Worker preserves the inbound `Host` header while rewriting the
|
||||||
|
/// target to a backend origin (`joinUrl` builds `origin + path + query`), so `Host` is exactly
|
||||||
|
/// the public address the load balancer routed this instance as — which is what a probe needs
|
||||||
|
/// to tell "which backend answered" when several instances sit behind one hostname. That is
|
||||||
|
/// more useful than the baked `BUILD_VERSION`, which is identical across every instance built
|
||||||
|
/// from the same image. The port is dropped when it is the scheme default
|
||||||
|
/// (`example.com:443` -> `example.com`) so the reported label matches the configured origin.
|
||||||
|
/// `None` when the request carries no usable `Host`.
|
||||||
|
fn backend_label_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||||
|
let raw = headers.get(header::HOST)?.to_str().ok()?.trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Trim the default https/http port so the label mirrors a bare origin.
|
||||||
|
let host = match raw.rsplit_once(':') {
|
||||||
|
Some((host, port)) if port == "443" || port == "80" => host,
|
||||||
|
_ => raw,
|
||||||
|
};
|
||||||
|
if host.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(host.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
async fn telegram_webhook(
|
async fn telegram_webhook(
|
||||||
State(app): State<AppState>,
|
State(app): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
|
@ -1930,7 +1952,6 @@ pub fn router_with_state<S: ReliableState + 'static>(
|
||||||
state,
|
state,
|
||||||
allowlist,
|
allowlist,
|
||||||
Arc::new(NoopWorker),
|
Arc::new(NoopWorker),
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1946,7 +1967,7 @@ pub fn router_with_state<S: ReliableState + 'static>(
|
||||||
/// 404 removes that ambiguity for clients, probes and the SPA alike. The SPA bootstrap card is
|
/// 404 removes that ambiguity for clients, probes and the SPA alike. The SPA bootstrap card is
|
||||||
/// driven by `/api/status` alone (`web/config.js` reveals the admin-session card only when
|
/// driven by `/api/status` alone (`web/config.js` reveals the admin-session card only when
|
||||||
/// `ready === true`), so nothing the status page renders is lost.
|
/// `ready === true`), so nothing the status page renders is lost.
|
||||||
pub fn router_configuration_setup(missing: Vec<String>, backend_id: Option<String>) -> Router {
|
pub fn router_configuration_setup(missing: Vec<String>) -> Router {
|
||||||
let worker_handle = Arc::new(WorkerHandle::new(Arc::new(NoopWorker)));
|
let worker_handle = Arc::new(WorkerHandle::new(Arc::new(NoopWorker)));
|
||||||
let app_state = AppState {
|
let app_state = AppState {
|
||||||
auth: AuthState {
|
auth: AuthState {
|
||||||
|
|
@ -1965,7 +1986,6 @@ pub fn router_configuration_setup(missing: Vec<String>, backend_id: Option<Strin
|
||||||
business_revision: Arc::new(RwLock::new(0)),
|
business_revision: Arc::new(RwLock::new(0)),
|
||||||
setup_missing: Arc::new(missing),
|
setup_missing: Arc::new(missing),
|
||||||
reload: Arc::new(ReloadCoordinator::new(worker_handle, None)),
|
reload: Arc::new(ReloadCoordinator::new(worker_handle, None)),
|
||||||
backend_id,
|
|
||||||
};
|
};
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/status", get(setup_status))
|
.route("/api/status", get(setup_status))
|
||||||
|
|
@ -1980,7 +2000,6 @@ pub fn router_with_worker_state<S: ReliableState + 'static>(
|
||||||
state: S,
|
state: S,
|
||||||
allowlist: HashSet<i64>,
|
allowlist: HashSet<i64>,
|
||||||
worker_handler: Arc<dyn WorkerHandler>,
|
worker_handler: Arc<dyn WorkerHandler>,
|
||||||
backend_id: Option<String>,
|
|
||||||
) -> Router {
|
) -> Router {
|
||||||
router_with_worker_state_and_runtime(
|
router_with_worker_state_and_runtime(
|
||||||
secrets,
|
secrets,
|
||||||
|
|
@ -1988,7 +2007,6 @@ pub fn router_with_worker_state<S: ReliableState + 'static>(
|
||||||
state,
|
state,
|
||||||
allowlist,
|
allowlist,
|
||||||
worker_handler,
|
worker_handler,
|
||||||
backend_id,
|
|
||||||
runtime_provider(OutboundConfig::default()),
|
runtime_provider(OutboundConfig::default()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1999,7 +2017,6 @@ pub fn router_with_worker_state_and_runtime<S: ReliableState + 'static>(
|
||||||
state: S,
|
state: S,
|
||||||
allowlist: HashSet<i64>,
|
allowlist: HashSet<i64>,
|
||||||
worker_handler: Arc<dyn WorkerHandler>,
|
worker_handler: Arc<dyn WorkerHandler>,
|
||||||
backend_id: Option<String>,
|
|
||||||
runtime: RuntimeConfigProvider,
|
runtime: RuntimeConfigProvider,
|
||||||
) -> Router {
|
) -> Router {
|
||||||
router_with_worker_state_runtime_bootstrap(
|
router_with_worker_state_runtime_bootstrap(
|
||||||
|
|
@ -2008,7 +2025,6 @@ pub fn router_with_worker_state_and_runtime<S: ReliableState + 'static>(
|
||||||
state,
|
state,
|
||||||
allowlist,
|
allowlist,
|
||||||
worker_handler,
|
worker_handler,
|
||||||
backend_id,
|
|
||||||
runtime,
|
runtime,
|
||||||
SecretString::new(String::new()),
|
SecretString::new(String::new()),
|
||||||
None,
|
None,
|
||||||
|
|
@ -2025,7 +2041,6 @@ pub fn router_with_worker_state_runtime_bootstrap<S: ReliableState + 'static>(
|
||||||
state: S,
|
state: S,
|
||||||
allowlist: HashSet<i64>,
|
allowlist: HashSet<i64>,
|
||||||
worker_handler: Arc<dyn WorkerHandler>,
|
worker_handler: Arc<dyn WorkerHandler>,
|
||||||
backend_id: Option<String>,
|
|
||||||
runtime: RuntimeConfigProvider,
|
runtime: RuntimeConfigProvider,
|
||||||
admin_token: SecretString,
|
admin_token: SecretString,
|
||||||
debug_token: Option<SecretString>,
|
debug_token: Option<SecretString>,
|
||||||
|
|
@ -2036,7 +2051,6 @@ pub fn router_with_worker_state_runtime_bootstrap<S: ReliableState + 'static>(
|
||||||
state,
|
state,
|
||||||
allowlist,
|
allowlist,
|
||||||
worker_handler,
|
worker_handler,
|
||||||
backend_id,
|
|
||||||
runtime,
|
runtime,
|
||||||
admin_token,
|
admin_token,
|
||||||
debug_token,
|
debug_token,
|
||||||
|
|
@ -2054,7 +2068,6 @@ fn router_with_worker_state_runtime_bootstrap_config<S: ReliableState + 'static>
|
||||||
state: S,
|
state: S,
|
||||||
allowlist: HashSet<i64>,
|
allowlist: HashSet<i64>,
|
||||||
worker_handler: Arc<dyn WorkerHandler>,
|
worker_handler: Arc<dyn WorkerHandler>,
|
||||||
backend_id: Option<String>,
|
|
||||||
runtime: RuntimeConfigProvider,
|
runtime: RuntimeConfigProvider,
|
||||||
admin_token: SecretString,
|
admin_token: SecretString,
|
||||||
debug_token: Option<SecretString>,
|
debug_token: Option<SecretString>,
|
||||||
|
|
@ -2076,7 +2089,6 @@ fn router_with_worker_state_runtime_bootstrap_config<S: ReliableState + 'static>
|
||||||
business_revision: Arc::new(RwLock::new(0)),
|
business_revision: Arc::new(RwLock::new(0)),
|
||||||
setup_missing: Arc::new(setup_missing),
|
setup_missing: Arc::new(setup_missing),
|
||||||
reload,
|
reload,
|
||||||
backend_id,
|
|
||||||
};
|
};
|
||||||
let mut router = Router::new()
|
let mut router = Router::new()
|
||||||
.route("/webhook/tg", post(telegram_webhook))
|
.route("/webhook/tg", post(telegram_webhook))
|
||||||
|
|
@ -2227,7 +2239,7 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn configuration_setup_status_lists_only_missing_names() {
|
async fn configuration_setup_status_lists_only_missing_names() {
|
||||||
let response = router_configuration_setup(vec!["REDIS_URL".into()], None)
|
let response = router_configuration_setup(vec!["REDIS_URL".into()])
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
|
|
@ -2249,15 +2261,17 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn setup_status_reports_backend_label() {
|
async fn setup_status_reports_backend_label() {
|
||||||
// `backend` is threaded from the router builder into `/api/status` so a probe through
|
// `backend` echoes the request `Host` header: when a probe goes through the load
|
||||||
// the load balancer can tell which origin answered — `version` alone is identical
|
// balancer, that header is the public address the LB routed this instance as, which is
|
||||||
// across instances built from the same image. `main` reads `BACKEND_ID`/`HOSTNAME` at
|
// what tells "which backend answered" — `version` alone is identical across instances
|
||||||
// startup and passes a real value; here the value is echoed verbatim end-to-end.
|
// built from the same image. The default https port is trimmed so the label mirrors a
|
||||||
let response = router_configuration_setup(vec![], Some("edge-1".into()))
|
// bare origin.
|
||||||
|
let response = router_configuration_setup(vec![])
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
.uri("/api/status")
|
.uri("/api/status")
|
||||||
|
.header("host", "edge-1.example.com:443")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
|
|
@ -2268,15 +2282,32 @@ mod tests {
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(value["backend"], "edge-1");
|
assert_eq!(value["backend"], "edge-1.example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn setup_status_reports_null_backend_without_host() {
|
||||||
|
let response = router_configuration_setup(vec![])
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/status")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(value["backend"].is_null());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn configuration_setup_status_lists_both_bootstrap_requirements() {
|
async fn configuration_setup_status_lists_both_bootstrap_requirements() {
|
||||||
let response = router_configuration_setup(
|
let response =
|
||||||
vec!["REDIS_URL".into(), "CONFIG_ENCRYPTION_KEY".into()],
|
router_configuration_setup(vec!["REDIS_URL".into(), "CONFIG_ENCRYPTION_KEY".into()])
|
||||||
None,
|
|
||||||
)
|
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
|
|
@ -2297,10 +2328,8 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn configuration_setup_mounts_only_status_and_probes() {
|
async fn configuration_setup_mounts_only_status_and_probes() {
|
||||||
let router = router_configuration_setup(
|
let router =
|
||||||
vec!["REDIS_URL".into(), "CONFIG_ENCRYPTION_KEY".into()],
|
router_configuration_setup(vec!["REDIS_URL".into(), "CONFIG_ENCRYPTION_KEY".into()]);
|
||||||
None,
|
|
||||||
);
|
|
||||||
let token = "Bearer CONFIG_ENCRYPTION_KEY-candidate";
|
let token = "Bearer CONFIG_ENCRYPTION_KEY-candidate";
|
||||||
|
|
||||||
// The status surface and both probes stay reachable: the SPA bootstrap card is driven
|
// The status surface and both probes stay reachable: the SPA bootstrap card is driven
|
||||||
|
|
@ -2356,7 +2385,6 @@ mod tests {
|
||||||
MemoryState::default(),
|
MemoryState::default(),
|
||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
Arc::new(NoopWorker),
|
Arc::new(NoopWorker),
|
||||||
None,
|
|
||||||
runtime_provider(OutboundConfig::default()),
|
runtime_provider(OutboundConfig::default()),
|
||||||
SecretString::new("acl-root".into()),
|
SecretString::new("acl-root".into()),
|
||||||
None,
|
None,
|
||||||
|
|
@ -3342,7 +3370,6 @@ mod tests {
|
||||||
state.clone(),
|
state.clone(),
|
||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
handler,
|
handler,
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
(router, state)
|
(router, state)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue