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