Skip to main content

wist_contracts/
work.rs

1//! **工作授权快照**:网关授权(grant)、agentd 拉取(`WorkGrant`)并确认。
2//!
3//! ## 为什么是「快照」而不是「指令流」
4//!
5//! 常驻工作(`StandingWork`)表达的是**期望状态**:某个采集面应当持续执行什么。
6//! 期望状态天然是幂等的(同一份重复拉取不产生副作用),断了网、重启了进程,
7//! 再拉一次就回到期望 —— 不必让网关记住「上次推到哪条」。
8//! 这跟 `PollControlCommands` 的长轮询指令流是两种东西:那条要求**不重放**,这条要求**可重拉**。
9//!
10//! 一次性工作(`OneShotWork`)是命令式的,但它也搭这份快照回来:
11//! 决定「现在还该做吗」的是它的状态(未了结才出现在快照里),不是「推没推过」。
12//!
13//! ## 为什么在契约 crate
14//!
15//! 这是**两侧都要解析**的字节:网关写授权、agentd 读并执行。各写一份结构体,
16//! 迟早出现「网关发了 `plan_version`、agentd 读的是 `version`」这种只能在真机联调时才发现的分叉。
17//! 与 `DiscoveryAspectPolicySet` 同一个理由。
18//!
19//! ## 与本地保护暂停的区别
20//!
21//! agentd 自己也会暂停(如 `spool over limit`),那是**本地保护**:自动、临时、本机可见。
22//! 这里下发的 `paused` 是**授权层状态**:人工、持久、跨重启。两者在观测里必须能分辨,
23//! 不要合并成一个 paused —— 合并之后「谁把采集停了」就再也说不清。
24
25use serde::{Deserialize, Serialize};
26
27use crate::capability_report::LogsCapabilities;
28
29/// 常驻工作的状态取值(对应模型 `StandingWork.status`)。
30///
31/// `superseded` 与 `revoked` 都不出现在 `WorkGrant.standing` 里:前者是「被新版本取代」,
32/// 后者是「授权被撤」。两者都要留痕,所以还是要有状态而不是删行。
33pub const STANDING_WORK_STATUSES: [&str; 4] = ["active", "paused", "superseded", "revoked"];
34
35/// 一次性工作的状态取值(对应模型 `OneShotWork.status`)。
36pub const ONE_SHOT_WORK_STATUSES: [&str; 9] = [
37    "dispatched",
38    "accepted",
39    "running",
40    "paused",
41    "succeeded",
42    "failed",
43    "timed_out",
44    "canceled",
45    "expired",
46];
47
48/// Agent **允许上报**的一次性工作状态([`ONE_SHOT_WORK_STATUSES`] 的真子集)。
49///
50/// 为什么不是全集:`dispatched` 是网关自己写的(派下去那一刻),`paused` / `canceled` / `expired`
51/// 归**控制面与期限**管(运维暂停撤回、或过了截止)—— agent 无权把它们写回去。
52/// 剩下的问题只有一种:「这件活做完了没有」,答案就这三种。
53pub const AGENT_REPORTABLE_WORK_STATUSES: [&str; 3] = ["running", "succeeded", "failed"];
54
55/// 一次性工作的**终态**:到了这几个状态就了结了,不再出现在快照里。
56pub const ONE_SHOT_TERMINAL_STATUSES: [&str; 5] =
57    ["succeeded", "failed", "timed_out", "canceled", "expired"];
58
59/// 工作类型:常驻(持续到被替换或撤回)与一次性(有期限与终态)。
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
61#[jumo(kind = "state", domain = "Control", module = "Control.Agent.Work")]
62#[serde(rename_all = "PascalCase")]
63pub enum WorkKind {
64    /// 常驻工作:按**采集面**授权,一个面一份。
65    Standing,
66    /// 一次性工作:按**动作**授权。
67    OneShot,
68}
69
70/// 常驻工作:网关声明该 Agent 应当持续执行的工作。
71///
72/// 粒度是**采集面**(一个面 = 一份工作),不是 capability:一份 `collect_logs` 会裹住十几个面,
73/// 那样「按面暂停 / 限流 / 审计」全都无从下手。面由模板的 `family_scope` 展开而来,
74/// **不携带 capability** —— 该面由哪个采集器承接,由 `spec` 里的单元各自决定。
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
76#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
77#[serde(deny_unknown_fields)]
78pub struct StandingWork {
79    pub work_id: String,
80    pub agent_id: String,
81    /// 采集面(`CollectionFamily`)。
82    pub family: String,
83    /// 工作参数:由采集目录的条目组合而成(**不是自由文本**),随 `plan_version` 整体替换。
84    pub spec: String,
85    /// 本工作按哪一版目录展开:目录换版**不追改**已授权工作(要跟新版得走新提案 + 审定)。
86    pub catalog_version: i64,
87    /// 生效依据:指向已批准的提案(人工直填 spec 时为空)。
88    #[serde(default)]
89    pub proposal_id: Option<String>,
90    /// 期望版本:网关每次改动 +1;agentd 回报实际版本,与它比对即得漂移。
91    pub plan_version: i64,
92    pub effective_from: String,
93    /// 见 [`STANDING_WORK_STATUSES`]。
94    pub status: String,
95    pub updated_by: String,
96    pub updated_at: String,
97}
98
99/// 一次性工作:有计划开始时间与期限,有明确终态。
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
101#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
102#[serde(deny_unknown_fields)]
103pub struct OneShotWork {
104    pub work_id: String,
105    pub agent_id: String,
106    /// 动作面:upgrade / snapshot / exec / ...(执行载体是 Reporting 域的 ActionPlan)。
107    pub action: String,
108    pub spec: String,
109    /// 计划开始时间:到点前不应执行(与「立即派发」区分开)。
110    pub scheduled_at: String,
111    /// 绝对截止:业务要求的时间点,**暂停也照走**(不由暂停顺延)。
112    pub deadline_at: String,
113    /// 执行预算(秒):**只在实际执行时消耗**,暂停期间不计。
114    pub timeout_seconds: i64,
115    /// 可中断性:只有可中断的动作才允许运行中暂停。
116    pub interruptible: bool,
117    /// 见 [`ONE_SHOT_WORK_STATUSES`]。
118    pub status: String,
119    /// 当前暂停的起点(运行中暂停才有;恢复后清空)。
120    #[serde(default)]
121    pub paused_at: Option<String>,
122    /// 累计暂停时长(秒):用于审计与「预算未被暂停消耗」的核对。
123    pub paused_total_seconds: i64,
124    /// 步级断点:已完成步骤保留、`current_step` 在恢复时重做。
125    #[serde(default)]
126    pub current_step: Option<String>,
127    #[serde(default)]
128    pub completed_steps: Vec<String>,
129    /// 已尝试次数(含恢复后的重做)。
130    pub attempt: i64,
131    pub issued_by: String,
132    pub issued_at: String,
133}
134
135impl OneShotWork {
136    /// 是否**未了结**(快照里只带未了结的活)。
137    pub fn is_outstanding(&self) -> bool {
138        !ONE_SHOT_TERMINAL_STATUSES.contains(&self.status.as_str())
139    }
140
141    /// 是否允许在运行期间暂停。
142    ///
143    /// 两条都要满足:动作声明了 `interruptible`,且此刻确实在做(`accepted`/`running`)。
144    /// 不可中断的动作**拒绝**而不是「尽力暂停」—— 挂起半个升级进程比不暂停更危险。
145    pub fn can_pause(&self) -> bool {
146        self.interruptible && matches!(self.status.as_str(), "accepted" | "running")
147    }
148}
149
150/// 管理面授权或撤回工作的回执:一份工作一次。
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
152#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
153#[serde(deny_unknown_fields)]
154pub struct WorkReceipt {
155    pub work_id: String,
156    pub agent_id: String,
157    pub work_kind: WorkKind,
158    /// accepted | paused | resumed | revoked | rejected。
159    pub status: String,
160    pub plan_version: i64,
161    pub created_at: String,
162}
163
164// ─────────────────────────────────────────────────────────────────────────────
165// 工作参数(`StandingWork.spec` 的内容)
166// ─────────────────────────────────────────────────────────────────────────────
167//
168// 模型里 `StandingWork.spec` 是一个 `String`,语义是「工作参数:由采集目录的条目
169// 组合而成,**不是自由文本**」。这里的三个类型就是它的**编码**:一串已物化的采集单元。
170//
171// 为什么必须带上来源与规则标识,而不是只给一串 `unit_id`:agentd 拿到工作要能
172// **直接照做**。单元的采集来源(`sources`)与数据面规则标识(`rule_ref`)本来都只
173// 存在于网关的采集目录里,只发 id 等于发了一张自己去不了的地址 —— 于是要么再去网关
174// 拉一次目录(多一条必须鉴权的路径),要么两边各维护一份目录(必然漂移)。
175//
176// 为什么不把它们塞成 `StandingWork` 的字段:那会把「工作」与「内容目录」的边界糊掉;
177// 模型里这个字段就是**不透明的工作参数**,保持它不透明是两侧能独立演进的前提。
178
179/// 一份常驻工作的工作参数。
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct WorkSpec {
183    /// 本工作包含的采集单元(已按该机器的事实裁剪过)。
184    #[serde(default)]
185    pub units: Vec<WorkSpecUnit>,
186}
187
188/// 一个已物化的采集单元:只说「采什么、怎么落地」,不复述策展元信息。
189///
190/// 刻意不带 `status` / `match` / `catalog_version`:那些是网关策展与裁剪的输入,
191/// 工作一旦发出去就已经裁剪完了。带上它们会让 agentd 有「再判断一次」的空间,
192/// 而 agentd 不是第二个策展器。
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct WorkSpecUnit {
196    pub unit_id: String,
197    /// `collect_logs` | `collect_metrics`:该单元由哪类采集器承接。
198    pub capability: String,
199    /// 数据面 rule/oml 标识(空 = 该单元还没接上规则,理论不会入工作)。
200    #[serde(default)]
201    pub rule_ref: String,
202    /// `none` | `root` | `fda`:要采到这东西得有什么权限 —— 缺权限时应**说清缺什么**,
203    /// 而不是安静地采不到。
204    #[serde(default)]
205    pub requires_privilege: String,
206    #[serde(default)]
207    pub sources: Vec<WorkSpecSource>,
208}
209
210/// 采集来源:`kind` ∈ `FileGlob` | `Exporter` | `UnifiedLogPredicate` | `MetricInterval`。
211///
212/// 与模型 `CollectionSource` 同形:`target` 的含义由 `kind` 决定
213/// (路径通配 / 导出器标识 / 谓词 / 周期)。
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct WorkSpecSource {
217    pub kind: String,
218    pub target: String,
219    /// 怎么读这条来源:`none`(一行一条)| `indented`(行首缩进是上一条的续行)。
220    ///
221    /// 只对可 tail 的 `FileGlob` 有意义,其余 kind 恒为 `none`。**不是**"要不要多行"这个偏好,
222    /// 而是这条日志的**固有格式**:说错了要么把多行记录拆散、要么把独立记录粘成一条。
223    #[serde(default = "default_multiline")]
224    pub multiline: String,
225    /// 从 `target` 匹配集合里剔掉的**绝对路径 glob**(默认空)。
226    ///
227    /// 只对 `FileGlob` 有意义,按**展开后的完整路径**匹配(不是文件名)。为什么需要它:
228    /// 容器日志的选源根是 `/var/log/containers/*.log`,而目录里同时有不该采的容器
229    /// (sidecar / init / 平台自身的 pod);没有排除项就只能把业务服务名写进 pattern,
230    /// 而服务名会随集群漂移。
231    ///
232    /// 空数组**不序列化**:旧 agent 的 `WorkSpecSource` 是 `deny_unknown_fields` 的,
233    /// 发一个空字段过去只会给旧机队添一种没必要的解析失败。
234    #[serde(default, skip_serializing_if = "Vec::is_empty")]
235    pub exclude_globs: Vec<String>,
236    /// 容器选源的结构化过滤器(默认空)。只对 `FileGlob` 有意义。
237    ///
238    /// 空(两个列表都空)**不序列化**:与 `exclude_globs` 同一个理由 —— 旧 agent 的
239    /// `WorkSpecSource` 是 `deny_unknown_fields` 的,发一个空字段过去只会给旧机队
240    /// 添一种没必要的解析失败。
241    ///
242    /// ⚠ 非空时**要求 agent 支持**(与 glob 单元同一条纪律):能力上报(D7)未落地前,
243    /// 目录里带 filters 的单元必须保持 `draft`,机队升级验证后再单独翻 `active`。
244    #[serde(default, skip_serializing_if = "ContainerFilters::is_empty")]
245    pub filters: ContainerFilters,
246}
247
248/// 容器日志的选源根(写死,D4)。
249///
250/// `/var/log/pods` 下有 `0.log` / `1.log` 与重启序号歧义;`/var/log/containers` 里
251/// 每个活容器恰好一条 symlink,文件名自带 pod / ns / container。
252pub const CONTAINER_LOG_ROOT: &str = "/var/log/containers";
253
254/// 容器选源的结构化过滤器(默认全空 = 不过滤)。
255///
256/// 为什么不是「再写几条 pattern」:容器日志文件名本身就是
257/// `<pod>_<namespace>_<container>-<id>.log`,选源要用的三个维度全在里面;把 namespace
258/// 白名单散成 N 条 path pattern 会随集群漂移,而过滤器是**结构化**的、与文件名格式解耦。
259///
260/// 求值位置:**发现阶段**(agentd 展开 glob、剔完 `exclude_globs` 之后,读之前),
261/// 读取器不参与 —— 所以它不影响 tail / checkpoint / 轮转语义。
262#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(deny_unknown_fields)]
264pub struct ContainerFilters {
265    /// 只采这些 namespace(空 = 不限)。
266    #[serde(default, skip_serializing_if = "Vec::is_empty")]
267    pub namespace_in: Vec<String>,
268    /// 这些 pod 不采(空 = 不排除)。
269    #[serde(default, skip_serializing_if = "Vec::is_empty")]
270    pub pod_not_in: Vec<String>,
271}
272
273impl ContainerFilters {
274    /// 两个列表都空 = 不过滤(空过滤器不上线,见 [`WorkSpecSource::filters`])。
275    pub fn is_empty(&self) -> bool {
276        self.namespace_in.is_empty() && self.pod_not_in.is_empty()
277    }
278
279    /// 形状校验:过滤器里的值是**精确名字**,不是 pattern。
280    ///
281    /// 网关、web、agentd 三处共用这一份(否则会出现「页面能填、agentd 不认」)。
282    /// 不强制 RFC1123:这里只拦“一定对不上”的形态(空串 / 空白 / 通配符 / 超长),
283    /// 名字是否真存在由集群决定,不是配置校验能回答的。
284    pub fn validate(&self) -> Result<(), String> {
285        for (field, values) in [
286            ("namespace_in", &self.namespace_in),
287            ("pod_not_in", &self.pod_not_in),
288        ] {
289            for value in values {
290                if value.trim().is_empty() {
291                    return Err(format!("{field} 里有空值"));
292                }
293                if value.trim() != value {
294                    return Err(format!("{field} 里 {value:?} 带首尾空白"));
295                }
296                if value.contains(['*', '?', '[', ']']) {
297                    return Err(format!(
298                        "{field} 里 {value:?} 含通配符(这里是精确名字,不是 pattern)"
299                    ));
300                }
301                if value.len() > 253 {
302                    return Err(format!("{field} 里 {value:?} 过长(>253)"));
303                }
304            }
305        }
306        Ok(())
307    }
308}
309
310fn default_multiline() -> String {
311    "none".to_string()
312}
313
314impl WorkSpec {
315    /// 解析 `StandingWork.spec`。
316    ///
317    /// 解析失败**不当作空工作**:空工作会让「参数坏了」退化成「没事可做」,
318    /// 两种情形的运维动作完全不同。
319    pub fn parse(spec: &str) -> Result<Self, serde_json::Error> {
320        serde_json::from_str(spec)
321    }
322
323    /// 序列化(网关写 `spec` 用)。字段顺序固定 → 同一份工作每次编码都一样,
324    /// `plan_version` 之外的字节也稳定,方便对账。
325    pub fn encode(&self) -> Result<String, serde_json::Error> {
326        serde_json::to_string(self)
327    }
328
329    /// 周期类来源声明的间隔(秒):取所有 `MetricInterval` 里最密的那一个。
330    ///
331    /// 取最密而不是取第一个:一份工作里若有两个指标单元,采慢的那个会
332    /// 静默压掉采密的要求,而“要得最急”的需求才是上送频率的下界。
333    pub fn metric_interval_seconds(&self) -> Option<i64> {
334        self.units
335            .iter()
336            .flat_map(|unit| unit.sources.iter())
337            .filter(|source| source.kind == "MetricInterval")
338            .filter_map(|source| parse_interval_seconds(&source.target))
339            .min()
340    }
341
342    /// 是否要采日志(有 `collect_logs` 单元)。
343    pub fn collects_logs(&self) -> bool {
344        self.units
345            .iter()
346            .any(|unit| unit.capability == "collect_logs")
347    }
348}
349
350/// agentd **有可能真的执行**的来源 kind(其余 kind 一律 `unsupported`,不当没看见)。
351///
352/// 注意:kind 对了**还不够** —— 目标形态也有限制,见 [`is_executable_source`]:
353/// `FileGlob` 要是**绝对路径**(显式路径或受支持的 glob)、`Exporter` 要是**已知导出器 ID**。
354/// 只按 kind 判会把「`**` 递归」「`~` 与相对路径」「写错的导出器」当成可采,而那在采集端落不了地。
355pub const EXECUTABLE_SOURCE_KINDS: &[&str] = &["FileGlob", "MetricInterval", "Exporter"];
356
357/// 目标里有没有通配元字符 —— 有就说明它不是**显式单路径**。
358pub fn has_glob_meta(target: &str) -> bool {
359    target.contains(['*', '?', '['])
360}
361
362/// 这个目标是不是采集端今天**真能打开**的路径:**绝对路径、无通配**。
363///
364/// 采集端(agentd 第一版)只实现显式单路径输入:**不做 glob 展开、也不展开 `~`**
365/// (见 `wist/wist-agentd/docs/design/log-file-input-spec.md` §2 的能力边界)。
366/// 单层 glob 从 W1 起可采,但那走 [`is_glob_path`];`~` 与相对路径仍永远不可采。
367///
368/// 从 W1 起,带通配的目标由 [`is_glob_path`] / [`is_file_target`] 判定 —— 它们才是
369/// 「采集端接不接得了」的口径;本函数只留给**单文件**场景(例如「一个显式路径能否参与
370/// 存在性裁剪」:那一步只能对显式路径做,探测不到的路径不能当不存在)。
371pub fn is_explicit_path(target: &str) -> bool {
372    target.starts_with('/') && !has_glob_meta(target)
373}
374
375/// 这个目标是不是**受支持的文件族 glob**:**绝对路径、有通配、不含 `**`**。
376///
377/// `**` 被拒不是保守,而是口径:采集端只扫 pattern 的固定前缀目录、`*` 不跨目录
378/// (`literal_separator`),递归展开既没实现也无法给出上限 —— 让它在目录装载期就被挡住,
379/// 比让 agent 每轮报一次 IO 错误好。
380pub fn is_glob_path(target: &str) -> bool {
381    target.starts_with('/') && has_glob_meta(target) && !target.contains("**")
382}
383
384/// 这个目标是不是采集端**能承接的文件目标**:显式绝对路径,或受支持的文件族 glob。
385///
386/// 不展开 `~`、不接受相对路径:相对路径的结果取决于进程 cwd,同一台机器换一种启动方式
387/// 就换一个采集对象,那不是「配置」,是运气。
388pub fn is_file_target(target: &str) -> bool {
389    is_explicit_path(target) || is_glob_path(target)
390}
391
392/// 内核日志导出器(W3):`journalctl -k -p 4 -o json`,**靠游标续读**。
393///
394/// 与其余导出器不同,它是**有状态**的:上一轮读到的游标落盘,下一轮从那里接着读。
395/// 契约只承诺 ID 与「可执行」;游标怎么存、失效(被 vacuum)怎么回退是采集端的事
396/// (见 `linux-server-log-coverage-plan.md` §6 W3 / D6)。
397///
398/// 为什么单独给个常量:agentd 的 `argv` 与状态处理要**按 ID 分支**,字面量散在两侧
399/// 迟早会漂(一处改名、另一处还在按旧名特判,表现就是「游标不生效、每轮重读」)。
400pub const KERNEL_LOG_EXPORTER: &str = "journalctl-kernel";
401
402/// 这条来源缺哪些日志能力(空 = 齐了)。
403///
404/// `None` = 这个 kind 不归 logs 能力管(`MetricInterval` 是指标;未知/不可执行的形态
405/// 已经被 [`is_executable_source`] 拦下,不需要第二套说法)。
406///
407/// 返回值是「桶:值」(`file_inputs:glob` / `multiline_modes:indented` /
408/// `exporters:journalctl-kernel`),可以直接用在拒绝文案与自观测里。
409///
410/// 为什么把判定放契约:网关(派活时)与 agentd(上报时)必须用**同一份**词表。
411/// 两边各写一份的结果是「agent 说自己支持 glob、网关按一套旧名单判它不支持」——
412/// 而且这种矛盾只能靠人去两台机器上对日志才能发现。
413pub fn missing_log_capabilities(
414    kind: &str,
415    target: &str,
416    multiline: &str,
417    declared: &LogsCapabilities,
418) -> Option<Vec<String>> {
419    let declared_has = |bucket: &[String], value: &str| bucket.iter().any(|item| item == value);
420
421    match kind {
422        "FileGlob" => {
423            // 不可执行的形态交给 `is_executable_source` 说,不在这里扣一顶“能力不够”的帽子。
424            if !is_file_target(target) {
425                return None;
426            }
427            let mut missing = Vec::new();
428            let need = if is_glob_path(target) {
429                FILE_INPUT_GLOB
430            } else {
431                FILE_INPUT_ABSOLUTE_PATH
432            };
433            if !declared_has(&declared.file_inputs, need) {
434                missing.push(format!("file_inputs:{need}"));
435            }
436            // `none` 是老读法(一行一条),不算一项需要协商的能力。
437            if multiline != "none" && !declared_has(&declared.multiline_modes, multiline) {
438                missing.push(format!("multiline_modes:{multiline}"));
439            }
440            Some(missing)
441        }
442        "Exporter" => {
443            let (id, _) = parse_exporter_target(target)?;
444            Some(if declared_has(&declared.exporters, id) {
445                Vec::new()
446            } else {
447                vec![format!("exporters:{id}")]
448            })
449        }
450        _ => None,
451    }
452}
453
454/// agentd 能承接的**文件输入形态**(能力名)。
455///
456/// 与 [`is_file_target`] 同源:判定能采的形态,就是这里要声明的形态 —— 两边各写一份必然漂。
457pub const FILE_INPUT_ABSOLUTE_PATH: &str = "absolute-path";
458/// 见 [`FILE_INPUT_ABSOLUTE_PATH`]。
459pub const FILE_INPUT_GLOB: &str = "glob";
460
461/// agentd 支持的 `multiline` 模式(与 [`WorkSpecSource::multiline`] 的取值同源)。
462pub const MULTILINE_MODES: &[&str] = &["none", "indented"];
463
464/// 定时导出器(`Exporter`)的**已知 ID**:协议层词表,网关 / agentd / web 三侧共用。
465///
466/// 为什么词表放这里:`is_executable_source` 要判「这条 `Exporter` 目标今天真能不能采」,
467/// 而网关的采集就绪度与 agentd 的「接不接得了」必须是**同一个判据**(见 [`is_executable_source`])。
468/// 词表只收**已实现**的导出器 —— 收录一个 ID 就等于承诺 agentd 能跑它;未知 ID(旧网关发新 ID、
469/// 或写错)一律当不可采,不要「写了就算」。新增能力 = 四侧(本 crate + 网关 + agentd + web)
470/// 一起改 + 知识库把对应单元开 `active`。
471pub const EXPORTER_IDS: &[&str] = &[
472    "journalctl-unit",
473    "journalctl-shutdown",
474    "last-reboot",
475    "nft-ruleset",
476    "iptables-save",
477    "smartctl",
478    "dmesg",
479    "auditd-execve",
480    KERNEL_LOG_EXPORTER,
481];
482
483/// 把一个 `Exporter` 目标拆成 `(id, arg)`:`"dmesg:panic"` → `("dmesg", Some("panic"))`。
484///
485/// 无 `:` 时 `arg` 为 `None`;空 id 或含空白的 id 视为无效(返回 `None`)。`arg` 是**声明式的窄选项**
486/// (如 `dmesg` 的过滤键),不是命令行片段 —— 实现侧各自解释,绝不拼进 shell。
487pub fn parse_exporter_target(target: &str) -> Option<(&str, Option<&str>)> {
488    let (id, arg) = match target.split_once(':') {
489        Some((id, arg)) => (id, Some(arg)),
490        None => (target, None),
491    };
492    let id = id.trim();
493    if id.is_empty() || id.contains(char::is_whitespace) {
494        return None;
495    }
496    Some((id, arg))
497}
498
499/// 这条 `Exporter` 目标的 ID 是不是**已知且已实现**的导出器。
500pub fn is_known_exporter(target: &str) -> bool {
501    match parse_exporter_target(target) {
502        Some((id, _)) => EXPORTER_IDS.contains(&id),
503        None => false,
504    }
505}
506
507/// 一条来源今天**能不能被采**:`kind` 要 agentd 能执行,`target` 还要是它接得住的形态。
508///
509/// 为什么把两件事放在一个函数里:网关的采集就绪度(「这个面能不能派下去」)与 agentd 的
510/// 「这条来源接不接得了」必须是**同一个判据**,否则会出现「网关说可采、agent 拿到后报
511/// unsupported」这种没人能发现的矛盾。加一种 kind 支持时**只改这里**。
512pub fn is_executable_source(kind: &str, target: &str) -> bool {
513    match kind {
514        // 本地文件采集:显式绝对路径,或受支持的文件族 glob(无 `**`、无 `~`)。
515        "FileGlob" => is_file_target(target),
516        // 指标:`target` 是采集周期(如 `15s`、`60s`),不是路径。
517        "MetricInterval" => true,
518        // 定时导出器:`target` 要是**已知导出器 ID**(可带 `:arg`)。未知 ID 不可执行。
519        "Exporter" => is_known_exporter(target),
520        _ => false,
521    }
522}
523
524impl WorkSpecUnit {
525    /// 该单元里可以交给本地文件采集器的来源(`FileGlob`)。
526    ///
527    /// 其它来源(导出器 / 统一日志谓词)不是本地 tail 一个文件能承接的:
528    /// 调用方应当把它们当成**还没支持的单元**如实报出来,而不是当没看见。
529    pub fn file_sources(&self) -> Vec<&WorkSpecSource> {
530        self.sources
531            .iter()
532            .filter(|source| source.kind == "FileGlob")
533            .collect()
534    }
535
536    /// 本单元有没有 agentd 目前接不了的来源(kind 不认识,或目标不是可采的绝对路径)。
537    pub fn unsupported_sources(&self) -> Vec<&WorkSpecSource> {
538        self.sources
539            .iter()
540            .filter(|source| !is_executable_source(&source.kind, &source.target))
541            .collect()
542    }
543
544    /// 本单元至少有一条来源是 agentd **今天真能采**的。
545    ///
546    /// 「能不能采」只看这个:与解析规则(`rule_ref`)无关 —— 采原文不需要规则,
547    /// 规则只决定采下来的东西能不能被归类、抽字段。
548    pub fn has_executable_source(&self) -> bool {
549        self.sources
550            .iter()
551            .any(|source| is_executable_source(&source.kind, &source.target))
552    }
553}
554
555/// 解析 `15s` / `60s` / `5m` 这类周期写法(与目录里 `MetricInterval` 的写法一致)。
556///
557/// 只认秒与分两种后缀:目录里的值是人写的,多一个单位就多一种笔误的可能,
558/// 而解析不了时返回 `None` 会让调用方回退到自己的默认值(不静默取 0)。
559pub fn parse_interval_seconds(raw: &str) -> Option<i64> {
560    let raw = raw.trim();
561    if let Some(value) = raw.strip_suffix('s') {
562        return value.trim().parse::<i64>().ok().filter(|value| *value > 0);
563    }
564    if let Some(value) = raw.strip_suffix('m') {
565        let minutes = value.trim().parse::<i64>().ok()?;
566        // `checked_mul`:`123456789012345678m` 这类输入会让 `minutes * 60` 溢出 i64。
567        // 契约是「解析不了返回 None」(调用方回退到自己的默认值),不能让它 panic(debug)
568        // 或悄悄回绕成一个错的间隔(release)。
569        return minutes.checked_mul(60).filter(|value| *value > 0);
570    }
571    None
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use crate::capability_report::LogsCapabilities;
578
579    fn one_shot(status: &str, interruptible: bool) -> OneShotWork {
580        OneShotWork {
581            work_id: "work-1".to_string(),
582            agent_id: "agent-1".to_string(),
583            action: "upgrade".to_string(),
584            spec: "0.1.4".to_string(),
585            scheduled_at: "2026-09-23T00:00:00Z".to_string(),
586            deadline_at: "2026-09-24T00:00:00Z".to_string(),
587            timeout_seconds: 600,
588            interruptible,
589            status: status.to_string(),
590            paused_at: None,
591            paused_total_seconds: 0,
592            current_step: None,
593            completed_steps: vec![],
594            attempt: 0,
595            issued_by: "admin".to_string(),
596            issued_at: "2026-09-23T00:00:00Z".to_string(),
597        }
598    }
599
600    #[test]
601    fn terminal_one_shot_work_is_not_outstanding() {
602        // 未了结的活才进快照:终态留在库里供审计,但不必再发给 agent。
603        for status in ONE_SHOT_TERMINAL_STATUSES {
604            assert!(!one_shot(status, true).is_outstanding(), "{status}");
605        }
606        for status in ["dispatched", "accepted", "running", "paused"] {
607            assert!(one_shot(status, true).is_outstanding(), "{status}");
608        }
609    }
610
611    #[test]
612    fn only_interruptible_running_work_can_pause() {
613        // 不可中断:拒绝,而不是「尽力暂停」。
614        assert!(!one_shot("running", false).can_pause());
615        // 可中断但没在做(还没到点 / 已经了结):也没什么可暂停的。
616        assert!(!one_shot("dispatched", true).can_pause());
617        assert!(!one_shot("succeeded", true).can_pause());
618        assert!(one_shot("accepted", true).can_pause());
619        assert!(one_shot("running", true).can_pause());
620    }
621
622    #[test]
623    fn work_kind_serializes_as_the_model_names() {
624        assert_eq!(
625            serde_json::to_string(&WorkKind::Standing).expect("serialize"),
626            "\"Standing\""
627        );
628        assert_eq!(
629            serde_json::to_string(&WorkKind::OneShot).expect("serialize"),
630            "\"OneShot\""
631        );
632    }
633
634    #[test]
635    fn only_explicit_absolute_paths_are_collectable_file_targets() {
636        // `is_explicit_path` 现在只服务「单文件」语义(例如存在性裁剪只能对显式路径做)。
637        // 这个判据被两侧共用(网关的采集就绪度 + agentd 的接受逻辑),所以在这里钉死。
638        assert!(is_explicit_path("/var/log/install.log"));
639        assert!(!is_explicit_path("/var/log/wifi.log*"));
640        assert!(!is_explicit_path("/Library/Logs/DiagnosticReports/*.ips"));
641        assert!(!is_explicit_path("~/Library/Logs/Homebrew/*"));
642        assert!(!is_explicit_path("relative/app.log"));
643    }
644
645    #[test]
646    fn file_targets_accept_single_level_globs_and_reject_recursion() {
647        // W1:`FileGlob` 判据从「显式路径」放宽为「绝对路径,允许通配」。
648        assert!(is_glob_path("/var/log/containers/*.log"));
649        assert!(is_glob_path("/var/log/pods/*/*/*.log"));
650        assert!(is_glob_path("/var/lib/postgresql/*/data/log/*.csv"));
651        assert!(is_file_target("/var/log/containers/*.log"));
652        // 显式路径仍是合法文件目标(单文件 input 走它)。
653        assert!(is_file_target("/var/log/auth.log"));
654        // `**` 不展开:采集端只扫固定前缀目录,接受它只会在采集端变成每轮 IO 错误。
655        assert!(!is_glob_path("/var/log/**/*.log"));
656        assert!(!is_file_target("/var/log/**/*.log"));
657        // `~` / 相对路径不展开。
658        assert!(!is_glob_path("~/Library/Logs/*.log"));
659        assert!(!is_file_target("relative/*.log"));
660        // 空目标不是文件目标。
661        assert!(!is_file_target(""));
662    }
663
664    #[test]
665    fn executability_needs_both_a_supported_kind_and_a_supported_target() {
666        // 指标:`target` 是周期不是路径,不管显式路径那一条。
667        assert!(is_executable_source("MetricInterval", "15s"));
668        // 文件:显式路径与单层 glob 都可执行;`**` / `~` 不可执行(网关不能把它们报成“可采”)。
669        assert!(is_executable_source("FileGlob", "/var/log/app.log"));
670        assert!(is_executable_source(
671            "FileGlob",
672            "/var/log/containers/*.log"
673        ));
674        assert!(!is_executable_source("FileGlob", "/var/log/**/*.log"));
675        assert!(!is_executable_source("FileGlob", "~/logs/*.log"));
676        // 导出器:kind 对了,ID 还得是**已知的**(`last,lastb` 是 macOS 侧、尚未实现)。
677        assert!(is_executable_source("Exporter", "smartctl"));
678        assert!(is_executable_source("Exporter", "dmesg:panic"));
679        assert!(!is_executable_source("Exporter", "last,lastb"));
680        // 还没实现的 kind:一律不可执行。
681        assert!(!is_executable_source("UnifiedLogPredicate", "syspolicyd"));
682    }
683
684    #[test]
685    fn exporter_targets_split_into_a_known_id_and_an_optional_arg() {
686        assert_eq!(parse_exporter_target("smartctl"), Some(("smartctl", None)));
687        assert_eq!(
688            parse_exporter_target("dmesg:panic"),
689            Some(("dmesg", Some("panic")))
690        );
691        // 空 id / 含空白的 id 不是合法 ID。
692        assert_eq!(parse_exporter_target(":panic"), None);
693        assert_eq!(parse_exporter_target(" dm esg"), None);
694        // 判定只看冒号前的 ID,`arg` 不参与。
695        assert!(is_known_exporter("dmesg:nvidia-xid"));
696        assert!(!is_known_exporter("nope"));
697    }
698
699    #[test]
700    fn the_kernel_log_exporter_is_in_the_contract_vocabulary() {
701        // 词表只增不改名:这一条丢了,网关会把内核单元判成「不可采」(`active` 直接被拦),
702        // agentd 也会把它报成 unsupported。
703        assert!(EXPORTER_IDS.contains(&KERNEL_LOG_EXPORTER));
704        assert!(is_known_exporter(KERNEL_LOG_EXPORTER));
705        assert!(is_executable_source("Exporter", KERNEL_LOG_EXPORTER));
706        // 前缀相似但不是同一个 ID:不能靠 starts_with 之类的模糊匹配混过去。
707        assert!(!is_known_exporter("journalctl-kernelx"));
708    }
709
710    // ── 能力协商(D7):词表与判定同源 ──
711
712    /// 一个“什么都支持”的 agent 声明(agentd 上报的就是这一份的全集)。
713    fn full_logs() -> LogsCapabilities {
714        LogsCapabilities {
715            file_inputs: vec![
716                FILE_INPUT_ABSOLUTE_PATH.to_string(),
717                FILE_INPUT_GLOB.to_string(),
718            ],
719            multiline_modes: MULTILINE_MODES
720                .iter()
721                .map(|mode| mode.to_string())
722                .collect(),
723            exporters: EXPORTER_IDS.iter().map(|id| id.to_string()).collect(),
724            ..LogsCapabilities::default()
725        }
726    }
727
728    #[test]
729    fn capability_gaps_name_the_bucket_and_value() {
730        let old_agent = LogsCapabilities {
731            // W1 之前的 agentd:只会显式路径,不认识导出器能力。
732            file_inputs: vec![FILE_INPUT_ABSOLUTE_PATH.to_string()],
733            ..LogsCapabilities::default()
734        };
735        assert_eq!(
736            missing_log_capabilities("FileGlob", "/var/log/app.log", "none", &old_agent),
737            Some(Vec::new())
738        );
739        assert_eq!(
740            missing_log_capabilities("FileGlob", "/var/log/app/*.log", "none", &old_agent),
741            Some(vec!["file_inputs:glob".to_string()])
742        );
743        // 多行模式也是能力:说错了会把记录拼碎。
744        assert_eq!(
745            missing_log_capabilities("FileGlob", "/var/log/a.log", "indented", &old_agent),
746            Some(vec!["multiline_modes:indented".to_string()])
747        );
748        // 导出器是具名能力:新 ID(如 W3 的内核日志)旧 agent 一定不认识。
749        assert_eq!(
750            missing_log_capabilities("Exporter", KERNEL_LOG_EXPORTER, "none", &old_agent),
751            Some(vec![format!("exporters:{KERNEL_LOG_EXPORTER}")])
752        );
753        // `arg` 不参与判定(与 `is_known_exporter` 同口径)。
754        assert_eq!(
755            missing_log_capabilities("Exporter", "dmesg:panic", "none", &full_logs()),
756            Some(Vec::new())
757        );
758    }
759
760    #[test]
761    fn an_exporter_id_outside_the_vocabulary_is_reported_as_missing() {
762        // 写错 ID / 未来 ID 遇到旧 agent:都是“没声明这个能力”,不能当成“不归我管”放过 ——
763        // 放过的后果是派下去、agent 回 unsupported。
764        assert_eq!(
765            missing_log_capabilities("Exporter", "journalctl-kernelx", "none", &full_logs()),
766            Some(vec!["exporters:journalctl-kernelx".to_string()])
767        );
768        // 带 `arg` 也一样:只按 ID 判。
769        assert_eq!(
770            missing_log_capabilities("Exporter", "dmesg:panic", "none", &old_style_logs()),
771            Some(vec!["exporters:dmesg".to_string()])
772        );
773    }
774
775    /// 只声明了“显式路径 + 任何导出器”这类旧能力的 agent。
776    fn old_style_logs() -> LogsCapabilities {
777        LogsCapabilities {
778            file_inputs: vec![FILE_INPUT_ABSOLUTE_PATH.to_string()],
779            exporters: vec!["smartctl".to_string()],
780            ..LogsCapabilities::default()
781        }
782    }
783
784    #[test]
785    fn capability_checks_say_nothing_about_things_they_do_not_own() {
786        // 指标不走 logs 能力;不可执行的形态由 `is_executable_source` 说,不重复扣帽子。
787        assert_eq!(
788            missing_log_capabilities("MetricInterval", "15s", "none", &full_logs()),
789            None
790        );
791        assert_eq!(
792            missing_log_capabilities("FileGlob", "~/a/*.log", "none", &full_logs()),
793            None
794        );
795        assert_eq!(
796            missing_log_capabilities("UnifiedLogPredicate", "syspolicyd", "none", &full_logs()),
797            None
798        );
799    }
800
801    #[test]
802    fn a_fully_declared_agent_is_missing_nothing_the_contract_calls_executable() {
803        // 防漂:契约说“能执行”的每一种形态,声明了全集就必须不报缺。
804        // 新增一个导出器 ID / 一种 multiline 模式而忘了加到常量里,这条会红。
805        let full = full_logs();
806        for target in ["/var/log/a.log", "/var/log/containers/*.log"] {
807            assert_eq!(
808                missing_log_capabilities("FileGlob", target, "none", &full),
809                Some(Vec::new()),
810                "{target}"
811            );
812        }
813        for mode in MULTILINE_MODES {
814            assert_eq!(
815                missing_log_capabilities("FileGlob", "/var/log/a.log", mode, &full),
816                Some(Vec::new()),
817                "{mode}"
818            );
819        }
820        for id in EXPORTER_IDS {
821            assert_eq!(
822                missing_log_capabilities("Exporter", id, "none", &full),
823                Some(Vec::new()),
824                "{id}"
825            );
826        }
827    }
828
829    // ── 工作参数(`spec`)的编码 ──
830
831    fn spec() -> WorkSpec {
832        WorkSpec {
833            units: vec![
834                WorkSpecUnit {
835                    unit_id: "mac-host-metrics".to_string(),
836                    capability: "collect_metrics".to_string(),
837                    rule_ref: "agent_uplink".to_string(),
838                    requires_privilege: "none".to_string(),
839                    sources: vec![WorkSpecSource {
840                        kind: "MetricInterval".to_string(),
841                        target: "15s".to_string(),
842                        multiline: "none".to_string(),
843                        exclude_globs: Vec::new(),
844                        filters: ContainerFilters::default(),
845                    }],
846                },
847                WorkSpecUnit {
848                    unit_id: "mac-privacy-tcc".to_string(),
849                    capability: "collect_logs".to_string(),
850                    rule_ref: "macos/tcc".to_string(),
851                    requires_privilege: "fda".to_string(),
852                    sources: vec![
853                        WorkSpecSource {
854                            kind: "Exporter".to_string(),
855                            target: "sqlite-snapshot(TCC.db)".to_string(),
856                            multiline: "none".to_string(),
857                            exclude_globs: Vec::new(),
858                            filters: ContainerFilters::default(),
859                        },
860                        WorkSpecSource {
861                            kind: "FileGlob".to_string(),
862                            // 显式单路径:单文件采集走 `startup_position` / 检查点的单文件语义。
863                            target: "/var/log/tccd.log".to_string(),
864                            // 多行格式是这条日志的固有属性,跟着来源走。
865                            multiline: "indented".to_string(),
866                            exclude_globs: Vec::new(),
867                            filters: ContainerFilters::default(),
868                        },
869                    ],
870                },
871            ],
872        }
873    }
874
875    #[test]
876    fn spec_round_trips_and_reports_what_agentd_can_do() {
877        let encoded = spec().encode().expect("encode");
878        let decoded = WorkSpec::parse(&encoded).expect("parse");
879        assert_eq!(decoded, spec());
880
881        assert!(decoded.collects_logs());
882        // 取了最密的周期:要得最急的才是上送频率的下界。
883        assert_eq!(decoded.metric_interval_seconds(), Some(15));
884        // 可本地 tail 的来源挑得出来,接不了的来源也报得出来(而不是当没看见)。
885        let files = decoded.units[1].file_sources();
886        assert_eq!(files.len(), 1);
887        assert_eq!(files[0].target, "/var/log/tccd.log");
888        assert_eq!(files[0].multiline, "indented");
889        assert_eq!(decoded.units[1].unsupported_sources().len(), 1);
890        assert_eq!(decoded.units[1].unsupported_sources()[0].kind, "Exporter");
891    }
892
893    #[test]
894    fn a_glob_file_source_is_collectable_but_recursive_globs_are_not() {
895        // W1 起:单层 glob 是采集端能执行的来源(容器日志就靠它)。
896        let mut unit = WorkSpecUnit {
897            unit_id: "linux-container-stdout".to_string(),
898            capability: "collect_logs".to_string(),
899            rule_ref: String::new(),
900            requires_privilege: "root".to_string(),
901            sources: vec![WorkSpecSource {
902                kind: "FileGlob".to_string(),
903                target: "/var/log/containers/*.log".to_string(),
904                multiline: "none".to_string(),
905                exclude_globs: vec!["/var/log/containers/*_kube-system_*.log".to_string()],
906                filters: ContainerFilters::default(),
907            }],
908        };
909        assert_eq!(unit.file_sources().len(), 1, "它是一条文件来源");
910        assert!(unit.unsupported_sources().is_empty());
911        assert!(unit.has_executable_source());
912
913        // `**` 仍不可执行:递归展开没实现,接受它就等于承诺一个采不到的面。
914        unit.sources[0].target = "/var/log/**/*.log".to_string();
915        assert_eq!(unit.unsupported_sources().len(), 1);
916        assert!(!unit.has_executable_source());
917    }
918
919    #[test]
920    fn exclude_globs_round_trip_and_stay_out_of_the_wire_when_empty() {
921        let mut source = WorkSpecSource {
922            kind: "FileGlob".to_string(),
923            target: "/var/log/containers/*.log".to_string(),
924            multiline: "none".to_string(),
925            exclude_globs: Vec::new(),
926            filters: ContainerFilters::default(),
927        };
928        // 空排除项不进 JSON:旧 agent 的 `WorkSpecSource` 是 `deny_unknown_fields` 的。
929        assert_eq!(
930            serde_json::to_string(&source).expect("serialize"),
931            r#"{"kind":"FileGlob","target":"/var/log/containers/*.log","multiline":"none"}"#
932        );
933
934        source.exclude_globs = vec!["/var/log/containers/*_kube-system_*.log".to_string()];
935        let json = serde_json::to_string(&source).expect("serialize");
936        assert_eq!(
937            serde_json::from_str::<WorkSpecSource>(&json).expect("parse"),
938            source
939        );
940        // 老网关发来的来源没有这两个字段:默认必须能解出来。
941        let old = r#"{"kind":"FileGlob","target":"/a/*"}"#;
942        let parsed: WorkSpecSource = serde_json::from_str(old).expect("parse legacy source");
943        assert!(parsed.exclude_globs.is_empty());
944        assert!(parsed.filters.is_empty());
945        assert_eq!(parsed.multiline, "none");
946    }
947
948    #[test]
949    fn container_filters_round_trip_and_stay_out_of_the_wire_when_empty() {
950        let mut source = WorkSpecSource {
951            kind: "FileGlob".to_string(),
952            target: "/var/log/containers/*.log".to_string(),
953            multiline: "none".to_string(),
954            exclude_globs: Vec::new(),
955            filters: ContainerFilters::default(),
956        };
957        // 空过滤器不进 JSON(与 exclude_globs 同一条纪律:旧 agent 是 deny_unknown_fields)。
958        let json = serde_json::to_string(&source).expect("serialize");
959        assert!(!json.contains("filters"), "{json}");
960
961        source.filters = ContainerFilters {
962            namespace_in: vec!["prod".to_string()],
963            pod_not_in: vec!["debug-pod".to_string()],
964        };
965        let json = serde_json::to_string(&source).expect("serialize");
966        assert!(
967            json.contains(r#""filters":{"namespace_in":["prod"],"pod_not_in":["debug-pod"]}"#),
968            "{json}"
969        );
970        assert_eq!(
971            serde_json::from_str::<WorkSpecSource>(&json).expect("parse"),
972            source
973        );
974
975        // 只给一半也要能解:另一侧默认空。
976        let half = r#"{"kind":"FileGlob","target":"/var/log/containers/*.log","filters":{"namespace_in":["prod"]}}"#;
977        let parsed: WorkSpecSource = serde_json::from_str(half).expect("parse half filters");
978        assert_eq!(parsed.filters.namespace_in, vec!["prod".to_string()]);
979        assert!(parsed.filters.pod_not_in.is_empty());
980    }
981
982    #[test]
983    fn an_explicitly_empty_filters_object_is_not_serialized() {
984        // 策展/页面可能显式写一个空对象(`filters = {}`):它仍然等于“不过滤”,
985        // 不能在线上多出一个键 —— 旧 agent 是 `deny_unknown_fields` 的。
986        let source = WorkSpecSource {
987            kind: "FileGlob".to_string(),
988            target: "/var/log/containers/*.log".to_string(),
989            multiline: "none".to_string(),
990            exclude_globs: Vec::new(),
991            filters: ContainerFilters {
992                namespace_in: Vec::new(),
993                pod_not_in: Vec::new(),
994            },
995        };
996        let json = serde_json::to_string(&source).expect("serialize");
997        assert!(!json.contains("filters"), "{json}");
998    }
999
1000    #[test]
1001    fn container_filters_keep_values_verbatim() {
1002        // 名字**不做归一化**(不折叠大小写、不去前后缀):k8s 名字永远小写,
1003        // 归一化反而会把写错的值当成能用。逐字保存、逐字比较(比较在 agentd)。
1004        let filters = ContainerFilters {
1005            namespace_in: vec!["Prod".to_string()],
1006            pod_not_in: vec!["debug-pod".to_string()],
1007        };
1008        let json = serde_json::to_string(&filters).expect("serialize");
1009        let parsed: ContainerFilters = serde_json::from_str(&json).expect("parse");
1010        assert_eq!(parsed.namespace_in, vec!["Prod".to_string()]);
1011        assert_eq!(parsed.pod_not_in, vec!["debug-pod".to_string()]);
1012    }
1013
1014    #[test]
1015    fn container_filters_reject_unknown_keys() {
1016        // `deny_unknown_fields`:拼错一个键(`namespaces`)不能静默当空过滤器 ——
1017        // 那会让策展人以为「只采 prod」,实际全采。
1018        assert!(serde_json::from_str::<ContainerFilters>(r#"{"namespaces":["prod"]}"#).is_err());
1019        assert!(serde_json::from_str::<ContainerFilters>(r#"{"namespace_in":["prod"]}"#).is_ok());
1020    }
1021
1022    #[test]
1023    fn container_filters_reject_values_that_can_never_match() {
1024        let ok = |namespace: &[&str], pods: &[&str]| ContainerFilters {
1025            namespace_in: namespace.iter().map(|v| v.to_string()).collect(),
1026            pod_not_in: pods.iter().map(|v| v.to_string()).collect(),
1027        };
1028
1029        assert!(
1030            ok(&["prod", "kube-system"], &["debug-pod"])
1031                .validate()
1032                .is_ok()
1033        );
1034        assert!(ok(&[], &[]).is_empty());
1035        // 这些一定对不上(过滤器存的是**精确名字**,不是 pattern):错在最早的入口就说。
1036        assert!(ok(&[""], &[]).validate().is_err());
1037        assert!(ok(&[" prod"], &[]).validate().is_err());
1038        assert!(ok(&["prod*"], &[]).validate().is_err());
1039        assert!(ok(&[], &["debug?pod"]).validate().is_err());
1040        assert!(ok(&[&"x".repeat(254)], &[]).validate().is_err());
1041    }
1042
1043    #[test]
1044    fn a_source_without_multiline_decodes_as_single_line() {
1045        // 老网关发来的 spec 没有 `multiline`:默认必须是一行一条。
1046        // 取错默认值会把多行日志**粘**成一条 —— 那是无声的内容损坏,不是格式问题。
1047        let json = r#"{"units":[{"unit_id":"u","capability":"collect_logs","sources":[{"kind":"FileGlob","target":"/a/*"}]}]}"#;
1048        let spec = WorkSpec::parse(json).expect("parse");
1049        assert_eq!(spec.units[0].sources[0].multiline, "none");
1050        // 再编码出去会把默认值写实:新网关发的 spec 字段总是齐的。
1051        assert_eq!(
1052            spec.encode().expect("encode"),
1053            r#"{"units":[{"unit_id":"u","capability":"collect_logs","rule_ref":"","requires_privilege":"","sources":[{"kind":"FileGlob","target":"/a/*","multiline":"none"}]}]}"#
1054        );
1055    }
1056
1057    #[test]
1058    fn a_broken_spec_is_an_error_not_an_empty_work() {
1059        // 参数坏了与「没事可做」是两回事:前者要人去看,后者什么都不用做。
1060        assert!(WorkSpec::parse("mac-host-metrics").is_err());
1061        assert!(WorkSpec::parse("{").is_err());
1062        // 空工作本身是合法的(能表示「这个面暂时没东西可采」)。
1063        assert!(WorkSpec::parse(r#"{"units":[]}"#).unwrap().units.is_empty());
1064        assert_eq!(
1065            WorkSpec::parse(r#"{"units":[]}"#)
1066                .unwrap()
1067                .metric_interval_seconds(),
1068            None
1069        );
1070    }
1071
1072    #[test]
1073    fn interval_parsing_accepts_seconds_and_minutes_only() {
1074        assert_eq!(parse_interval_seconds("15s"), Some(15));
1075        assert_eq!(parse_interval_seconds(" 5s "), Some(5));
1076        assert_eq!(parse_interval_seconds("5m"), Some(300));
1077        // 认不出来的回 None(调用方回退到自己的默认值),而不是静默当 0。
1078        assert_eq!(parse_interval_seconds("15"), None);
1079        assert_eq!(parse_interval_seconds("0s"), None);
1080        assert_eq!(parse_interval_seconds(""), None);
1081    }
1082
1083    #[test]
1084    fn interval_parsing_never_overflows_on_huge_values() {
1085        // 契约是「解析不了返回 None」,不是 panic:溢出在 debug 下 panic、release 下回绕成一个
1086        // 错误的间隔 —— 两个后果都不能接受。这是两侧都要解析的字节,输入可能来自远端。
1087        assert_eq!(parse_interval_seconds("922337203685477580m"), None);
1088        assert_eq!(parse_interval_seconds("153722867280912931m"), None);
1089        // 秒分支本来就靠 parse 失败兜底。
1090        assert_eq!(parse_interval_seconds("99999999999999999999s"), None);
1091        // 合法但很大的分钟值仍要算得出来(不误伤)。
1092        assert_eq!(parse_interval_seconds("600m"), Some(36_000));
1093    }
1094
1095    #[test]
1096    fn metric_interval_ignores_unparseable_targets_instead_of_treating_them_as_zero() {
1097        // 认不出的周期不能静默当 0(0 会被当成「无限密」),而要如实跳过,只取能解析的最密值。
1098        let unit = |target: &str| WorkSpecUnit {
1099            unit_id: "u".to_string(),
1100            capability: "collect_metrics".to_string(),
1101            rule_ref: String::new(),
1102            requires_privilege: String::new(),
1103            sources: vec![WorkSpecSource {
1104                kind: "MetricInterval".to_string(),
1105                target: target.to_string(),
1106                multiline: "none".to_string(),
1107                exclude_globs: Vec::new(),
1108                filters: ContainerFilters::default(),
1109            }],
1110        };
1111        let mixed = WorkSpec {
1112            units: vec![unit("garbage"), unit("30s")],
1113        };
1114        assert_eq!(mixed.metric_interval_seconds(), Some(30));
1115
1116        let all_bad = WorkSpec {
1117            units: vec![unit("nope")],
1118        };
1119        assert_eq!(all_bad.metric_interval_seconds(), None);
1120    }
1121}