dev-manager-tauri/src-tauri/src/commands/delivery_status.rs
lanrtop dbc1b068ab
All checks were successful
Push & PR Check / check (push) Successful in 59s
Push & PR Check / check (pull_request) Successful in 1m0s
fix(delivery): avoid false deployed timestamp
Rules-Applied: R07,R13
2026-09-23 12:09:38 +09:00

898 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 项目交付状态聚合。
//!
//! 本模块只采集和呈现信号,不替项目判断功能是否应交付。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"
);
}
}