feat(delivery): observe production revision with dual evidence #19

Merged
lanrtop merged 4 commits from feat/project-capability-control-plane-p3c into master 2026-09-23 11:18:16 +08:00
2 changed files with 66 additions and 25 deletions
Showing only changes of commit 2b577f6902 - Show all commits

View File

@ -141,7 +141,7 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言:
## production 交付观测边界 ## production 交付观测边界
项目可自主管理 `.lian-jing/capabilities.yaml` 的 `delivery.targets.production`,声明 Gitea Actions 的**精确 status context** 与公开 HTTP JSON 版本探针。炼境只读该声明;接入包不生成或覆盖项目的 production 配置。探针不接受 URL 凭证、认证头、查询参数、非 HTTP(S) 协议或本机/内网字面地址,不跟随重定向,并限制请求时间与响应大小。 项目可自主管理 `.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 或数据库写权限。 `get_delivery_status` 分别采集 Gitea 默认分支目标 SHA 的部署过程证据和当前服务报告的运行 SHA。只有部署 context 成功、探针健康、两个完整 SHA 相等时才报告 `deployed`。`failed` 保留实际运行版本,`drifted` 表示版本不一致;缺失/不可达证据明确降级。该状态仅是技术交付观测,不替代业务测试、上线决策,也不赋予炼境部署、回滚、SSH 或数据库写权限。

View File

@ -4,7 +4,12 @@
//! 信号与部署证据各自独立降级:任一来源不可用时必须明确标出,而不能猜测成功。 //! 信号与部署证据各自独立降级:任一来源不可用时必须明确标出,而不能猜测成功。
use std::{ use std::{
collections::BTreeMap, io::Read, net::IpAddr, path::Path, process::Command, time::Duration, collections::BTreeMap,
io::{self, Read},
net::{IpAddr, SocketAddr, ToSocketAddrs},
path::Path,
process::Command,
time::Duration,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -170,41 +175,63 @@ fn validate_probe_url(raw: &str) -> Result<(), String> {
} }
let ip_host = host.trim_start_matches('[').trim_end_matches(']'); let ip_host = host.trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = ip_host.parse::<IpAddr>() { if let Ok(ip) = ip_host.parse::<IpAddr>() {
let blocked = match ip { if !is_public_probe_ip(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()); return Err("探针不允许本机或内网 IP".into());
} }
} }
Ok(()) 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 或请求头。 /// 探针只用项目声明的 URL 发起无认证 GET,不接受 Agent 动态 URL 或请求头。
pub fn probe_http_json(probe: &HttpJsonProbe) -> ProbeEvidence { pub fn probe_http_json(probe: &HttpJsonProbe) -> ProbeEvidence {
if let Err(detail) = validate_probe_url(&probe.url) { if let Err(detail) = validate_probe_url(&probe.url) {
return ProbeEvidence::unavailable(detail); return ProbeEvidence::unavailable(detail);
} }
let agent = ureq::AgentBuilder::new() let agent = ureq::AgentBuilder::new()
.try_proxy_from_env(false)
.resolver(|addr: &str| retain_public_probe_addresses(addr.to_socket_addrs()?))
.redirects(0) .redirects(0)
.timeout(Duration::from_secs(4)) .timeout(Duration::from_secs(4))
.timeout_connect(Duration::from_secs(3)) .timeout_connect(Duration::from_secs(3))
@ -721,6 +748,20 @@ delivery:
assert!(validate_probe_url("https://api3.yljt.info/health/version").is_ok()); 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] #[test]
fn missing_profile_is_not_configured_and_probe_body_is_strict() { fn missing_profile_is_not_configured_and_probe_body_is_strict() {
let empty = std::env::temp_dir().join(format!( let empty = std::env::temp_dir().join(format!(