Compare commits

...

3 Commits

Author SHA1 Message Date
lanrtop
8bc33aaab9 fix(delivery): distinguish skipped production deploy
All checks were successful
Push & PR Check / check (push) Successful in 1m18s
Push & PR Check / check (pull_request) Successful in 58s
2026-09-23 11:08:10 +09:00
lanrtop
2b577f6902 fix(delivery): reject private DNS probe targets
Filter resolved connection addresses and bypass environment proxies so project-declared probes cannot reach local services through public hostnames.
2026-09-23 10:44:05 +09:00
lanrtop
02b9f1ec36 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
2026-09-23 10:37:07 +09:00
9 changed files with 922 additions and 22 deletions

View File

@ -194,7 +194,8 @@ GET /health/version
- `not_configured`:项目没有 production delivery profile。
- `pending`:部署 context 尚未到达最终状态。
- `deployed`:部署成功、探针健康、运行 SHA 与目标 SHA 一致。
- `drifted`:探针可达,但运行 SHA 与目标 SHA 不一致(含回滚后的旧版本)。
- `drifted`:本次部署 context 成功、探针可达,但运行 SHA 与目标 SHA 不一致(含回滚后的旧版本)。
- `skipped`:本次默认分支提交未触发部署;保留实际运行 SHA,不把正常跳过误报为漂移或部署成功。
- `failed`:Gitea 明确报告部署失败;若探针仍健康,必须同时展示实际运行 SHA,不能把线上误报为不可用。
- `unhealthy`:运行 SHA 可读但技术健康失败。
- `unknown`:缺少足以判断的证据。
@ -324,25 +325,27 @@ 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 可读取真实结果。"
- 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 与版本健康端点
- status: todo
### 🔵 P3-B. Enterprise System 不可变 revision 与版本健康端点
- status: in_progress
- 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
- 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 自检
- status: todo
### 🔵 P3-C. production delivery profile、安全探针与 onboarding 自检
- status: in_progress
- complexity: M
- files: src-tauri/src/commands/delivery_status.rs, src-tauri/src/commands/onboarding.rs, src-tauri/resources/onboarding/
- depends: A. Canonical 项目上下文与 capability schema
- acceptance: "[cargo test] 读取声明了 production/http-json probe 的 capability profile → 得到固定 environment/component/context/url 字段;`file:`、URL 内凭证、动态认证头、超时响应和超限响应体分别被拒绝或降级为 unavailable;未配置项目返回 not_configured,onboarding 自检不覆盖已有声明。"
### 📋 P3-D. deployment 双证据聚合与 MCP 契约
- status: todo
### 🔵 P3-D. deployment 双证据聚合与 MCP 契约
- status: in_progress
- complexity: M
- 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 自检
- acceptance: "[MCP] `get_delivery_status(project_id)` 对固定 fixture 调用 → Gitea success + probe healthy + SHA 相同返回 deployed;SHA 不同返回 drifted;deploy failure 同时保留实际运行 SHA;来源不可达返回 unavailable;证据不足返回 unknown,且现有 Git/PR/CI/review 顶层字段保持兼容。"
- acceptance: "[MCP] `get_delivery_status(project_id)` 对固定 fixture 调用 → Gitea success + probe healthy + SHA 相同返回 deployed;成功部署但 SHA 不同返回 drifted;deploy skipped 且运行旧 SHA 返回 skipped;deploy failure 同时保留实际运行 SHA;来源不可达返回 unavailable;证据不足返回 unknown,且现有 Git/PR/CI/review 顶层字段保持兼容。"
- progress_note: "2026-09-23:真实 Enterprise run #529 的 deploy-api 为 skipped;聚合层新增 skipped 与旧运行 SHA 回归测试,避免普通文档提交误报 drifted。隔离分支 cargo test --lib 187/187;正式版尚未加载新代码,production 探针当前为 401,待 PR/发布后做 MCP 端到端验收。"
### 📋 P3-E. Enterprise production 端到端验收与观测基线
- status: todo

View File

@ -139,6 +139,12 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言:
这套 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`;本次提交的部署 job 被跳过时报告 `skipped` 并保留运行 SHA,不把文档类提交误报为漂移。缺失/不可达证据明确降级。该状态仅是技术交付观测,不替代业务测试、上线决策,也不赋予炼境部署、回滚、SSH 或数据库写权限。
## 三条设计原则
辩证结论,缺一会让体系在现实中碎裂。

View File

@ -3,7 +3,7 @@
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
> `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 健康。
- `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。
- `not_configured`:项目尚未声明 `.lian-jing/capabilities.yaml` 的 production delivery profile;这是正常的可选接入状态,不代表部署失败。
- `deployed` 仅表示 Gitea 部署 context 成功、公开版本探针健康且运行 SHA 与默认分支目标 SHA 完全一致;成功部署后 SHA 不一致才标 `drifted`。本次提交部署 job 被跳过时标 `skipped` 并保留实际运行 SHA,不误报漂移。技术健康不替代业务验收。
- production profile 由项目维护,炼境接入包不会覆盖;只声明公开 HTTP(S) JSON URL、精确 Gitea context 和字段名,不放凭证或命令。
- 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。
## PR 合并(炼境托管项目)

View File

@ -15,7 +15,7 @@ contracts:
- "src-tauri/src/commands/toolchain.rs"
- "src-tauri/src/commands/cicd.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: 被管理项目身份上下文
paths:

View File

@ -3,7 +3,7 @@
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
> `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 健康。
- `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 合并(炼境托管项目)

View File

@ -3,9 +3,488 @@
//! 本模块只采集和呈现信号,不替项目判断功能是否应交付。Git 本地状态、Gitea
//! 信号与部署证据各自独立降级:任一来源不可用时必须明确标出,而不能猜测成功。
use std::{path::Path, process::Command};
use std::{
collections::BTreeMap,
io::{self, Read},
net::{IpAddr, SocketAddr, ToSocketAddrs},
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>() {
if !is_public_probe_ip(ip) {
return Err("探针不允许本机或内网 IP".into());
}
}
Ok(())
}
fn is_public_probe_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => {
let octets = ip.octets();
!ip.is_private()
&& !ip.is_loopback()
&& !ip.is_link_local()
&& !ip.is_multicast()
&& !ip.is_unspecified()
&& !ip.is_broadcast()
&& octets[0] != 0
&& !(octets[0] == 100 && (64..=127).contains(&octets[1]))
}
IpAddr::V6(ip) => {
if let Some(ipv4) = ip.to_ipv4() {
return is_public_probe_ip(IpAddr::V4(ipv4));
}
!ip.is_loopback()
&& !ip.is_unique_local()
&& !ip.is_unicast_link_local()
&& !ip.is_multicast()
&& !ip.is_unspecified()
}
}
}
fn retain_public_probe_addresses(
addresses: impl IntoIterator<Item = SocketAddr>,
) -> io::Result<Vec<SocketAddr>> {
let safe: Vec<_> = addresses
.into_iter()
.filter(|address| is_public_probe_ip(address.ip()))
.collect();
if safe.is_empty() {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"探针域名未解析到允许的公网地址",
));
}
Ok(safe)
}
/// 探针只用项目声明的 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()
.try_proxy_from_env(false)
.resolver(|addr: &str| retain_public_probe_addresses(addr.to_socket_addrs()?))
.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_skipped = components
.values()
.any(|(_, deploy, _)| deploy.state == "skipped");
let any_unhealthy = components
.values()
.any(|(_, _, probe)| probe.state == "unhealthy");
let any_drifted = target_sha.is_some_and(|target| {
components.values().any(|(_, deploy, probe)| {
deploy.state == "success"
&& 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 if any_skipped {
(
"skipped",
"本次默认分支提交未触发部署;保留当前运行版本,不将正常跳过误报为漂移",
)
} 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)]
pub struct DeliverySignal {
@ -81,13 +560,7 @@ pub fn get_delivery_status(
pull_request: gitea.pull_request,
ci: gitea.ci,
review: gitea.review,
// P1 尚未要求项目向炼境回传部署结果。保留独立信号位,防止把 CI 成功误报为线上健康。
deployment: DeliverySignal::new(
"unknown",
"deployment_report",
"项目尚未向炼境回传部署完成或健康证据;CI 成功不等于 production 健康",
serde_json::json!({}),
),
deployment: inspect_declared_deployment(conn, project_id, project_root),
}
}
@ -224,6 +697,194 @@ mod tests {
get_delivery_status(&conn, "unbound", Path::new("E:/definitely-not-a-project"));
assert_eq!(status.pull_request.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 probe_dns_resolver_never_returns_private_addresses() {
let loopback: SocketAddr = "127.0.0.1:443".parse().unwrap();
let private: SocketAddr = "192.168.1.1:443".parse().unwrap();
let carrier_nat: SocketAddr = "100.64.0.1:443".parse().unwrap();
let public: SocketAddr = "8.8.8.8:443".parse().unwrap();
assert!(retain_public_probe_addresses([loopback, private, carrier_nat]).is_err());
assert_eq!(
retain_public_probe_addresses([loopback, public, private]).unwrap(),
vec![public]
);
assert!(validate_probe_url("http://100.64.0.1/version").is_err());
}
#[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"
);
let skipped = aggregate_fixture("skipped", "healthy", Some(&old));
assert_eq!(skipped.state, "skipped");
assert_eq!(skipped.data["running_revision"], old);
assert_eq!(
aggregate_fixture("skipped", "unavailable", None).state,
"unavailable"
);
assert_eq!(
aggregate_fixture("unknown", "healthy", Some(&old)).state,
"unknown"
);
assert_eq!(
aggregate_fixture("unknown", "healthy", Some(&target)).state,
"unknown"
);
}
}

View File

@ -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。
pub fn summarize_reviews(reviews: &serde_json::Value) -> DeliverySignal {
let Some(items) = reviews.as_array() else {
@ -1911,6 +2005,78 @@ mod tests {
);
}
#[test]
fn production_context_uses_remote_default_branch_and_exact_job_name() {
use std::io::{BufRead, BufReader, 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));
};
// Windows 上从 nonblocking listener 接受的 socket 也可能保持 nonblocking。
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(std::time::Duration::from_secs(3)))
.unwrap();
let mut request = String::new();
BufReader::new(&mut stream).read_line(&mut request).unwrap();
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]
fn gitea_1_22_skipped_job_is_not_deployment_success() {
// 脱敏自 enterprise-system 真实 run #529:Gitea 1.22.6 把 skipped job

View File

@ -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 {
match crate::commands::toolchain::parse_toolchain_contract(root) {
Err(error) => {
@ -844,6 +867,18 @@ pub fn verify_onboarding(
checks.push(check_capability_packs(
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() {
checks.push(check_toolchain_resolver(root));
}
@ -1325,6 +1360,29 @@ mod tests {
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]
fn managed_version_check_states() {
let expected = extract_managed_block(TMPL_AGENTS)

View File

@ -187,7 +187,7 @@ pub fn tools_list_result() -> Value {
},
{
"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/skipped/drifted/not_configured/unavailable,不替项目作发布决定。",
"inputSchema": {
"type": "object",
"properties": {