Skip to main content

wist_api/status/
v1.rs

1//! `agent/status` seam —— **v1** 基线。
2//!
3//! agentd 周期性上报自身状态(CPU/内存/工作状态/发现策略版本/证书状态/机器画像…),网关收下并落库。
4//! 冻结基线:只做**加性**兼容不动它;需要非加性变更就新开 `v2`。
5//! 约定见 `wist-design/doc/design/foundation/api-seam-inventory.md` §7。
6
7use serde::{Deserialize, Serialize};
8
9use wist_contracts::capability_report::LogsCapabilities;
10
11use super::{AgentLocalWork, AgentUplinkState, HostProfile};
12
13/// 本版本的线上版本号(与路由 `/api/v1/…` 一致)。
14pub const API_VERSION: &str = wist_contracts::API_VERSION_V1;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum AgentWorkState {
19    Paused,
20    Resumed,
21}
22
23/// 工作状态变化(非告警、非失败):暂停/恢复各上报一次。
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct AgentWorkStateChange {
27    pub input_id: String,
28    pub state: AgentWorkState,
29    pub reason: String,
30    pub at: String,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct AgentStatusReport {
36    pub agent_id: String,
37    pub instance_id: String,
38    pub version: String,
39    /// Own resident-set size in bytes reported by the agent.
40    #[serde(default)]
41    pub memory_bytes: Option<u64>,
42    /// Agent 进程自身 CPU 占用,**单核口径**(100% = 占满一个核;多线程进程可 >100)。
43    ///
44    /// 它只统计 agent 进程**自己**的 CPU 时间(`getrusage(RUSAGE_SELF)`),不含它拉起的子进程。
45    #[serde(default)]
46    pub cpu_percent: Option<f64>,
47    /// Agent 所在机器的**逻辑核数**(`available_parallelism`)。
48    ///
49    /// 为什么必须和 `cpu_percent` 同一份上报带上来:`cpu_percent` 是单核口径,
50    /// 而运维看图时真正常问的是「这台机器被它占了百分之几」——那是 `cpu_percent / 核数`。
51    /// 少了核数,右侧那个数既算不出来、也无法复核(4 核上的 13% 和 64 核上的 13% 完全不是一回事)。
52    ///
53    /// 为什么让 agent 报原始值、而不是它自己算好整机占比:沿用本仓已有的取舍
54    /// (与 `discovery_policy_version`、事实摘要 digest 同理)——agent 只交**原始事实**
55    /// (自己的 CPU 时间、自己的核数),换算只留一处,在网关。agent 自算的派生值
56    /// 一旦算法退化,下游没有任何一层能发现。
57    #[serde(default)]
58    pub cpu_cores: Option<u32>,
59    /// Measured round-trip latency to the admin control plane in milliseconds.
60    #[serde(default)]
61    pub admin_latency_ms: Option<u64>,
62    /// 自上次上报以来的工作状态变化(paused/resumed),非告警、非失败。
63    #[serde(default)]
64    pub work_state_changes: Option<Vec<AgentWorkStateChange>>,
65    /// 本机**实际生效**的发现方向策略版本;`None` = 还没拿到策略表(在用内建默认周期)。
66    ///
67    /// 为什么必须由 agent 上报、而不是网关自己记账:网关知道自己**发布**了哪一版,
68    /// 但不知道某台机器**拉到并应用**了哪一版 —— 拉取可能失败、可能还没到轮询节拍、
69    /// 也可能拿到后被夹取。而运维要回答的正是那句「我改了策略,哪些机器还没生效」。
70    #[serde(default)]
71    pub discovery_policy_version: Option<i64>,
72    /// 本机**工作内容视图**(`state/work.json` 的子集):我手里有哪些工作、各自在采哪些文件、
73    /// 一次性工作做到哪一步。
74    ///
75    /// 为什么必须由 agent 上报:网关知道自己**授权**了什么,但「真的在采哪些文件」只有 agent
76    /// 知道(本机手工加的输入、暂停、某条来源今天接不接得了)。网关侧只存最近一份。
77    /// `None` = 这台 agent 还没报过(旧版本 agent 不发这个字段)。
78    #[serde(default)]
79    pub local_work: Option<AgentLocalWork>,
80    /// 本机**实际生效**的采集输出状态(见 [`AgentUplinkState`])。
81    ///
82    /// 为什么必须由 agent 上报:网关知道自己**下发**了「启用 + 目标」,但不知道 agent
83    /// **生效**成了什么 —— grant 可能还没拉到、可能被本机总闸拦住、可能目标连不上。
84    /// 运维要回答的正是那句「这台为什么不上送」。
85    ///
86    /// `None` = 这台 agent 还没报过(旧版本 agent 不发这个字段)—— 落库后保持上一次的值
87    /// (与 `local_work` 同口径)。
88    #[serde(default)]
89    pub uplink_state: Option<AgentUplinkState>,
90    /// 本机**客户端证书**状态(mTLS);还没有证书时 `None`。
91    ///
92    /// 为什么要 agent 上报:证书与到期时间只有本机知道(服务端在握手期就验完了,
93    /// 而**过期证书根本进不来**);而「哪些机器快到期 / 已过期需重装」正是运维要提前看到的
94    /// (见 `docs/design/agent-identity-mtls.md` §5.5)。
95    ///
96    /// `None` = 这台 agent 还没报过 / 没证书 —— 落库后保持上一次的值(与其他可选字段同口径)。
97    #[serde(default)]
98    pub certificate_status: Option<AgentCertificateStatus>,
99    /// 机器画像(机器名 / `node_id` / `machine_id` / 网卡地址)—— 注册表里「这是哪台机器」的展示来源。
100    ///
101    /// 为什么由状态上报带(而不是只靠注册):**凭证书首触重建**登记时,机器画像全是空的
102    /// (证书只承载稳定身份),原设计指望「后续状态上报补齐」,但那条通道以前没有这些字段 ——
103    /// 于是经证书注册的机器在管理面永远只剩一个 ID。这里把注册时的 `HostProfile` 原样带上。
104    ///
105    /// `None` = 老版本 agentd 没带 —— 落库后保持上一次的值(与 `local_work` / `uplink_state` 同口径)。
106    #[serde(default)]
107    pub machine_profile: Option<HostProfile>,
108    /// 本机**能承接的日志形态 / 导出器**(D7 能力协商)。
109    ///
110    /// 为什么要 agent 报、而不是网关按 `version` 推:版本号只能回答“新旧”,回答不了
111    /// “这一版在**这个环境**里真能不能采”(导出器依赖的二进制可能没装、单层 glob 支持是
112    /// 一个具体能力而不是一个版本)。而回滚场景里最容易出错的就是“新目录 + 回滚后的 agent” ——
113    /// 网关必须看**它自己说了什么**。
114    ///
115    /// `None` = 旧版本 agentd 没报:网关按“未知”处理 —— 依赖新能力(glob / 新导出器)的单元
116    /// **不算可采**,宁可少派也不派下去报 unsupported。
117    #[serde(default)]
118    pub logs_capabilities: Option<LogsCapabilities>,
119}
120
121/// agent 本地客户端证书状态(上报给网关,供页面/告警展示)。
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct AgentCertificateStatus {
125    /// 证书到期时刻(RFC3339)。
126    pub not_after: String,
127    /// 距到期的剩余秒数(已过期为负)。
128    pub remaining_seconds: i64,
129    /// `valid` / `renew_due` / `expired`。
130    pub state: String,
131    /// agent 本机**最近一次续签判定**的结果(§5.5)。
132    ///
133    /// `None` = 老版本 agentd 不发(或本机台账一时读不到)—— 网关落库时**保留上一次的值**
134    /// (与本报告里 `local_work` / `uplink_state` 同一口径)。
135    #[serde(default)]
136    pub last_renewal: Option<AgentCredentialRenewal>,
137}
138
139/// agent 本机**最近一次续签判定**的结果(§5.5)。
140///
141/// 续签是后台动作,**不记录就等于静默**:agentd 把本地台账(`identity/renewal.json`)原样带上来,
142/// 网关只存 / 展示,不重算。与 [`AgentCertificateStatus`] 同口径:`None` = 还没报过。
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct AgentCredentialRenewal {
146    /// `not_due` / `renewed` / `failed` / `needs_reinstall` / `revoked`(与 agentd 本地台账同口径)。
147    pub outcome: String,
148    /// 本次判定时刻(RFC3339)。
149    pub checked_at: String,
150    /// 人读细节(失败原因 / 续到了什么时候…);无内容时为空串。
151    pub detail: String,
152    /// 续签后证书的到期时刻(RFC3339);无证书时为空串。
153    pub not_after: String,
154}
155
156/// 状态上报的**回执**。
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct AgentStatusAck {
160    pub agent_id: String,
161    pub instance_id: String,
162    pub acknowledged_at: String,
163}
164
165#[cfg(test)]
166mod tests {
167    use super::{AgentCertificateStatus, AgentStatusReport, AgentWorkState, AgentWorkStateChange};
168
169    #[test]
170    fn agent_work_state_uses_snake_case_on_the_wire() {
171        assert_eq!(
172            serde_json::to_string(&AgentWorkState::Paused).unwrap(),
173            "\"paused\""
174        );
175        assert_eq!(
176            serde_json::to_string(&AgentWorkState::Resumed).unwrap(),
177            "\"resumed\""
178        );
179        let change = AgentWorkStateChange {
180            input_id: "app".to_string(),
181            state: AgentWorkState::Paused,
182            reason: "spool_over_limit".to_string(),
183            at: "2026-09-27T00:00:00Z".to_string(),
184        };
185        let json = serde_json::to_string(&change).expect("encode");
186        let back: AgentWorkStateChange = serde_json::from_str(&json).expect("decode");
187        assert_eq!(back, change);
188
189        // 与同族的 AgentStatusReport 一致:拒绝未知字段,避免字段漂移静默通过。
190        let mutated = json.replacen('{', "{\"extra\":1,", 1);
191        assert!(serde_json::from_str::<AgentWorkStateChange>(&mutated).is_err());
192    }
193
194    #[test]
195    fn a_minimal_agent_status_report_decodes_and_extra_keys_fail() {
196        // 旧版 agent 只发三个必填字段;其余全是 #[serde(default)]。
197        let json = r#"{"agent_id":"a","instance_id":"i","version":"0.1.5"}"#;
198        let report: AgentStatusReport = serde_json::from_str(json).expect("decode");
199        assert_eq!(report.memory_bytes, None);
200        assert_eq!(report.cpu_percent, None);
201        assert_eq!(report.work_state_changes, None);
202        assert_eq!(report.discovery_policy_version, None);
203        assert!(report.local_work.is_none());
204        assert!(report.uplink_state.is_none());
205        assert!(report.certificate_status.is_none());
206        // 机器画像同样是可选的:旧版 agent 不带 -> None(网关落库时保持上一次的值)。
207        assert!(report.machine_profile.is_none());
208
209        assert!(
210            serde_json::from_str::<AgentStatusReport>(
211                r#"{"agent_id":"a","instance_id":"i","version":"v","nope":1}"#
212            )
213            .is_err()
214        );
215    }
216
217    /// 机器画像随状态上报带上时能如实解码(机器名 / node_id / 网卡地址)。
218    #[test]
219    fn an_agent_status_report_carries_the_machine_profile() {
220        let json = r#"{
221            "agent_id":"a","instance_id":"i","version":"0.1.24",
222            "machine_profile":{
223                "node_id":"node-1","hostname":"host-1","os":"linux","arch":"x86_64",
224                "machine_id":"mid-1","cloud_instance_id":null,"k8s_node_uid":null,
225                "ip_addresses":["en0 192.168.1.5/24","10.8.0.2"]
226            }
227        }"#;
228        let report: AgentStatusReport = serde_json::from_str(json).expect("decode");
229        let profile = report.machine_profile.expect("machine_profile present");
230        assert_eq!(profile.hostname, "host-1");
231        assert_eq!(profile.node_id, "node-1");
232        assert_eq!(profile.machine_id, "mid-1");
233        assert_eq!(
234            profile.ip_addresses,
235            vec!["en0 192.168.1.5/24".to_string(), "10.8.0.2".to_string()]
236        );
237    }
238
239    /// 证书状态里的「最近一次续签」是可选的,且拒绝未知字段:
240    /// 旧 agentd 少发它必须仍能解码(上线顺序不一,网关不能因此 400)。
241    #[test]
242    fn certificate_status_last_renewal_is_optional_and_round_trips() {
243        let legacy =
244            r#"{"not_after":"2026-11-04T00:00:00Z","remaining_seconds":100,"state":"valid"}"#;
245        let status: AgentCertificateStatus = serde_json::from_str(legacy).expect("decode");
246        assert!(status.last_renewal.is_none());
247
248        let json = r#"{"not_after":"2026-11-04T00:00:00Z","remaining_seconds":100,"state":"valid",
249            "last_renewal":{"outcome":"renewed","checked_at":"2026-10-08T00:00:00Z",
250            "detail":"credential renewed","not_after":"2026-11-04T00:00:00Z"}}"#;
251        let status: AgentCertificateStatus = serde_json::from_str(json).expect("decode");
252        assert_eq!(
253            status.last_renewal.as_ref().expect("renewal").outcome,
254            "renewed"
255        );
256
257        let encoded = serde_json::to_string(&status).expect("encode");
258        let back: AgentCertificateStatus = serde_json::from_str(&encoded).expect("decode");
259        assert_eq!(back, status);
260
261        // 字段漂移(`last_renewal` 里多出未知键)必须显形。
262        let drifted = r#"{"not_after":"x","remaining_seconds":0,"state":"valid",
263            "last_renewal":{"outcome":"renewed","checked_at":"t","detail":"","not_after":"",
264            "extra":1}}"#;
265        assert!(serde_json::from_str::<AgentCertificateStatus>(drifted).is_err());
266    }
267
268    /// D7:能力清单可选;旧 agentd 不带时必须是 `None`(网关据此按“未知”处理)。
269    #[test]
270    fn logs_capabilities_are_optional_and_round_trip() {
271        let legacy = r#"{"agent_id":"a","instance_id":"i","version":"0.8.0"}"#;
272        let report: AgentStatusReport = serde_json::from_str(legacy).expect("decode");
273        assert!(report.logs_capabilities.is_none());
274
275        let json = r#"{"agent_id":"a","instance_id":"i","version":"0.9.0",
276            "logs_capabilities":{
277                "file_inputs":["absolute-path","glob"],
278                "multiline_modes":["none","indented"],
279                "exporters":["journalctl-kernel"]
280            }}"#;
281        let report: AgentStatusReport = serde_json::from_str(json).expect("decode");
282        let logs = report.logs_capabilities.expect("logs_capabilities");
283        assert_eq!(logs.exporters, vec!["journalctl-kernel".to_string()]);
284        assert!(logs.file_inputs.contains(&"glob".to_string()));
285
286        // 字段漂移仍要显形(与同族结构一致)。
287        let drifted = r#"{"agent_id":"a","instance_id":"i","version":"v",
288            "logs_capabilities":{"file_inputs":[],"nope":1}}"#;
289        assert!(serde_json::from_str::<AgentStatusReport>(drifted).is_err());
290    }
291}