feat(status): report per-instance backend label so probes can identify the origin
`/api/status` now echoes a `backend` field alongside `version`. The baked `BUILD_VERSION` is identical across every instance built from the same image bath, so it answers "did this deploy land?" but not "which origin answered?" when a probe goes through the Cloudflare Worker load balancer (`ARCH-LB-WORKER`) — and the Worker's `x-lb-backend` header is unreliable (it is silently dropped when the response headers are immutable, e.g. cross-origin without CORS). Having the backend self-report in the body removes that dependency entirely. The value is a per-instance observability label read once at startup from `BACKEND_ID` (falling back to `HOSTNAME`, which container runtimes set their selves). It is not a connection secret and is never persisted (`C-REDIS-ONLY-STATE`). `null` means neither env var was set — two such instances are indistinguishable by this field by design. Wiring keeps handlers off the environment (matching the `debug_surface_requested` rationale: env is process-global and reading it inside a handler races tests): `main` reads it once and threads `backend_id` through the router builders into `AppState::backend_id`. Also replaces the drift-prone `src/main.rs:<line>` anchors in design.md / design.zh-CN.md with symbol references (`std::env::var` key names, `LlmClient::with_runtime`), per the symbol-not-line rule; the insert had already shifted them. Tests: `setup_status_reports_backend_label` asserts the label round-trips into the body. GATE-P0 105 passed / 0 failed / 4 ignored; GATE-DOCS 6/6 clean.
This commit is contained in:
parent
6ae8978836
commit
88f0923e6c
|
|
@ -10,5 +10,10 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -365,8 +365,8 @@ Testing: **No integrated test directory**, and no stand-alone util tool module.
|
||||||
> Use new configuration, keep old instance on failure. This migration replaces the following stage 0 environment variable list. The stage 0 list is only for historical purposes.
|
> Use new configuration, keep old instance on failure. This migration replaces the following stage 0 environment variable list. The stage 0 list is only for historical purposes.
|
||||||
> Compatibility instructions.
|
> Compatibility instructions.
|
||||||
|
|
||||||
> **Actual implementation** (`ARCH-CONFIG-ENV`): process startup read-only **2** process-level credentials plus listening port (`src/main.rs:45` / `:54` / `:63`,
|
> **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`,
|
||||||
> Add optional `DEBUG_TOKEN` in `:107`), directly `std::env::var`, **do not use figment, do not use TOML/configuration file**.
|
> plus optional `DEBUG_TOKEN` and the `/api/status` label `BACKEND_ID`), **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 }`, 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.
|
- `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.
|
||||||
- `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:
|
||||||
|
|
@ -746,7 +746,7 @@ The LLM capability only exists in the `src/ai.rs` file; **no** configuration/fal
|
||||||
| Variable | Type | Default | Purpose |
|
| Variable | Type | Default | Purpose |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `LLM_ENABLED` | bool | `false` | Master switch; off by default, no verification when not enabled `LLM_API_KEY`/`LLM_BASE_URL`/`LLM_MODEL` |
|
| `LLM_ENABLED` | bool | `false` | Master switch; off by default, no verification when not enabled `LLM_API_KEY`/`LLM_BASE_URL`/`LLM_MODEL` |
|
||||||
| `LLM_ALLOW_NET` | bool | `false` | Runtime outbound permission; `LlmClient` is constructed only if `LLM_ENABLED && LLM_ALLOW_NET` are both true, otherwise the `llm` field is `None` (`src/main.rs:135-141`) |
|
| `LLM_ALLOW_NET` | bool | `false` | Runtime outbound permission; `LlmClient` is constructed only if `LLM_ENABLED && LLM_ALLOW_NET` are both true, otherwise the `llm` field is `None` (`LlmClient::with_runtime` in `src/main.rs`) |
|
||||||
| `LLM_API_KEY` | string | Can be omitted | Bearer token; required only when `LLM_ENABLED=true` |
|
| `LLM_API_KEY` | string | Can be omitted | Bearer token; required only when `LLM_ENABLED=true` |
|
||||||
| `LLM_BASE_URL` | URL | Can be omitted | Required only when enabled, and must be `https`, otherwise `AiError::InvalidEndpoint` |
|
| `LLM_BASE_URL` | URL | Can be omitted | Required only when enabled, and must be `https`, otherwise `AiError::InvalidEndpoint` |
|
||||||
| `LLM_MODEL` | string | Can be omitted | Required only when enabled; transparently passed to `model` in `/chat/completions` |
|
| `LLM_MODEL` | string | Can be omitted | Required only when enabled; transparently passed to `model` in `/chat/completions` |
|
||||||
|
|
|
||||||
|
|
@ -365,8 +365,8 @@ message-weave/
|
||||||
> 使用新配置,失败保留旧实例。该迁移替代下述阶段0环境变量清单,阶段0列表仅作为历史
|
> 使用新配置,失败保留旧实例。该迁移替代下述阶段0环境变量清单,阶段0列表仅作为历史
|
||||||
> 兼容说明。
|
> 兼容说明。
|
||||||
|
|
||||||
> **实际实现**(`ARCH-CONFIG-ENV`):进程启动只读 **2 个**进程级凭据加监听端口(`src/main.rs:45` / `:54` / `:63`,
|
> **实际实现**(`ARCH-CONFIG-ENV`):进程启动只读 **2 个**进程级凭据加监听端口(均经 `main` 里的 `std::env::var`,键名 `REDIS_URL` / `CONFIG_ENCRYPTION_KEY` / `PORT`,
|
||||||
> 另加可选 `DEBUG_TOKEN` 于 `:107`),直接 `std::env::var`,**不使用 figment、不使用 TOML/配置文件**。
|
> 另加可选 `DEBUG_TOKEN` 与 `/api/status` 标签 `BACKEND_ID`),**不使用 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 }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。`version` 是 `build.rs` 在编译期烘焙的 `BUILD_VERSION`(`<git-sha 或 nogit>+<UTC 构建时间>`)。SPA 已不再渲染它,确认部署落地只能查这个接口。
|
- `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 都未设置);两个都不设的实例在该字段上不可区分,这是设计如此。
|
||||||
- `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` 只读取和写入非敏感运行参数:
|
||||||
|
|
@ -746,7 +746,7 @@ LLM 能力只存在于 `src/ai.rs` 一个文件;**没有**配置 / 回退 /
|
||||||
| 变量 | 类型 | 默认 | 用途 |
|
| 变量 | 类型 | 默认 | 用途 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `LLM_ENABLED` | bool | `false` | 总开关;默认关闭,未启用时不校验 `LLM_API_KEY`/`LLM_BASE_URL`/`LLM_MODEL` |
|
| `LLM_ENABLED` | bool | `false` | 总开关;默认关闭,未启用时不校验 `LLM_API_KEY`/`LLM_BASE_URL`/`LLM_MODEL` |
|
||||||
| `LLM_ALLOW_NET` | bool | `false` | 运行时出站许可;`LLM_ENABLED && LLM_ALLOW_NET` 同时为真才构造 `LlmClient`,否则 `llm` 字段为 `None`(`src/main.rs:135-141`) |
|
| `LLM_ALLOW_NET` | bool | `false` | 运行时出站许可;`LLM_ENABLED && LLM_ALLOW_NET` 同时为真才构造 `LlmClient`,否则 `llm` 字段为 `None`(`LlmClient::with_runtime` 于 `src/main.rs`) |
|
||||||
| `LLM_API_KEY` | string | 可省略 | Bearer token;仅 `LLM_ENABLED=true` 时必填 |
|
| `LLM_API_KEY` | string | 可省略 | Bearer token;仅 `LLM_ENABLED=true` 时必填 |
|
||||||
| `LLM_BASE_URL` | URL | 可省略 | 仅启用时必填,且必须 `https`,否则 `AiError::InvalidEndpoint` |
|
| `LLM_BASE_URL` | URL | 可省略 | 仅启用时必填,且必须 `https`,否则 `AiError::InvalidEndpoint` |
|
||||||
| `LLM_MODEL` | string | 可省略 | 仅启用时必填;透传给 `/chat/completions` 的 `model` |
|
| `LLM_MODEL` | string | 可省略 | 仅启用时必填;透传给 `/chat/completions` 的 `model` |
|
||||||
|
|
|
||||||
|
|
@ -275,12 +275,18 @@ Response conventions:
|
||||||
The JMAP probe deliberately authenticates against the *normalized origin*: probing the raw
|
The JMAP probe deliberately authenticates against the *normalized origin*: probing the raw
|
||||||
session URL unauthenticated would report not-ready forever and make ingress stop routing.
|
session URL unauthenticated would report not-ready forever and make ingress stop routing.
|
||||||
- `GET /api/status` (**always 200**) returns
|
- `GET /api/status` (**always 200**) returns
|
||||||
`{"ready": <bool>, "mode": "configured"|"configuration-setup", "missing": [...], "version": "<build-fingerprint>"}`.
|
`{"ready": <bool>, "mode": "configured"|"configuration-setup", "missing": [...], "version": "<build-fingerprint>", "backend": "<id>"|null}`.
|
||||||
`ready` is `false` and `missing` lists the absent required keys (`REDIS_URL`,
|
`ready` is `false` and `missing` lists the absent required keys (`REDIS_URL`,
|
||||||
`CONFIG_ENCRYPTION_KEY`) when a variable is absent — the route itself never errors, so it is
|
`CONFIG_ENCRYPTION_KEY`) when a variable is absent — the route itself never errors, so it is
|
||||||
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>`). The SPA no longer renders it — the API is now the
|
(`<git-sha-or-nogit>+<UTC build time>`) — identical across every instance built from the same
|
||||||
only way to confirm a deploy landed.
|
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
|
||||||
|
`HOSTNAME`, which container runtimes set), threaded into `AppState::backend_id` and echoed
|
||||||
|
here. It is an observability label, not a connection secret and not persisted. `null` means
|
||||||
|
neither env var was set; two such instances are indistinguishable by this field, so a probe
|
||||||
|
that needs to tell origins apart must set `BACKEND_ID` per instance at deploy time. 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
|
||||||
|
|
|
||||||
|
|
@ -272,11 +272,10 @@ env var — 并且“DEBUG_TOKEN”非空**(“SAF-DEBUG-GATE”;门被读
|
||||||
JMAP 探针有意针对*规范化来源*进行身份验证:探测原始数据
|
JMAP 探针有意针对*规范化来源*进行身份验证:探测原始数据
|
||||||
未经身份验证的会话 URL 将永远报告未就绪并使入口停止路由。
|
未经身份验证的会话 URL 将永远报告未就绪并使入口停止路由。
|
||||||
- `GET /api/status`(**始终 200**)返回
|
- `GET /api/status`(**始终 200**)返回
|
||||||
`{“ready”:<bool>,“mode”:“configured”|“configuration-setup”,“missing”:[...],“version”:“<build-fingerprint>”}`。
|
`{"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`)当变量不存在时 - 路由本身永远不会出错,所以它是
|
`CONFIG_ENCRYPTION_KEY`)——路由本身永不报错,可安全轮询。`version` 是 `build.rs` 烘焙的 `BUILD_VERSION` 字符串
|
||||||
可以安全进行民意调查。 `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-or-nogit>+<UTC 构建时间>`)。SPA 已不再渲染它,确认部署落地只能查这个接口。
|
|
||||||
- `/debug/*` 在一个地方返回 **503**:业务执行时`POST /debug/notify`
|
- `/debug/*` 在一个地方返回 **503**:业务执行时`POST /debug/notify`
|
||||||
配置未加载或没有出站客户端 — `service_unavailable`
|
配置未加载或没有出站客户端 — `service_unavailable`
|
||||||
`重试时间:30`。三个探测端点反而报告失败
|
`重试时间:30`。三个探测端点反而报告失败
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,7 @@ 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,6 +26,23 @@ 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};
|
||||||
|
|
@ -50,6 +67,7 @@ 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,
|
||||||
|
|
@ -73,7 +91,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).merge(web::router());
|
let app = router_configuration_setup(missing, backend_id).merge(web::router());
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -198,6 +216,7 @@ 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,6 +104,11 @@ 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 {
|
||||||
|
|
@ -220,6 +225,7 @@ async fn setup_status(State(app): State<AppState>) -> Response {
|
||||||
"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,
|
||||||
}))
|
}))
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
@ -1924,6 +1930,7 @@ pub fn router_with_state<S: ReliableState + 'static>(
|
||||||
state,
|
state,
|
||||||
allowlist,
|
allowlist,
|
||||||
Arc::new(NoopWorker),
|
Arc::new(NoopWorker),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1939,7 +1946,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>) -> Router {
|
pub fn router_configuration_setup(missing: Vec<String>, backend_id: Option<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 {
|
||||||
|
|
@ -1958,6 +1965,7 @@ pub fn router_configuration_setup(missing: Vec<String>) -> Router {
|
||||||
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))
|
||||||
|
|
@ -1972,6 +1980,7 @@ 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,
|
||||||
|
|
@ -1979,6 +1988,7 @@ 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()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1989,6 +1999,7 @@ 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(
|
||||||
|
|
@ -1997,6 +2008,7 @@ 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,
|
||||||
|
|
@ -2013,6 +2025,7 @@ 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>,
|
||||||
|
|
@ -2023,6 +2036,7 @@ 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,
|
||||||
|
|
@ -2040,6 +2054,7 @@ 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>,
|
||||||
|
|
@ -2061,6 +2076,7 @@ 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))
|
||||||
|
|
@ -2211,7 +2227,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()])
|
let response = router_configuration_setup(vec!["REDIS_URL".into()], None)
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
|
|
@ -2231,19 +2247,45 @@ mod tests {
|
||||||
assert!(!body.windows(4).any(|part| part == b"pass"));
|
assert!(!body.windows(4).any(|part| part == b"pass"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn setup_status_reports_backend_label() {
|
||||||
|
// `backend` is threaded from the router builder into `/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. `main` reads `BACKEND_ID`/`HOSTNAME` at
|
||||||
|
// startup and passes a real value; here the value is echoed verbatim end-to-end.
|
||||||
|
let response = router_configuration_setup(vec![], Some("edge-1".into()))
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/status")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
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_eq!(value["backend"], "edge-1");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn configuration_setup_status_lists_both_bootstrap_requirements() {
|
async fn configuration_setup_status_lists_both_bootstrap_requirements() {
|
||||||
let response =
|
let response = router_configuration_setup(
|
||||||
router_configuration_setup(vec!["REDIS_URL".into(), "CONFIG_ENCRYPTION_KEY".into()])
|
vec!["REDIS_URL".into(), "CONFIG_ENCRYPTION_KEY".into()],
|
||||||
.oneshot(
|
None,
|
||||||
Request::builder()
|
)
|
||||||
.method("GET")
|
.oneshot(
|
||||||
.uri("/api/status")
|
Request::builder()
|
||||||
.body(Body::empty())
|
.method("GET")
|
||||||
.unwrap(),
|
.uri("/api/status")
|
||||||
)
|
.body(Body::empty())
|
||||||
.await
|
.unwrap(),
|
||||||
.unwrap();
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -2255,8 +2297,10 @@ 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 =
|
let router = router_configuration_setup(
|
||||||
router_configuration_setup(vec!["REDIS_URL".into(), "CONFIG_ENCRYPTION_KEY".into()]);
|
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
|
||||||
|
|
@ -2312,6 +2356,7 @@ 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,
|
||||||
|
|
@ -3297,6 +3342,7 @@ mod tests {
|
||||||
state.clone(),
|
state.clone(),
|
||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
handler,
|
handler,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
(router, state)
|
(router, state)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue