Skip to main content

wist_api/facts/
v1.rs

1//! `agent/facts` seam —— **v1** 基线。
2//!
3//! 冻结基线:只做**加性**兼容不动它;非加性变更就新开 `v2`。
4//! 约定见 `wist-design/doc/design/foundation/api-seam-inventory.md` §7。
5
6use serde::{Deserialize, Serialize};
7
8use wist_contracts::API_VERSION_V1;
9
10/// 本版本的线上版本号(与路由 `/api/v1/…` 一致)。
11pub const API_VERSION: &str = API_VERSION_V1;
12
13pub const REPORT_AGENT_FACT_SUMMARY_KIND: &str = "report_agent_fact_summary";
14
15/// 事实上报的确认状态。
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum FactSummaryAckStatus {
19    /// 已入库。
20    Accepted,
21    /// 内容未变(网关按**自算**摘要判定):只刷留痕,未改内容、未重复计分。
22    Duplicate,
23    /// envelope 或身份非法。
24    Rejected,
25}
26
27/// agentd → 网关的事实**摘要**上报(控制面)。
28///
29/// 与数据面上的原文快照(`ReportDiscoverySnapshot`)分工不同,**不是同一条路**:
30/// 摘要只服务用途推断(网关侧按规则表算),去重后 10~30 KB,走已认证的控制面;
31/// 原文快照一台几百 KB,走数据面给中心做资产整理。所以网关只接摘要。
32///
33/// 幂等键是内容摘要,不是 `revision`(后者每轮 refresh 无条件 +1)。
34///
35/// agentd **无条件周期全量**上报,判重归网关:网关用
36/// `wist_contracts::fact_summary::FactContent::content_digest` 从收到的内容**自己算**摘要,
37/// 以此判重。`content_digest` 字段因此只是 agent 的**声明**:
38/// 与网关算出来的不一致时会记 `FactDigestMismatch` 告警(可能只是版本偏差,**不拒收**)。
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct ReportAgentFactSummary {
42    pub api_version: String,
43    pub kind: String,
44    pub report_id: String,
45    pub agent_id: String,
46    pub instance_id: String,
47    /// agent 侧声明的内容摘要。**不是**判重键:网关从下列内容字段自算,此值只作版本偏差的金丝雀。
48    pub content_digest: String,
49    /// 仅留痕:快照 revision 每轮 refresh 无条件 +1,网关不据它判重。
50    pub revision: i64,
51    /// 仅留痕:观察到的事实属于哪一刻(快照生成时间)。
52    pub observed_at: String,
53    pub os: String,
54    pub arch: String,
55    /// 仅留痕:去重前的进程条数(去重会毁掉基数,留一个原始计数备查),不进摘要。
56    pub process_count: i64,
57    /// 去重后的进程可执行标识。注意两边不同源:
58    /// macOS 是 `ps -axo comm=` 给的完整路径,Linux 是 `/proc/{pid}/comm`(只有 basename)。
59    pub process_executables: Vec<String>,
60    /// 已装包名(仅 linux;macOS 侧待定)。
61    pub packages: Vec<String>,
62    pub listen_ports: Vec<String>,
63    // ── 以下三个是**留痕/展示**字段:**不进内容摘要**,也不参与判重 ──
64    //
65    // 为什么不进摘要:摘要回答的是「内容变了没有」(幂等键与用途判据的输入)。
66    // 机器名、IP 会因 DHCP/改名而变,但它们不影响「这台机器是干什么用的」——
67    // 放进摘要会让每次换网就触发一次重报与重算。所以它们只用于展示与追溯。
68    // 也正因如此,`fact-v1` 的字段集**没变**,不需要 bump 版本、不需要强制重报。
69    /// 主机标识(发现里 `host` 方向的 `host.id`)。
70    #[serde(default)]
71    pub host_id: String,
72    /// 主机名(`host.name`)。
73    #[serde(default)]
74    pub host_name: String,
75    /// 网卡地址(每块网卡一条,形如 `en0 192.168.1.5/24`)。
76    #[serde(default)]
77    pub network_addresses: Vec<String>,
78    pub reported_at: String,
79}
80
81impl ReportAgentFactSummary {
82    #[allow(clippy::too_many_arguments)]
83    pub fn new_agent_facts(
84        report_id: String,
85        agent_id: String,
86        instance_id: String,
87        content_digest: String,
88        revision: i64,
89        observed_at: String,
90        os: String,
91        arch: String,
92        process_count: i64,
93        process_executables: Vec<String>,
94        packages: Vec<String>,
95        listen_ports: Vec<String>,
96        reported_at: String,
97    ) -> Self {
98        Self {
99            api_version: API_VERSION_V1.to_string(),
100            kind: REPORT_AGENT_FACT_SUMMARY_KIND.to_string(),
101            report_id,
102            agent_id,
103            instance_id,
104            content_digest,
105            revision,
106            observed_at,
107            os,
108            arch,
109            process_count,
110            process_executables,
111            packages,
112            listen_ports,
113            host_id: String::new(),
114            host_name: String::new(),
115            network_addresses: Vec::new(),
116            reported_at,
117        }
118    }
119
120    /// 补上**留痕/展示**字段(不参与内容摘要与判重)。
121    ///
122    /// 为什么另开一个方法而不是给构造函数再加三个参数:那个函数已经有 13 个位置参数,
123    /// 再加就是 16 个 —— 调用方只需错一次顺序,就会把主机名传成 os、把端口传成包名,
124    /// 而这类错**不会报错**(都是 String/Vec<String>),只会静默写错数据。
125    pub fn with_display(
126        mut self,
127        host_id: String,
128        host_name: String,
129        network_addresses: Vec<String>,
130    ) -> Self {
131        self.host_id = host_id;
132        self.host_name = host_name;
133        self.network_addresses = network_addresses;
134        self
135    }
136}
137
138/// Gateway 对事实上报的确认响应(对应模型 `FactSummaryAccepted`)。
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(deny_unknown_fields)]
141pub struct FactSummaryAccepted {
142    pub report_id: String,
143    pub agent_id: String,
144    pub content_digest: String,
145    pub ack_status: FactSummaryAckStatus,
146    /// 幂等命中(`duplicate`)时仍回带已存的建议,Agent 侧不必再问一次。
147    pub suggestion_id: Option<String>,
148    pub received_at: String,
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn fact_summary_ack_status_uses_snake_case_and_rejects_unknown() {
157        assert_eq!(
158            serde_json::to_string(&FactSummaryAckStatus::Duplicate).unwrap(),
159            "\"duplicate\""
160        );
161        assert!(serde_json::from_str::<FactSummaryAckStatus>("\"nope\"").is_err());
162    }
163
164    #[test]
165    fn new_agent_facts_defaults_display_fields_then_with_display_fills_them() {
166        let summary = ReportAgentFactSummary::new_agent_facts(
167            "fact_1".to_string(),
168            "agent-1".to_string(),
169            "inst-1".to_string(),
170            "fact-v1:sha256:abc".to_string(),
171            7,
172            "2026-09-27T00:00:00Z".to_string(),
173            "macos".to_string(),
174            "arm64".to_string(),
175            3,
176            vec!["/usr/bin/a".to_string()],
177            Vec::new(),
178            vec!["443".to_string()],
179            "2026-09-27T00:00:01Z".to_string(),
180        );
181        assert_eq!(summary.kind, REPORT_AGENT_FACT_SUMMARY_KIND);
182        assert!(summary.host_id.is_empty());
183        assert!(summary.network_addresses.is_empty());
184
185        let with_display = summary.with_display(
186            "host-id".to_string(),
187            "host-name".to_string(),
188            vec!["en0 10.0.0.1/24".to_string()],
189        );
190        assert_eq!(with_display.host_id, "host-id");
191        assert_eq!(with_display.host_name, "host-name");
192
193        let json = serde_json::to_string(&with_display).expect("encode");
194        let back: ReportAgentFactSummary = serde_json::from_str(&json).expect("decode");
195        assert_eq!(back, with_display);
196    }
197}