Skip to main content

wist_contracts/
discovery_policy.rs

1//! 发现方向的**策略表**:平台策展,网关装载并下发给 agentd。
2//!
3//! ## 为什么要下发,而不是各自写死
4//!
5//! 各方向的观测周期是**平台级**取舍(采得密是为了少漏检,报得疏是为了不噪声),
6//! 不该在每台机器的二进制里各写一份。写死的代价已经在代码里显形过:
7//! 探针的 `refresh_interval()` 里注释着「值取自模型 DiscoveryAspectPolicy.default_interval_seconds」,
8//! 而那份模型值**没有任何通路进代码** —— 于是模型与代码成了两份可以静默漂移的抄本。
9//! 现在:值放 `wist-knowledge/aspect-policies.toml`(与用途规则表同样的策展数据),
10//! 网关装载并校验后下发,agentd 的应用逻辑只认这一份。
11//!
12//! ## 为什么在契约 crate
13//!
14//! 这是**两侧都要解析**的字节:网关装载校验、agentd 拉取应用。各写一份结构体就会出现
15//! 「网关发了字段 A、agentd 读的是字段 B」这种只能在真机联调时才发现的问题。
16//!
17//! ## 未配置时的语义
18//!
19//! 策略表是**可选**的:网关没配表就**不提供**这个端点(agentd 继续用自己的内建默认值),
20//! 而不是发一份空表 —— 空表会让「平台没发布策略」与「发布了空策略」变得无法区分。
21
22use serde::{Deserialize, Serialize};
23
24/// 发现方向的闭集(与模型 `Discovery.Probe.DiscoveryAspect` 一致)。
25///
26/// 用字符串而不是 Rust 枚举承载:它同时是**探针名**(`DiscoveryProbe::name()` 返回的
27/// `"host"` / `"process"` …),agentd 侧直接拿它查表,不必再维护一层映射。
28pub const DISCOVERY_ASPECTS: [&str; 7] = [
29    "host",
30    "network",
31    "process",
32    "endpoint",
33    "container",
34    "k8s",
35    "package",
36];
37
38/// 平台闭集。
39pub const DISCOVERY_PLATFORMS: [&str; 2] = ["macos", "linux"];
40
41/// 单个发现方向的策略。
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
43#[jumo(kind = "struct", domain = "Discovery", module = "Discovery.Probe")]
44#[serde(deny_unknown_fields)]
45pub struct DiscoveryAspectPolicy {
46    /// 方向名(见 [`DISCOVERY_ASPECTS`])。
47    pub aspect: String,
48    /// **观测**周期(秒):多久采一次。
49    pub default_interval_seconds: i64,
50    /// 下限:网关侧改这个方向时不允许低于它(采得太密会把被管机器拖住)。
51    pub min_interval_seconds: i64,
52    /// 上限:同理,采得太疏就失了观测意义。
53    pub max_interval_seconds: i64,
54    /// 基线面:回答「我是什么」,是自识别与派活的底座 —— 不可关。
55    pub baseline: bool,
56    /// 未显式声明时的默认开关。
57    pub enabled_by_default: bool,
58    /// 适用平台(见 [`DISCOVERY_PLATFORMS`])。
59    #[serde(default)]
60    pub platforms: Vec<String>,
61    /// 产出的事实(供人理解「为什么要这个面」),如 `os/arch/核数/内存/磁盘/GPU`。
62    #[serde(default)]
63    pub yields: String,
64}
65
66impl DiscoveryAspectPolicy {
67    /// 该方向是否适用于某平台。
68    pub fn supports(&self, platform: &str) -> bool {
69        self.platforms.iter().any(|item| item == platform)
70    }
71}
72
73/// 一版策略表:版本号 + 发布时间 + 各方向的策略。
74///
75/// 形状刻意与 `PurposeRuleSet` 对齐(版本 + 发布时间 + 条目列表),因为它是**同一种东西**:
76/// 平台策展、按版本发布、改内容必须同时 bump 版本(否则两侧无法判断谁过期了)。
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
78#[jumo(kind = "struct", domain = "Discovery", module = "Discovery.Probe")]
79#[serde(deny_unknown_fields)]
80pub struct DiscoveryAspectPolicySet {
81    pub policy_version: i64,
82    pub published_at: String,
83    #[serde(default)]
84    pub policies: Vec<DiscoveryAspectPolicy>,
85}
86
87impl DiscoveryAspectPolicySet {
88    pub fn new(
89        policy_version: i64,
90        published_at: String,
91        policies: Vec<DiscoveryAspectPolicy>,
92    ) -> Self {
93        Self {
94            policy_version,
95            published_at,
96            policies,
97        }
98    }
99
100    /// 取某个方向的策略;表里没有该方向返回 `None`(调用方回退到自己的默认值)。
101    pub fn for_aspect(&self, aspect: &str) -> Option<&DiscoveryAspectPolicy> {
102        self.policies.iter().find(|policy| policy.aspect == aspect)
103    }
104
105    /// 取某个方向的观测周期(秒)。
106    pub fn interval_seconds_for(&self, aspect: &str) -> Option<i64> {
107        self.for_aspect(aspect)
108            .map(|policy| policy.default_interval_seconds)
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    fn policy(aspect: &str, interval: i64) -> DiscoveryAspectPolicy {
117        DiscoveryAspectPolicy {
118            aspect: aspect.to_string(),
119            default_interval_seconds: interval,
120            min_interval_seconds: 60,
121            max_interval_seconds: 3600,
122            baseline: false,
123            enabled_by_default: true,
124            platforms: vec!["macos".to_string()],
125            yields: String::new(),
126        }
127    }
128
129    #[test]
130    fn looks_up_a_policy_by_aspect_name() {
131        let set = DiscoveryAspectPolicySet::new(
132            3,
133            "2026-09-22T00:00:00Z".to_string(),
134            vec![policy("host", 900), policy("process", 300)],
135        );
136
137        assert_eq!(set.interval_seconds_for("process"), Some(300));
138        assert_eq!(set.for_aspect("host").map(|p| p.baseline), Some(false));
139        // 表里没有的方向:回退由调用方决定,这里如实返回 None。
140        assert_eq!(set.interval_seconds_for("package"), None);
141    }
142
143    #[test]
144    fn platform_membership_is_exact() {
145        let mut policy = policy("endpoint", 300);
146        policy.platforms = vec!["linux".to_string()];
147
148        assert!(policy.supports("linux"));
149        assert!(!policy.supports("macos"));
150        // 不做前缀匹配:`linux` 不该匹配 `linux2`。
151        assert!(!policy.supports("linux2"));
152    }
153
154    #[test]
155    fn round_trips_with_serde() {
156        let set = DiscoveryAspectPolicySet::new(
157            1,
158            "2026-09-22T00:00:00Z".to_string(),
159            vec![policy("host", 900)],
160        );
161        let json = serde_json::to_string(&set).expect("serialize");
162        let decoded: DiscoveryAspectPolicySet = serde_json::from_str(&json).expect("deserialize");
163        assert_eq!(decoded, set);
164    }
165
166    #[test]
167    fn rejects_unknown_fields() {
168        // 两侧各自演进时,多出来的字段必须是**响亮的错误**,而不是静默忽略 ——
169        // 静默忽略会让「网关发了新字段、agentd 装作没看见」变成长期无声的语义分叉。
170        let json = r#"{"policy_version":1,"published_at":"x","policies":[],"extra":true}"#;
171        assert!(serde_json::from_str::<DiscoveryAspectPolicySet>(json).is_err());
172    }
173
174    #[test]
175    fn an_empty_table_has_no_policy_for_any_aspect() {
176        // 未配置的表与「配了一个空表」在这里是同形的:调用方据此回退到自己的内建默认值。
177        let set = DiscoveryAspectPolicySet::new(0, "2026-09-27T00:00:00Z".to_string(), Vec::new());
178        for aspect in DISCOVERY_ASPECTS {
179            assert_eq!(set.interval_seconds_for(aspect), None, "{aspect}");
180            assert!(set.for_aspect(aspect).is_none(), "{aspect}");
181        }
182    }
183
184    #[test]
185    fn omitted_optional_fields_decode_to_stable_defaults() {
186        // 手写策略表(页面、联调)不写 platforms/yields 时应能解析,而不是报缺字段。
187        let set: DiscoveryAspectPolicySet =
188            serde_json::from_str(r#"{"policy_version":1,"published_at":"t"}"#).expect("decode");
189        assert!(set.policies.is_empty());
190
191        let policy: DiscoveryAspectPolicy =
192            serde_json::from_str(
193                r#"{"aspect":"host","default_interval_seconds":900,"min_interval_seconds":60,"max_interval_seconds":3600,"baseline":true,"enabled_by_default":true}"#,
194            )
195            .expect("decode");
196        assert!(policy.platforms.is_empty());
197        assert_eq!(policy.yields, "");
198    }
199}