898 lines
30 KiB
Rust
898 lines
30 KiB
Rust
//! 项目交付状态聚合。
|
||
//!
|
||
//! 本模块只采集和呈现信号,不替项目判断功能是否应交付。Git 本地状态、Gitea
|
||
//! 信号与部署证据各自独立降级:任一来源不可用时必须明确标出,而不能猜测成功。
|
||
|
||
use std::{
|
||
collections::BTreeMap,
|
||
io::{self, Read},
|
||
net::{IpAddr, SocketAddr, ToSocketAddrs},
|
||
path::Path,
|
||
process::Command,
|
||
time::Duration,
|
||
};
|
||
|
||
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.state == "success")
|
||
.then(|| deploy.data["status"]["updated_at"].as_str())
|
||
.flatten()
|
||
})
|
||
.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 {
|
||
/// 领域状态,例如 `passed`、`open`、`unknown`、`unavailable`。
|
||
pub state: String,
|
||
/// 产生该信号的系统,例如 `gitea`、`local_git`、`deployment_report`。
|
||
pub source: String,
|
||
/// 面向 Agent 的可读解释;不得把未知状态描述成成功。
|
||
pub detail: String,
|
||
/// 结构化原始摘要,便于不同 Agent 作自己的交付判断。
|
||
pub data: serde_json::Value,
|
||
}
|
||
|
||
impl DeliverySignal {
|
||
pub fn new(
|
||
state: impl Into<String>,
|
||
source: impl Into<String>,
|
||
detail: impl Into<String>,
|
||
data: serde_json::Value,
|
||
) -> Self {
|
||
Self {
|
||
state: state.into(),
|
||
source: source.into(),
|
||
detail: detail.into(),
|
||
data,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Serialize, Clone, PartialEq)]
|
||
pub struct RecentCommit {
|
||
pub sha: String,
|
||
pub short_sha: String,
|
||
pub committed_at: String,
|
||
pub subject: String,
|
||
}
|
||
|
||
#[derive(Debug, Serialize, Clone, PartialEq)]
|
||
pub struct GitDeliveryStatus {
|
||
/// `clean` / `dirty` / `unavailable`。
|
||
pub state: String,
|
||
pub source: String,
|
||
pub detail: String,
|
||
pub branch: Option<String>,
|
||
pub changed_files: usize,
|
||
pub recent_commit: Option<RecentCommit>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize, Clone, PartialEq)]
|
||
pub struct DeliveryStatus {
|
||
pub project_id: String,
|
||
pub git: GitDeliveryStatus,
|
||
pub pull_request: DeliverySignal,
|
||
pub ci: DeliverySignal,
|
||
pub review: DeliverySignal,
|
||
pub deployment: DeliverySignal,
|
||
}
|
||
|
||
/// 聚合已注册项目的交付信号。连接由 MCP/Tauri 边界传入,方便 in-memory DB 测试。
|
||
pub fn get_delivery_status(
|
||
conn: &rusqlite::Connection,
|
||
project_id: &str,
|
||
project_root: &Path,
|
||
) -> DeliveryStatus {
|
||
let git = inspect_git_status(project_root);
|
||
let sha = git.recent_commit.as_ref().map(|commit| commit.sha.as_str());
|
||
let gitea =
|
||
crate::commands::gitea::get_delivery_signals(conn, project_id, git.branch.as_deref(), sha);
|
||
|
||
DeliveryStatus {
|
||
project_id: project_id.to_string(),
|
||
git,
|
||
pull_request: gitea.pull_request,
|
||
ci: gitea.ci,
|
||
review: gitea.review,
|
||
deployment: inspect_declared_deployment(conn, project_id, project_root),
|
||
}
|
||
}
|
||
|
||
/// 读取本地 Git,不依赖网络。失败也返回结构化状态,使 MCP 调用可继续展示其他来源。
|
||
pub fn inspect_git_status(project_root: &Path) -> GitDeliveryStatus {
|
||
let porcelain = match run_git(project_root, ["status", "--porcelain=v1", "--branch"]) {
|
||
Ok(output) => output,
|
||
Err(detail) => {
|
||
return GitDeliveryStatus {
|
||
state: "unavailable".into(),
|
||
source: "local_git".into(),
|
||
detail,
|
||
branch: None,
|
||
changed_files: 0,
|
||
recent_commit: None,
|
||
}
|
||
}
|
||
};
|
||
let (branch, changed_files) = parse_porcelain_status(&porcelain);
|
||
let recent_commit = run_git(
|
||
project_root,
|
||
["log", "-1", "--format=%H%x1f%h%x1f%cI%x1f%s"],
|
||
)
|
||
.ok()
|
||
.and_then(|output| parse_recent_commit(&output));
|
||
let state = if changed_files == 0 { "clean" } else { "dirty" };
|
||
|
||
GitDeliveryStatus {
|
||
state: state.into(),
|
||
source: "local_git".into(),
|
||
detail: if changed_files == 0 {
|
||
"工作区无未提交改动".into()
|
||
} else {
|
||
format!("工作区有 {changed_files} 个未提交路径")
|
||
},
|
||
branch,
|
||
changed_files,
|
||
recent_commit,
|
||
}
|
||
}
|
||
|
||
fn run_git<const N: usize>(project_root: &Path, args: [&str; N]) -> Result<String, String> {
|
||
let output = Command::new("git")
|
||
.arg("-C")
|
||
.arg(project_root)
|
||
.args(args)
|
||
.output()
|
||
.map_err(|e| format!("无法执行本地 git:{e}"))?;
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||
return Err(if stderr.is_empty() {
|
||
"本地 git 命令失败".into()
|
||
} else {
|
||
format!("本地 git 命令失败:{stderr}")
|
||
});
|
||
}
|
||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||
}
|
||
|
||
pub fn parse_porcelain_status(output: &str) -> (Option<String>, usize) {
|
||
let mut lines = output.lines();
|
||
let branch = lines.next().and_then(|line| {
|
||
line.strip_prefix("## ").and_then(|value| {
|
||
let name = value
|
||
.strip_prefix("No commits yet on ")
|
||
.unwrap_or(value)
|
||
.split("...")
|
||
.next()
|
||
.unwrap_or(value)
|
||
.trim()
|
||
.trim_end_matches(" [gone]");
|
||
(!name.is_empty() && !name.starts_with("HEAD (")).then(|| name.to_string())
|
||
})
|
||
});
|
||
(branch, lines.filter(|line| !line.trim().is_empty()).count())
|
||
}
|
||
|
||
pub fn parse_recent_commit(output: &str) -> Option<RecentCommit> {
|
||
let mut fields = output.trim().split('\u{1f}');
|
||
let sha = fields.next()?.trim();
|
||
let short_sha = fields.next()?.trim();
|
||
let committed_at = fields.next()?.trim();
|
||
let subject = fields.next()?.trim();
|
||
(!sha.is_empty()).then(|| RecentCommit {
|
||
sha: sha.to_string(),
|
||
short_sha: short_sha.to_string(),
|
||
committed_at: committed_at.to_string(),
|
||
subject: subject.to_string(),
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn porcelain_parser_reports_branch_and_dirty_paths() {
|
||
let (branch, changed_files) = parse_porcelain_status(
|
||
"## feat/delivery...origin/feat/delivery [ahead 1]\n M src/a.rs\n?? docs/new.md\n",
|
||
);
|
||
assert_eq!(branch.as_deref(), Some("feat/delivery"));
|
||
assert_eq!(changed_files, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn porcelain_parser_handles_unborn_and_detached_heads() {
|
||
let (branch, _) = parse_porcelain_status("## No commits yet on feat/new\n?? file.rs\n");
|
||
assert_eq!(branch.as_deref(), Some("feat/new"));
|
||
let (branch, _) = parse_porcelain_status("## HEAD (detached at abc123)\n");
|
||
assert!(branch.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn recent_commit_parser_keeps_delivery_anchor() {
|
||
let commit = parse_recent_commit(
|
||
"1234567890abcdef\u{1f}12345678\u{1f}2026-09-21T10:00:00+09:00\u{1f}feat(mcp): status\n",
|
||
)
|
||
.expect("应解析 commit");
|
||
assert_eq!(commit.short_sha, "12345678");
|
||
assert_eq!(commit.subject, "feat(mcp): status");
|
||
}
|
||
|
||
#[test]
|
||
fn unavailable_local_git_is_explicit() {
|
||
let status = inspect_git_status(Path::new("E:/definitely-not-a-project"));
|
||
assert_eq!(status.state, "unavailable");
|
||
assert!(status.recent_commit.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn no_gitea_binding_does_not_fabricate_delivery_success() {
|
||
let conn = crate::db::conn_with_schema();
|
||
let status =
|
||
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, "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["deployed_at"], "2026-09-22T03:38:55Z");
|
||
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!(failed.data["deployed_at"].is_null());
|
||
let pending = aggregate_fixture("pending", "healthy", Some(&old));
|
||
assert_eq!(pending.state, "pending");
|
||
assert_eq!(pending.data["running_revision"], old);
|
||
assert!(pending.data["deployed_at"].is_null());
|
||
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!(skipped.data["deployed_at"].is_null());
|
||
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"
|
||
);
|
||
}
|
||
}
|