feat(delivery): observe production revision with dual evidence
Read project-owned production profile, verify Gitea deploy context and public version probe, and surface explicit degraded states. Feature-Confirmed: true
This commit is contained in:
parent
7fc3011411
commit
02b9f1ec36
@ -324,21 +324,22 @@ v1 不新增 deployment history 表。最近部署时间和过程状态来自 Gi
|
|||||||
- acceptance: "agent_verify: [cargo test] `merge_pull_request` 在 confirmed=false、expected_head_sha 与远端不一致、PR 非 open、CI pending/failure 或 review changes_requested 时拒绝且不执行 merge;仅在 PR open、SHA 一致、CI passed、无阻塞 review 且 confirmed=true 时允许执行;[MCP schema] confirmed 与 expected_head_sha 均为必填;合并后重新调用 get_delivery_status 可读取真实结果。"
|
- acceptance: "agent_verify: [cargo test] `merge_pull_request` 在 confirmed=false、expected_head_sha 与远端不一致、PR 非 open、CI pending/failure 或 review changes_requested 时拒绝且不执行 merge;仅在 PR open、SHA 一致、CI passed、无阻塞 review 且 confirmed=true 时允许执行;[MCP schema] confirmed 与 expected_head_sha 均为必填;合并后重新调用 get_delivery_status 可读取真实结果。"
|
||||||
- done_note: "2026-09-22:cargo test --lib 151/151、pnpm typecheck 与 PR CI 全部通过;MCP 实机先以错误 SHA 验证 fail-closed,再由用户确认 PR #14 与完整 head SHA b6d749025458928b86ab607a5a7f31479f8db85f 后,通过带 head_commit_id 的能力本体成功合并;回读 get_delivery_status 为 PR none、CI passed。"
|
- done_note: "2026-09-22:cargo test --lib 151/151、pnpm typecheck 与 PR CI 全部通过;MCP 实机先以错误 SHA 验证 fail-closed,再由用户确认 PR #14 与完整 head SHA b6d749025458928b86ab607a5a7f31479f8db85f 后,通过带 head_commit_id 的能力本体成功合并;回读 get_delivery_status 为 PR none、CI passed。"
|
||||||
|
|
||||||
### 📋 P3-B. Enterprise System 不可变 revision 与版本健康端点
|
### 🔵 P3-B. Enterprise System 不可变 revision 与版本健康端点
|
||||||
- status: todo
|
- status: in_progress
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: E:/code-project/enterprise-system/apps/api/src/index.ts, E:/code-project/enterprise-system/apps/api/Dockerfile, E:/code-project/enterprise-system/deploy/deploy-api.sh
|
- files: E:/code-project/enterprise-system/apps/api/src/index.ts, E:/code-project/enterprise-system/apps/api/Dockerfile, E:/code-project/enterprise-system/deploy/deploy-api.sh
|
||||||
- acceptance: "[HTTP API] 以 BUILD_REVISION=<sha-a> 构建并启动 API 镜像后调用 `GET /health/version` → 200,body 仅含 ok/component/revision/started_at 且 revision=<sha-a>;切回以 <sha-b> 构建的旧镜像 → revision=<sha-b>,证明回滚不会伪报新 SHA。"
|
- acceptance: "[HTTP API] 以 BUILD_REVISION=<sha-a> 构建并启动 API 镜像后调用 `GET /health/version` → 200,body 仅含 ok/component/revision/started_at 且 revision=<sha-a>;切回以 <sha-b> 构建的旧镜像 → revision=<sha-b>,证明回滚不会伪报新 SHA。"
|
||||||
|
- progress_note: "Enterprise 隔离分支 feat/production-revision-evidence 本地提交 d915399;API typecheck、bash 静态检查与隔离端口 HTTP 四字段/鉴权冒烟通过。Docker daemon 未运行,新旧镜像回滚和 production 仍待验,故保持 in_progress。"
|
||||||
|
|
||||||
### 📋 P3-C. production delivery profile、安全探针与 onboarding 自检
|
### 🔵 P3-C. production delivery profile、安全探针与 onboarding 自检
|
||||||
- status: todo
|
- status: in_progress
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: src-tauri/src/commands/delivery_status.rs, src-tauri/src/commands/onboarding.rs, src-tauri/resources/onboarding/
|
- files: src-tauri/src/commands/delivery_status.rs, src-tauri/src/commands/onboarding.rs, src-tauri/resources/onboarding/
|
||||||
- depends: A. Canonical 项目上下文与 capability schema
|
- depends: A. Canonical 项目上下文与 capability schema
|
||||||
- acceptance: "[cargo test] 读取声明了 production/http-json probe 的 capability profile → 得到固定 environment/component/context/url 字段;`file:`、URL 内凭证、动态认证头、超时响应和超限响应体分别被拒绝或降级为 unavailable;未配置项目返回 not_configured,onboarding 自检不覆盖已有声明。"
|
- acceptance: "[cargo test] 读取声明了 production/http-json probe 的 capability profile → 得到固定 environment/component/context/url 字段;`file:`、URL 内凭证、动态认证头、超时响应和超限响应体分别被拒绝或降级为 unavailable;未配置项目返回 not_configured,onboarding 自检不覆盖已有声明。"
|
||||||
|
|
||||||
### 📋 P3-D. deployment 双证据聚合与 MCP 契约
|
### 🔵 P3-D. deployment 双证据聚合与 MCP 契约
|
||||||
- status: todo
|
- status: in_progress
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: src-tauri/src/commands/delivery_status.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/mcp_server.rs
|
- files: src-tauri/src/commands/delivery_status.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/mcp_server.rs
|
||||||
- depends: P3-A. Gitea 1.22 真实 deployment signal 实证与归一化, P3-C. production delivery profile、安全探针与 onboarding 自检
|
- depends: P3-A. Gitea 1.22 真实 deployment signal 实证与归一化, P3-C. production delivery profile、安全探针与 onboarding 自检
|
||||||
|
|||||||
@ -139,6 +139,12 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言:
|
|||||||
|
|
||||||
这套 scope 是单用户本机的误操作隔离,不是多租户认证。威胁模型变化前,不向项目配置写持久 token。
|
这套 scope 是单用户本机的误操作隔离,不是多租户认证。威胁模型变化前,不向项目配置写持久 token。
|
||||||
|
|
||||||
|
## production 交付观测边界
|
||||||
|
|
||||||
|
项目可自主管理 `.lian-jing/capabilities.yaml` 的 `delivery.targets.production`,声明 Gitea Actions 的**精确 status context** 与公开 HTTP JSON 版本探针。炼境只读该声明;接入包不生成或覆盖项目的 production 配置。探针不接受 URL 凭证、认证头、查询参数、非 HTTP(S) 协议或本机/内网字面地址,不跟随重定向,并限制请求时间与响应大小。
|
||||||
|
|
||||||
|
`get_delivery_status` 分别采集 Gitea 默认分支目标 SHA 的部署过程证据和当前服务报告的运行 SHA。只有部署 context 成功、探针健康、两个完整 SHA 相等时才报告 `deployed`。`failed` 保留实际运行版本,`drifted` 表示版本不一致;缺失/不可达证据明确降级。该状态仅是技术交付观测,不替代业务测试、上线决策,也不赋予炼境部署、回滚、SSH 或数据库写权限。
|
||||||
|
|
||||||
## 三条设计原则
|
## 三条设计原则
|
||||||
|
|
||||||
辩证结论,缺一会让体系在现实中碎裂。
|
辩证结论,缺一会让体系在现实中碎裂。
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
|
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
|
||||||
> `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。
|
> `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。
|
||||||
|
|
||||||
<!-- ONBOARDING_MANAGED_START version="1.6.0" -->
|
<!-- ONBOARDING_MANAGED_START version="1.7.0" -->
|
||||||
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。
|
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。
|
||||||
|
|
||||||
## 分支策略(复杂度感知)
|
## 分支策略(复杂度感知)
|
||||||
@ -107,6 +107,9 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
|
|||||||
|
|
||||||
- `unknown`:炼境尚未获得该项证据,**不是成功**;例如 CI 成功不能代替 production 健康。
|
- `unknown`:炼境尚未获得该项证据,**不是成功**;例如 CI 成功不能代替 production 健康。
|
||||||
- `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。
|
- `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。
|
||||||
|
- `not_configured`:项目尚未声明 `.lian-jing/capabilities.yaml` 的 production delivery profile;这是正常的可选接入状态,不代表部署失败。
|
||||||
|
- `deployed` 仅表示 Gitea 部署 context 成功、公开版本探针健康且运行 SHA 与默认分支目标 SHA 完全一致;`drifted` 表示运行 SHA 不一致。技术健康不替代业务验收。
|
||||||
|
- production profile 由项目维护,炼境接入包不会覆盖;只声明公开 HTTP(S) JSON URL、精确 Gitea context 和字段名,不放凭证或命令。
|
||||||
- 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。
|
- 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。
|
||||||
|
|
||||||
## PR 合并(炼境托管项目)
|
## PR 合并(炼境托管项目)
|
||||||
|
|||||||
@ -15,7 +15,7 @@ contracts:
|
|||||||
- "src-tauri/src/commands/toolchain.rs"
|
- "src-tauri/src/commands/toolchain.rs"
|
||||||
- "src-tauri/src/commands/cicd.rs"
|
- "src-tauri/src/commands/cicd.rs"
|
||||||
- "src-tauri/src/commands/gitea.rs"
|
- "src-tauri/src/commands/gitea.rs"
|
||||||
doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。"
|
doc: "工具 schema、dispatch 与调用说明必须同步;production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。"
|
||||||
|
|
||||||
- label: 被管理项目身份上下文
|
- label: 被管理项目身份上下文
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
|
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
|
||||||
> `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。
|
> `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。
|
||||||
|
|
||||||
<!-- ONBOARDING_MANAGED_START version="1.6.0" -->
|
<!-- ONBOARDING_MANAGED_START version="1.7.0" -->
|
||||||
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。
|
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。
|
||||||
|
|
||||||
## 分支策略(复杂度感知)
|
## 分支策略(复杂度感知)
|
||||||
@ -107,6 +107,9 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
|
|||||||
|
|
||||||
- `unknown`:炼境尚未获得该项证据,**不是成功**;例如 CI 成功不能代替 production 健康。
|
- `unknown`:炼境尚未获得该项证据,**不是成功**;例如 CI 成功不能代替 production 健康。
|
||||||
- `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。
|
- `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。
|
||||||
|
- `not_configured`:项目尚未声明 `.lian-jing/capabilities.yaml` 的 production delivery profile;这是正常的可选接入状态,不代表部署失败。
|
||||||
|
- `deployed` 仅表示 Gitea 部署 context 成功、公开版本探针健康且运行 SHA 与默认分支目标 SHA 完全一致;`drifted` 表示运行 SHA 不一致。技术健康不替代业务验收。
|
||||||
|
- production profile 由项目维护,炼境接入包不会覆盖;只声明公开 HTTP(S) JSON URL、精确 Gitea context 和字段名,不放凭证或命令。
|
||||||
- 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。
|
- 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。
|
||||||
|
|
||||||
## PR 合并(炼境托管项目)
|
## PR 合并(炼境托管项目)
|
||||||
|
|||||||
@ -3,9 +3,452 @@
|
|||||||
//! 本模块只采集和呈现信号,不替项目判断功能是否应交付。Git 本地状态、Gitea
|
//! 本模块只采集和呈现信号,不替项目判断功能是否应交付。Git 本地状态、Gitea
|
||||||
//! 信号与部署证据各自独立降级:任一来源不可用时必须明确标出,而不能猜测成功。
|
//! 信号与部署证据各自独立降级:任一来源不可用时必须明确标出,而不能猜测成功。
|
||||||
|
|
||||||
use std::{path::Path, process::Command};
|
use std::{
|
||||||
|
collections::BTreeMap, io::Read, net::IpAddr, path::Path, process::Command, time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const CAPABILITY_PROFILE_PATH: &str = ".lian-jing/capabilities.yaml";
|
||||||
|
const MAX_PROFILE_BYTES: u64 = 16 * 1024;
|
||||||
|
const MAX_PROBE_BYTES: u64 = 8 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct CapabilityProfile {
|
||||||
|
version: u64,
|
||||||
|
delivery: Option<DeliveryCapabilities>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct DeliveryCapabilities {
|
||||||
|
targets: Option<DeliveryTargets>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct DeliveryTargets {
|
||||||
|
production: Option<ProductionDeliveryProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ProductionDeliveryProfile {
|
||||||
|
pub provider: String,
|
||||||
|
pub components: BTreeMap<String, DeliveryComponentProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DeliveryComponentProfile {
|
||||||
|
pub deploy_context: String,
|
||||||
|
pub probe: HttpJsonProbe,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct HttpJsonProbe {
|
||||||
|
pub kind: String,
|
||||||
|
pub url: String,
|
||||||
|
pub revision_field: String,
|
||||||
|
pub healthy_field: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
|
pub struct ProbeEvidence {
|
||||||
|
pub state: String,
|
||||||
|
pub revision: Option<String>,
|
||||||
|
pub healthy: Option<bool>,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProbeEvidence {
|
||||||
|
fn unavailable(detail: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
state: "unavailable".into(),
|
||||||
|
revision: None,
|
||||||
|
healthy: None,
|
||||||
|
detail: detail.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 项目拥有此声明;炼境只读取和校验,不分发或修改项目配置。
|
||||||
|
pub fn read_production_profile(root: &Path) -> Result<Option<ProductionDeliveryProfile>, String> {
|
||||||
|
let path = root.join(CAPABILITY_PROFILE_PATH);
|
||||||
|
let metadata = match std::fs::metadata(&path) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(error) => return Err(format!("无法读取 delivery profile:{error}")),
|
||||||
|
};
|
||||||
|
if !metadata.is_file() || metadata.len() > MAX_PROFILE_BYTES {
|
||||||
|
return Err("delivery profile 不是普通文件或超过 16 KiB".into());
|
||||||
|
}
|
||||||
|
let content = std::fs::read_to_string(&path)
|
||||||
|
.map_err(|error| format!("无法读取 delivery profile:{error}"))?;
|
||||||
|
parse_production_profile(&content)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_production_profile(
|
||||||
|
content: &str,
|
||||||
|
) -> Result<Option<ProductionDeliveryProfile>, String> {
|
||||||
|
if content.len() as u64 > MAX_PROFILE_BYTES {
|
||||||
|
return Err("delivery profile 超过 16 KiB".into());
|
||||||
|
}
|
||||||
|
let profile: CapabilityProfile = serde_yaml::from_str(content)
|
||||||
|
.map_err(|error| format!("delivery profile YAML 无效:{error}"))?;
|
||||||
|
if profile.version != 1 {
|
||||||
|
return Err(format!(
|
||||||
|
"不支持 delivery profile version={}",
|
||||||
|
profile.version
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some(production) = profile
|
||||||
|
.delivery
|
||||||
|
.and_then(|delivery| delivery.targets)
|
||||||
|
.and_then(|targets| targets.production)
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if production.provider != "gitea-actions"
|
||||||
|
|| production.components.is_empty()
|
||||||
|
|| production.components.len() > 4
|
||||||
|
{
|
||||||
|
return Err("production 仅支持 gitea-actions 且须声明 1–4 个组件".into());
|
||||||
|
}
|
||||||
|
for (component, config) in &production.components {
|
||||||
|
if !valid_identifier(component) || !valid_context(&config.deploy_context) {
|
||||||
|
return Err(format!("组件 {component:?} 的名称或 deploy_context 无效"));
|
||||||
|
}
|
||||||
|
if config.probe.kind != "http-json"
|
||||||
|
|| !valid_identifier(&config.probe.revision_field)
|
||||||
|
|| !valid_identifier(&config.probe.healthy_field)
|
||||||
|
{
|
||||||
|
return Err(format!("组件 {component:?} 的 probe 字段无效"));
|
||||||
|
}
|
||||||
|
validate_probe_url(&config.probe.url)?;
|
||||||
|
}
|
||||||
|
Ok(Some(production))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_identifier(value: &str) -> bool {
|
||||||
|
!value.is_empty()
|
||||||
|
&& value.len() <= 64
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.all(|ch| ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'-')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_context(value: &str) -> bool {
|
||||||
|
!value.is_empty()
|
||||||
|
&& value.len() <= 128
|
||||||
|
&& value.trim() == value
|
||||||
|
&& !value.chars().any(char::is_control)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_probe_url(raw: &str) -> Result<(), String> {
|
||||||
|
if raw.len() > 2048 {
|
||||||
|
return Err("探针 URL 超过长度上限".into());
|
||||||
|
}
|
||||||
|
let url = tauri::Url::parse(raw).map_err(|_| "探针 URL 无效".to_string())?;
|
||||||
|
if !matches!(url.scheme(), "http" | "https")
|
||||||
|
|| !url.username().is_empty()
|
||||||
|
|| url.password().is_some()
|
||||||
|
|| url.query().is_some()
|
||||||
|
|| url.fragment().is_some()
|
||||||
|
{
|
||||||
|
return Err("探针仅允许无凭证、无查询参数的 http/https URL".into());
|
||||||
|
}
|
||||||
|
let host = url.host_str().ok_or("探针 URL 缺少主机名")?;
|
||||||
|
let host_lower = host.to_ascii_lowercase();
|
||||||
|
if host_lower == "localhost"
|
||||||
|
|| host_lower.ends_with(".localhost")
|
||||||
|
|| host_lower.ends_with(".local")
|
||||||
|
|| host_lower.ends_with(".internal")
|
||||||
|
{
|
||||||
|
return Err("探针不允许本机或内网域名".into());
|
||||||
|
}
|
||||||
|
let ip_host = host.trim_start_matches('[').trim_end_matches(']');
|
||||||
|
if let Ok(ip) = ip_host.parse::<IpAddr>() {
|
||||||
|
let blocked = match ip {
|
||||||
|
IpAddr::V4(ip) => {
|
||||||
|
ip.is_private()
|
||||||
|
|| ip.is_loopback()
|
||||||
|
|| ip.is_link_local()
|
||||||
|
|| ip.is_multicast()
|
||||||
|
|| ip.is_unspecified()
|
||||||
|
}
|
||||||
|
IpAddr::V6(ip) => {
|
||||||
|
ip.to_ipv4_mapped().is_some_and(|mapped| {
|
||||||
|
mapped.is_private()
|
||||||
|
|| mapped.is_loopback()
|
||||||
|
|| mapped.is_link_local()
|
||||||
|
|| mapped.is_multicast()
|
||||||
|
|| mapped.is_unspecified()
|
||||||
|
}) || ip.is_loopback()
|
||||||
|
|| ip.is_unique_local()
|
||||||
|
|| ip.is_unicast_link_local()
|
||||||
|
|| ip.is_multicast()
|
||||||
|
|| ip.is_unspecified()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if blocked {
|
||||||
|
return Err("探针不允许本机或内网 IP".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 探针只用项目声明的 URL 发起无认证 GET,不接受 Agent 动态 URL 或请求头。
|
||||||
|
pub fn probe_http_json(probe: &HttpJsonProbe) -> ProbeEvidence {
|
||||||
|
if let Err(detail) = validate_probe_url(&probe.url) {
|
||||||
|
return ProbeEvidence::unavailable(detail);
|
||||||
|
}
|
||||||
|
let agent = ureq::AgentBuilder::new()
|
||||||
|
.redirects(0)
|
||||||
|
.timeout(Duration::from_secs(4))
|
||||||
|
.timeout_connect(Duration::from_secs(3))
|
||||||
|
.timeout_read(Duration::from_secs(3))
|
||||||
|
.timeout_write(Duration::from_secs(3))
|
||||||
|
.build();
|
||||||
|
let response = match agent.get(&probe.url).call() {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(error) => return ProbeEvidence::unavailable(format!("探针请求失败:{error}")),
|
||||||
|
};
|
||||||
|
let body = match read_bounded_probe(response.into_reader()) {
|
||||||
|
Ok(body) => body,
|
||||||
|
Err(detail) => return ProbeEvidence::unavailable(detail),
|
||||||
|
};
|
||||||
|
parse_probe_response(&body, probe)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_bounded_probe(reader: impl Read) -> Result<Vec<u8>, String> {
|
||||||
|
let mut body = Vec::new();
|
||||||
|
reader
|
||||||
|
.take(MAX_PROBE_BYTES + 1)
|
||||||
|
.read_to_end(&mut body)
|
||||||
|
.map_err(|error| format!("探针响应不可读:{error}"))?;
|
||||||
|
if body.len() as u64 > MAX_PROBE_BYTES {
|
||||||
|
return Err("探针响应超过 8 KiB".into());
|
||||||
|
}
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_declared_deployment(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
project_id: &str,
|
||||||
|
root: &Path,
|
||||||
|
) -> DeliverySignal {
|
||||||
|
let profile = match read_production_profile(root) {
|
||||||
|
Ok(None) => {
|
||||||
|
return DeliverySignal::new(
|
||||||
|
"not_configured",
|
||||||
|
"project_capabilities",
|
||||||
|
"项目未声明 production delivery profile",
|
||||||
|
serde_json::json!({}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(detail) => {
|
||||||
|
return DeliverySignal::new(
|
||||||
|
"unavailable",
|
||||||
|
"project_capabilities",
|
||||||
|
detail,
|
||||||
|
serde_json::json!({}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Some(profile)) => profile,
|
||||||
|
};
|
||||||
|
let contexts: Vec<String> = profile
|
||||||
|
.components
|
||||||
|
.values()
|
||||||
|
.map(|component| component.deploy_context.clone())
|
||||||
|
.collect();
|
||||||
|
let remote =
|
||||||
|
crate::commands::gitea::get_production_context_signals(conn, project_id, &contexts);
|
||||||
|
let (target_sha, deploy_signals) = match remote {
|
||||||
|
Ok((sha, signals)) => (Some(sha), signals),
|
||||||
|
Err(signal) => (
|
||||||
|
None,
|
||||||
|
contexts
|
||||||
|
.iter()
|
||||||
|
.map(|context| (context.clone(), signal.clone()))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let components = profile
|
||||||
|
.components
|
||||||
|
.iter()
|
||||||
|
.map(|(name, component)| {
|
||||||
|
(
|
||||||
|
name.clone(),
|
||||||
|
(
|
||||||
|
component.deploy_context.clone(),
|
||||||
|
deploy_signals
|
||||||
|
.get(&component.deploy_context)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
DeliverySignal::new(
|
||||||
|
"unknown",
|
||||||
|
"gitea",
|
||||||
|
"部署 context 未返回结果",
|
||||||
|
serde_json::json!({}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
probe_http_json(&component.probe),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
aggregate_deployment(target_sha.as_deref(), components)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentEvidence = (String, DeliverySignal, ProbeEvidence);
|
||||||
|
|
||||||
|
/// 纯函数:部署过程与当前运行事实必须同时成立;不把 CI 或 HTTP 单独升格为成功。
|
||||||
|
fn aggregate_deployment(
|
||||||
|
target_sha: Option<&str>,
|
||||||
|
components: BTreeMap<String, ComponentEvidence>,
|
||||||
|
) -> DeliverySignal {
|
||||||
|
let any_failed = components
|
||||||
|
.values()
|
||||||
|
.any(|(_, deploy, _)| deploy.state == "failure");
|
||||||
|
let any_pending = components
|
||||||
|
.values()
|
||||||
|
.any(|(_, deploy, _)| deploy.state == "pending");
|
||||||
|
let any_unhealthy = components
|
||||||
|
.values()
|
||||||
|
.any(|(_, _, probe)| probe.state == "unhealthy");
|
||||||
|
let any_drifted = target_sha.is_some_and(|target| {
|
||||||
|
components.values().any(|(_, _, probe)| {
|
||||||
|
probe
|
||||||
|
.revision
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|running| running != target)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let all_deployed = target_sha.is_some()
|
||||||
|
&& !components.is_empty()
|
||||||
|
&& components.values().all(|(_, deploy, probe)| {
|
||||||
|
deploy.state == "success"
|
||||||
|
&& probe.state == "healthy"
|
||||||
|
&& probe.revision.as_deref() == target_sha
|
||||||
|
});
|
||||||
|
let any_unavailable = components
|
||||||
|
.values()
|
||||||
|
.any(|(_, deploy, probe)| deploy.state == "unavailable" || probe.state == "unavailable");
|
||||||
|
let (state, detail) = if any_failed {
|
||||||
|
(
|
||||||
|
"failed",
|
||||||
|
"Gitea 明确报告部署失败;同时保留探针所见的运行版本",
|
||||||
|
)
|
||||||
|
} else if any_pending {
|
||||||
|
(
|
||||||
|
"pending",
|
||||||
|
"部署 context 尚未结束,当前运行版本可能仍是旧版本",
|
||||||
|
)
|
||||||
|
} else if any_unhealthy {
|
||||||
|
("unhealthy", "运行版本可读,但项目技术健康字段为 false")
|
||||||
|
} else if any_drifted {
|
||||||
|
("drifted", "当前运行 SHA 与 Gitea 默认分支目标 SHA 不一致")
|
||||||
|
} else if all_deployed {
|
||||||
|
(
|
||||||
|
"deployed",
|
||||||
|
"Gitea 部署 context 成功且当前运行 SHA 与目标一致",
|
||||||
|
)
|
||||||
|
} else if any_unavailable {
|
||||||
|
("unavailable", "Gitea 部署状态或运行探针不可达")
|
||||||
|
} else {
|
||||||
|
("unknown", "缺少足以判断 production 部署的双证据")
|
||||||
|
};
|
||||||
|
let running_revision = (components.len() == 1)
|
||||||
|
.then(|| {
|
||||||
|
components
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.and_then(|(_, _, probe)| probe.revision.clone())
|
||||||
|
})
|
||||||
|
.flatten();
|
||||||
|
let deployed_at = (components.len() == 1)
|
||||||
|
.then(|| {
|
||||||
|
components
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.and_then(|(_, deploy, _)| deploy.data["status"]["updated_at"].as_str())
|
||||||
|
.map(str::to_owned)
|
||||||
|
})
|
||||||
|
.flatten();
|
||||||
|
let mut evidence = Vec::new();
|
||||||
|
let component_data: BTreeMap<String, serde_json::Value> = components
|
||||||
|
.iter()
|
||||||
|
.map(|(name, (context, deploy, probe))| {
|
||||||
|
evidence.push(serde_json::json!({
|
||||||
|
"component": name,
|
||||||
|
"source": "gitea",
|
||||||
|
"state": deploy.state,
|
||||||
|
}));
|
||||||
|
evidence.push(serde_json::json!({
|
||||||
|
"component": name,
|
||||||
|
"source": "http_probe",
|
||||||
|
"state": probe.state,
|
||||||
|
}));
|
||||||
|
let revision_match = target_sha
|
||||||
|
.and_then(|target| probe.revision.as_deref().map(|running| running == target));
|
||||||
|
(
|
||||||
|
name.clone(),
|
||||||
|
serde_json::json!({
|
||||||
|
"deploy_context": context,
|
||||||
|
"deploy": deploy,
|
||||||
|
"health": probe,
|
||||||
|
"revision_match": revision_match,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
DeliverySignal::new(
|
||||||
|
state,
|
||||||
|
"gitea+http_probe",
|
||||||
|
detail,
|
||||||
|
serde_json::json!({
|
||||||
|
"environment": "production",
|
||||||
|
"target_revision": target_sha,
|
||||||
|
"running_revision": running_revision,
|
||||||
|
"deployed_at": deployed_at,
|
||||||
|
"components": component_data,
|
||||||
|
"evidence": evidence,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_probe_response(body: &[u8], probe: &HttpJsonProbe) -> ProbeEvidence {
|
||||||
|
let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) else {
|
||||||
|
return ProbeEvidence::unavailable("探针未返回有效 JSON");
|
||||||
|
};
|
||||||
|
let revision = value
|
||||||
|
.get(&probe.revision_field)
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(str::to_owned);
|
||||||
|
let healthy = value
|
||||||
|
.get(&probe.healthy_field)
|
||||||
|
.and_then(|value| value.as_bool());
|
||||||
|
if revision
|
||||||
|
.as_deref()
|
||||||
|
.is_none_or(|value| value.len() != 40 || !value.bytes().all(|ch| ch.is_ascii_hexdigit()))
|
||||||
|
|| healthy.is_none()
|
||||||
|
{
|
||||||
|
return ProbeEvidence::unavailable("探针缺少完整 Git SHA 或布尔健康字段");
|
||||||
|
}
|
||||||
|
ProbeEvidence {
|
||||||
|
state: if healthy == Some(true) {
|
||||||
|
"healthy"
|
||||||
|
} else {
|
||||||
|
"unhealthy"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
revision,
|
||||||
|
healthy,
|
||||||
|
detail: "已读取项目声明的只读 HTTP JSON 探针".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Clone, PartialEq)]
|
#[derive(Debug, Serialize, Clone, PartialEq)]
|
||||||
pub struct DeliverySignal {
|
pub struct DeliverySignal {
|
||||||
@ -81,13 +524,7 @@ pub fn get_delivery_status(
|
|||||||
pull_request: gitea.pull_request,
|
pull_request: gitea.pull_request,
|
||||||
ci: gitea.ci,
|
ci: gitea.ci,
|
||||||
review: gitea.review,
|
review: gitea.review,
|
||||||
// P1 尚未要求项目向炼境回传部署结果。保留独立信号位,防止把 CI 成功误报为线上健康。
|
deployment: inspect_declared_deployment(conn, project_id, project_root),
|
||||||
deployment: DeliverySignal::new(
|
|
||||||
"unknown",
|
|
||||||
"deployment_report",
|
|
||||||
"项目尚未向炼境回传部署完成或健康证据;CI 成功不等于 production 健康",
|
|
||||||
serde_json::json!({}),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,6 +661,169 @@ mod tests {
|
|||||||
get_delivery_status(&conn, "unbound", Path::new("E:/definitely-not-a-project"));
|
get_delivery_status(&conn, "unbound", Path::new("E:/definitely-not-a-project"));
|
||||||
assert_eq!(status.pull_request.state, "not_configured");
|
assert_eq!(status.pull_request.state, "not_configured");
|
||||||
assert_eq!(status.ci.state, "not_configured");
|
assert_eq!(status.ci.state, "not_configured");
|
||||||
assert_eq!(status.deployment.state, "unknown");
|
assert_eq!(status.deployment.state, "not_configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILE: &str = r#"
|
||||||
|
version: 1
|
||||||
|
delivery:
|
||||||
|
targets:
|
||||||
|
production:
|
||||||
|
provider: gitea-actions
|
||||||
|
components:
|
||||||
|
api:
|
||||||
|
deploy_context: "CI — DeepSeek Agent / deploy-api (push)"
|
||||||
|
probe:
|
||||||
|
kind: http-json
|
||||||
|
url: https://api3.yljt.info/health/version
|
||||||
|
revision_field: revision
|
||||||
|
healthy_field: ok
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn production_profile_has_fixed_read_only_shape() {
|
||||||
|
let profile = parse_production_profile(PROFILE).unwrap().unwrap();
|
||||||
|
assert_eq!(profile.provider, "gitea-actions");
|
||||||
|
let api = &profile.components["api"];
|
||||||
|
assert_eq!(
|
||||||
|
api.deploy_context,
|
||||||
|
"CI — DeepSeek Agent / deploy-api (push)"
|
||||||
|
);
|
||||||
|
assert_eq!(api.probe.url, "https://api3.yljt.info/health/version");
|
||||||
|
assert!(parse_production_profile(
|
||||||
|
&PROFILE.replace("provider: gitea-actions", "provider: ssh")
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
assert!(parse_production_profile(&PROFILE.replace(
|
||||||
|
"healthy_field: ok",
|
||||||
|
"healthy_field: ok\n headers: {Authorization: secret}"
|
||||||
|
))
|
||||||
|
.is_err());
|
||||||
|
assert!(parse_production_profile("version: 1\nplanning: {}\n")
|
||||||
|
.unwrap()
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_url_rejects_credentials_and_non_http_sources() {
|
||||||
|
for url in [
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"https://user:secret@api3.yljt.info/health/version",
|
||||||
|
"https://api3.yljt.info/health/version?token=secret",
|
||||||
|
"http://127.0.0.1:27190/mcp",
|
||||||
|
"http://[::1]/version",
|
||||||
|
"http://[::ffff:127.0.0.1]/version",
|
||||||
|
"http://192.168.1.10/version",
|
||||||
|
"http://localhost/version",
|
||||||
|
] {
|
||||||
|
assert!(validate_probe_url(url).is_err(), "{url} 应被拒绝");
|
||||||
|
}
|
||||||
|
assert!(validate_probe_url("https://api3.yljt.info/health/version").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_profile_is_not_configured_and_probe_body_is_strict() {
|
||||||
|
let empty = std::env::temp_dir().join(format!(
|
||||||
|
"lianjing_delivery_profile_absent_{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
assert!(read_production_profile(&empty).unwrap().is_none());
|
||||||
|
let probe = parse_production_profile(PROFILE)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.components
|
||||||
|
.remove("api")
|
||||||
|
.unwrap()
|
||||||
|
.probe;
|
||||||
|
let sha = "a".repeat(40);
|
||||||
|
let healthy = parse_probe_response(
|
||||||
|
format!(r#"{{"ok":true,"revision":"{sha}"}}"#).as_bytes(),
|
||||||
|
&probe,
|
||||||
|
);
|
||||||
|
assert_eq!(healthy.state, "healthy");
|
||||||
|
assert_eq!(healthy.revision.as_deref(), Some(sha.as_str()));
|
||||||
|
let unhealthy = parse_probe_response(
|
||||||
|
format!(r#"{{"ok":false,"revision":"{sha}"}}"#).as_bytes(),
|
||||||
|
&probe,
|
||||||
|
);
|
||||||
|
assert_eq!(unhealthy.state, "unhealthy");
|
||||||
|
assert_eq!(parse_probe_response(b"{}", &probe).state, "unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oversized_and_timed_out_probe_bodies_are_unavailable() {
|
||||||
|
let oversized = vec![b'x'; MAX_PROBE_BYTES as usize + 1];
|
||||||
|
assert!(read_bounded_probe(std::io::Cursor::new(oversized)).is_err());
|
||||||
|
|
||||||
|
struct TimedOut;
|
||||||
|
impl Read for TimedOut {
|
||||||
|
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(read_bounded_probe(TimedOut)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("timeout"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aggregate_fixture(
|
||||||
|
deploy_state: &str,
|
||||||
|
probe_state: &str,
|
||||||
|
running: Option<&str>,
|
||||||
|
) -> DeliverySignal {
|
||||||
|
let mut components = BTreeMap::new();
|
||||||
|
components.insert(
|
||||||
|
"api".to_string(),
|
||||||
|
(
|
||||||
|
"CI — DeepSeek Agent / deploy-api (push)".to_string(),
|
||||||
|
DeliverySignal::new(
|
||||||
|
deploy_state,
|
||||||
|
"gitea",
|
||||||
|
"fixture",
|
||||||
|
serde_json::json!({"status": {"updated_at": "2026-09-22T03:38:55Z"}}),
|
||||||
|
),
|
||||||
|
ProbeEvidence {
|
||||||
|
state: probe_state.to_string(),
|
||||||
|
revision: running.map(str::to_owned),
|
||||||
|
healthy: Some(probe_state == "healthy"),
|
||||||
|
detail: "fixture".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
aggregate_deployment(Some(&"a".repeat(40)), components)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deployment_requires_both_evidence_sources_and_exact_sha() {
|
||||||
|
let target = "a".repeat(40);
|
||||||
|
let old = "b".repeat(40);
|
||||||
|
let deployed = aggregate_fixture("success", "healthy", Some(&target));
|
||||||
|
assert_eq!(deployed.state, "deployed");
|
||||||
|
assert_eq!(deployed.data["target_revision"], target);
|
||||||
|
assert_eq!(deployed.data["running_revision"], target);
|
||||||
|
assert_eq!(deployed.data["evidence"].as_array().unwrap().len(), 2);
|
||||||
|
|
||||||
|
let drifted = aggregate_fixture("success", "healthy", Some(&old));
|
||||||
|
assert_eq!(drifted.state, "drifted");
|
||||||
|
assert_eq!(drifted.data["running_revision"], old);
|
||||||
|
let failed = aggregate_fixture("failure", "healthy", Some(&old));
|
||||||
|
assert_eq!(failed.state, "failed");
|
||||||
|
assert_eq!(failed.data["running_revision"], old);
|
||||||
|
assert_eq!(
|
||||||
|
aggregate_fixture("pending", "healthy", Some(&old)).state,
|
||||||
|
"pending"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
aggregate_fixture("success", "unhealthy", Some(&target)).state,
|
||||||
|
"unhealthy"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
aggregate_fixture("success", "unavailable", None).state,
|
||||||
|
"unavailable"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
aggregate_fixture("unknown", "healthy", Some(&target)).state,
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -607,6 +607,100 @@ pub fn summarize_status_context(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// production 目标始终来自 Gitea 默认分支,不取本地工作分支的 HEAD。
|
||||||
|
/// 这样 Agent 在 feature branch 上开发时也不会把未合并 SHA 当成线上目标。
|
||||||
|
pub fn get_production_context_signals(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
project_id: &str,
|
||||||
|
contexts: &[String],
|
||||||
|
) -> Result<(String, std::collections::BTreeMap<String, DeliverySignal>), DeliverySignal> {
|
||||||
|
let binding: rusqlite::Result<(String, String, String)> = conn.query_row(
|
||||||
|
"SELECT instance_id, gitea_owner, gitea_repo_name FROM gitea_repos WHERE project_id = ?1",
|
||||||
|
params![project_id],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||||
|
);
|
||||||
|
let (instance_id, owner, repo) = binding.map_err(|error| {
|
||||||
|
DeliverySignal::new(
|
||||||
|
if matches!(error, rusqlite::Error::QueryReturnedNoRows) {
|
||||||
|
"not_configured"
|
||||||
|
} else {
|
||||||
|
"unavailable"
|
||||||
|
},
|
||||||
|
"gitea",
|
||||||
|
format!("读取项目 Gitea 绑定失败:{error}"),
|
||||||
|
serde_json::json!({}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let (base_url, token) = instance_auth(conn, &instance_id).map_err(|error| {
|
||||||
|
DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({}))
|
||||||
|
})?;
|
||||||
|
let agent = ureq::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
|
.build();
|
||||||
|
let read = |path: &str, label: &str| {
|
||||||
|
agent
|
||||||
|
.get(&format!("{base_url}{path}"))
|
||||||
|
.set("Authorization", &format!("token {token}"))
|
||||||
|
.call()
|
||||||
|
.map_err(|error| api_err(label, error))
|
||||||
|
.and_then(|response| {
|
||||||
|
response
|
||||||
|
.into_json::<serde_json::Value>()
|
||||||
|
.map_err(|error| format!("{label}: 解析响应失败: {error}"))
|
||||||
|
})
|
||||||
|
.map_err(|error| {
|
||||||
|
DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({}))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let repository = read(
|
||||||
|
&format!("/api/v1/repos/{owner}/{repo}"),
|
||||||
|
"读取 Gitea 仓库失败",
|
||||||
|
)?;
|
||||||
|
let branch = repository["default_branch"]
|
||||||
|
.as_str()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
DeliverySignal::new(
|
||||||
|
"unknown",
|
||||||
|
"gitea",
|
||||||
|
"仓库未返回默认分支",
|
||||||
|
serde_json::json!({}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let branch_path = format!(
|
||||||
|
"/api/v1/repos/{owner}/{repo}/branches/{}",
|
||||||
|
urlencoding::encode(branch)
|
||||||
|
);
|
||||||
|
let branch_response = read(&branch_path, "读取 Gitea 默认分支失败")?;
|
||||||
|
let target_sha = branch_response["commit"]["id"]
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| branch_response["commit"]["sha"].as_str())
|
||||||
|
.filter(|value| value.len() == 40 && value.bytes().all(|ch| ch.is_ascii_hexdigit()))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
DeliverySignal::new(
|
||||||
|
"unknown",
|
||||||
|
"gitea",
|
||||||
|
"默认分支未返回完整 commit SHA",
|
||||||
|
serde_json::json!({}),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
let statuses = read(
|
||||||
|
&format!("/api/v1/repos/{owner}/{repo}/statuses/{target_sha}?sort=recentupdate&limit=50"),
|
||||||
|
"读取 Gitea 部署 context 失败",
|
||||||
|
)?;
|
||||||
|
let signals = contexts
|
||||||
|
.iter()
|
||||||
|
.map(|context| {
|
||||||
|
(
|
||||||
|
context.clone(),
|
||||||
|
summarize_status_context(&statuses, &target_sha, context),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok((target_sha, signals))
|
||||||
|
}
|
||||||
|
|
||||||
/// 对每位 reviewer 仅保留提交时间最新的状态,避免旧的 approve 掩盖后续 request changes。
|
/// 对每位 reviewer 仅保留提交时间最新的状态,避免旧的 approve 掩盖后续 request changes。
|
||||||
pub fn summarize_reviews(reviews: &serde_json::Value) -> DeliverySignal {
|
pub fn summarize_reviews(reviews: &serde_json::Value) -> DeliverySignal {
|
||||||
let Some(items) = reviews.as_array() else {
|
let Some(items) = reviews.as_array() else {
|
||||||
@ -1911,6 +2005,74 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn production_context_uses_remote_default_branch_and_exact_job_name() {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
listener.set_nonblocking(true).unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let sha = "a".repeat(40);
|
||||||
|
let status_body = serde_json::json!([{
|
||||||
|
"context": "CI — DeepSeek Agent / deploy-api (push)",
|
||||||
|
"status": "success",
|
||||||
|
"description": "Success",
|
||||||
|
"updated_at": "2026-09-22T03:38:55Z"
|
||||||
|
}])
|
||||||
|
.to_string();
|
||||||
|
let responses = [
|
||||||
|
(
|
||||||
|
"/api/v1/repos/o/r",
|
||||||
|
r#"{"default_branch":"main"}"#.to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/api/v1/repos/o/r/branches/main",
|
||||||
|
format!(r#"{{"commit":{{"id":"{sha}"}}}}"#),
|
||||||
|
),
|
||||||
|
("/api/v1/repos/o/r/statuses/", status_body),
|
||||||
|
];
|
||||||
|
let server = std::thread::spawn(move || {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
for (expected_path, body) in responses {
|
||||||
|
let mut stream = loop {
|
||||||
|
if let Ok((stream, _)) = listener.accept() {
|
||||||
|
break stream;
|
||||||
|
}
|
||||||
|
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
};
|
||||||
|
let mut request = [0u8; 4096];
|
||||||
|
let len = stream.read(&mut request).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&request[..len]);
|
||||||
|
assert!(
|
||||||
|
request.starts_with(&format!("GET {expected_path}")),
|
||||||
|
"{request}"
|
||||||
|
);
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
stream.write_all(response.as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i1','test',?1,'token')",
|
||||||
|
params![format!("http://{address}")],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name) VALUES ('r1','i1','p1','o','r')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let context = "CI — DeepSeek Agent / deploy-api (push)".to_string();
|
||||||
|
let (target, signals) =
|
||||||
|
get_production_context_signals(&conn, "p1", &[context.clone()]).unwrap();
|
||||||
|
assert_eq!(target, sha);
|
||||||
|
assert_eq!(signals[&context].state, "success");
|
||||||
|
server.join().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gitea_1_22_skipped_job_is_not_deployment_success() {
|
fn gitea_1_22_skipped_job_is_not_deployment_success() {
|
||||||
// 脱敏自 enterprise-system 真实 run #529:Gitea 1.22.6 把 skipped job
|
// 脱敏自 enterprise-system 真实 run #529:Gitea 1.22.6 把 skipped job
|
||||||
|
|||||||
@ -546,6 +546,29 @@ fn check_capability_packs(content: Option<&str>) -> VerifyCheck {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn check_delivery_profile(content: &str) -> VerifyCheck {
|
||||||
|
match crate::commands::delivery_status::parse_production_profile(content) {
|
||||||
|
Ok(Some(profile)) => VerifyCheck::with_state(
|
||||||
|
"production delivery profile",
|
||||||
|
VerifyState::Ready,
|
||||||
|
format!(
|
||||||
|
".lian-jing/capabilities.yaml 已声明 {} 个只读组件探针;项目文件由项目自行维护,接入包不会覆盖",
|
||||||
|
profile.components.len()
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Ok(None) => VerifyCheck::with_state(
|
||||||
|
"production delivery profile",
|
||||||
|
VerifyState::Ready,
|
||||||
|
".lian-jing/capabilities.yaml 格式有效;未配置 production delivery(可选)",
|
||||||
|
),
|
||||||
|
Err(error) => VerifyCheck::with_state(
|
||||||
|
"production delivery profile",
|
||||||
|
VerifyState::Unavailable,
|
||||||
|
format!(".lian-jing/capabilities.yaml 无效:{error};请在项目内修复,接入包不会覆盖"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn check_toolchain_resolver(root: &Path) -> VerifyCheck {
|
fn check_toolchain_resolver(root: &Path) -> VerifyCheck {
|
||||||
match crate::commands::toolchain::parse_toolchain_contract(root) {
|
match crate::commands::toolchain::parse_toolchain_contract(root) {
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@ -844,6 +867,18 @@ pub fn verify_onboarding(
|
|||||||
checks.push(check_capability_packs(
|
checks.push(check_capability_packs(
|
||||||
read(".lian-jing/capability-packs.json").as_deref(),
|
read(".lian-jing/capability-packs.json").as_deref(),
|
||||||
));
|
));
|
||||||
|
// delivery profile 是可选的项目所有声明;缺席不降低基础 onboarding 的通过率。
|
||||||
|
let delivery_path = root.join(".lian-jing/capabilities.yaml");
|
||||||
|
if delivery_path.exists() {
|
||||||
|
checks.push(match std::fs::read_to_string(&delivery_path) {
|
||||||
|
Ok(content) => check_delivery_profile(&content),
|
||||||
|
Err(error) => VerifyCheck::with_state(
|
||||||
|
"production delivery profile",
|
||||||
|
VerifyState::Unavailable,
|
||||||
|
format!("无法读取项目声明:{error}"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
if node_pnpm_contract_present(root) || root.join("mise.toml").exists() {
|
if node_pnpm_contract_present(root) || root.join("mise.toml").exists() {
|
||||||
checks.push(check_toolchain_resolver(root));
|
checks.push(check_toolchain_resolver(root));
|
||||||
}
|
}
|
||||||
@ -1325,6 +1360,29 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&base);
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_owned_delivery_profile_is_checked_but_never_overwritten() {
|
||||||
|
let base = std::env::temp_dir().join(format!(
|
||||||
|
"lianjing_delivery_onboarding_test_{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
std::fs::create_dir_all(base.join(".lian-jing")).unwrap();
|
||||||
|
let profile_path = base.join(".lian-jing/capabilities.yaml");
|
||||||
|
let profile = "version: 1\ndelivery:\n targets:\n production:\n provider: gitea-actions\n components:\n api:\n deploy_context: deploy-api\n probe:\n kind: http-json\n url: https://api3.yljt.info/health/version\n revision_field: revision\n healthy_field: ok\n";
|
||||||
|
std::fs::write(&profile_path, profile).unwrap();
|
||||||
|
dispatch_templates(base.to_str().unwrap());
|
||||||
|
assert_eq!(std::fs::read_to_string(&profile_path).unwrap(), profile);
|
||||||
|
let check = check_delivery_profile(profile);
|
||||||
|
assert_eq!(check.state, VerifyState::Ready);
|
||||||
|
let invalid = check_delivery_profile(&profile.replace(
|
||||||
|
"url: https://api3.yljt.info/health/version",
|
||||||
|
"url: file:///etc/passwd",
|
||||||
|
));
|
||||||
|
assert_eq!(invalid.state, VerifyState::Unavailable);
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn managed_version_check_states() {
|
fn managed_version_check_states() {
|
||||||
let expected = extract_managed_block(TMPL_AGENTS)
|
let expected = extract_managed_block(TMPL_AGENTS)
|
||||||
|
|||||||
@ -187,7 +187,7 @@ pub fn tools_list_result() -> Value {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_delivery_status",
|
"name": "get_delivery_status",
|
||||||
"description": "读取项目当前交付信号:本地 Git 工作区与最近 SHA、当前分支的 Gitea PR、该 SHA 的 CI、PR review,以及已知 deployment/health 证据。该工具只呈现来源信号,不替项目判断是否应该发布;来源不可用或未接入时会明确返回 unknown/unavailable。",
|
"description": "只读读取项目交付信号:本地 Git/PR/CI/review,以及项目声明的 production Gitea 部署 context 与公开 HTTP 版本探针。仅当部署成功、探针健康且运行 SHA 与 Gitea 默认分支目标 SHA 完全相同才返回 deployed;失败、漂移、未配置或来源不可达分别保留 failed/drifted/not_configured/unavailable,不替项目作发布决定。",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user