use serde::{Deserialize, Serialize};
use crate::capability_report::LogsCapabilities;
pub const STANDING_WORK_STATUSES: [&str; 4] = ["active", "paused", "superseded", "revoked"];
pub const ONE_SHOT_WORK_STATUSES: [&str; 9] = [
"dispatched",
"accepted",
"running",
"paused",
"succeeded",
"failed",
"timed_out",
"canceled",
"expired",
];
pub const AGENT_REPORTABLE_WORK_STATUSES: [&str; 3] = ["running", "succeeded", "failed"];
pub const ONE_SHOT_TERMINAL_STATUSES: [&str; 5] =
["succeeded", "failed", "timed_out", "canceled", "expired"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "state", domain = "Control", module = "Control.Agent.Work")]
#[serde(rename_all = "PascalCase")]
pub enum WorkKind {
Standing,
OneShot,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
#[serde(deny_unknown_fields)]
pub struct StandingWork {
pub work_id: String,
pub agent_id: String,
pub family: String,
pub spec: String,
pub catalog_version: i64,
#[serde(default)]
pub proposal_id: Option<String>,
pub plan_version: i64,
pub effective_from: String,
pub status: String,
pub updated_by: String,
pub updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
#[serde(deny_unknown_fields)]
pub struct OneShotWork {
pub work_id: String,
pub agent_id: String,
pub action: String,
pub spec: String,
pub scheduled_at: String,
pub deadline_at: String,
pub timeout_seconds: i64,
pub interruptible: bool,
pub status: String,
#[serde(default)]
pub paused_at: Option<String>,
pub paused_total_seconds: i64,
#[serde(default)]
pub current_step: Option<String>,
#[serde(default)]
pub completed_steps: Vec<String>,
pub attempt: i64,
pub issued_by: String,
pub issued_at: String,
}
impl OneShotWork {
pub fn is_outstanding(&self) -> bool {
!ONE_SHOT_TERMINAL_STATUSES.contains(&self.status.as_str())
}
pub fn can_pause(&self) -> bool {
self.interruptible && matches!(self.status.as_str(), "accepted" | "running")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
#[serde(deny_unknown_fields)]
pub struct WorkReceipt {
pub work_id: String,
pub agent_id: String,
pub work_kind: WorkKind,
pub status: String,
pub plan_version: i64,
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkSpec {
#[serde(default)]
pub units: Vec<WorkSpecUnit>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkSpecUnit {
pub unit_id: String,
pub capability: String,
#[serde(default)]
pub rule_ref: String,
#[serde(default)]
pub requires_privilege: String,
#[serde(default)]
pub sources: Vec<WorkSpecSource>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkSpecSource {
pub kind: String,
pub target: String,
#[serde(default = "default_multiline")]
pub multiline: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude_globs: Vec<String>,
#[serde(default, skip_serializing_if = "ContainerFilters::is_empty")]
pub filters: ContainerFilters,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub drop_if_contains: Vec<String>,
}
pub const CONTAINER_LOG_ROOT: &str = "/var/log/containers";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ContainerFilters {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub namespace_in: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pod_not_in: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub name_in: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub name_not_in: Vec<String>,
}
pub const FILTER_NAMESPACE_IN: &str = "namespace_in";
pub const FILTER_POD_NOT_IN: &str = "pod_not_in";
pub const FILTER_NAME_IN: &str = "name_in";
pub const FILTER_NAME_NOT_IN: &str = "name_not_in";
pub const CONTAINER_FILTERS: &[&str] = &[
FILTER_NAMESPACE_IN,
FILTER_POD_NOT_IN,
FILTER_NAME_IN,
FILTER_NAME_NOT_IN,
];
pub const DROP_IF_CONTAINS_SUBSTRING: &str = "substring";
pub const DROP_IF_CONTAINS_MODES: &[&str] = &[DROP_IF_CONTAINS_SUBSTRING];
pub const DROP_IF_CONTAINS_MAX_ITEMS: usize = 8;
pub const DROP_IF_CONTAINS_MAX_BYTES: usize = 200;
pub fn validate_drop_if_contains(needles: &[String]) -> Result<(), String> {
if needles.len() > DROP_IF_CONTAINS_MAX_ITEMS {
return Err(format!(
"drop_if_contains 条数过多({},上限 {DROP_IF_CONTAINS_MAX_ITEMS})",
needles.len()
));
}
for (index, needle) in needles.iter().enumerate() {
if needle.trim().is_empty() {
return Err(format!("drop_if_contains[{index}] 是空串"));
}
if needle.trim() != needle {
return Err(format!("drop_if_contains[{index}] {needle:?} 带首尾空白"));
}
if needle.chars().any(char::is_control) {
return Err(format!("drop_if_contains[{index}] {needle:?} 含控制字符"));
}
if needle.len() > DROP_IF_CONTAINS_MAX_BYTES {
return Err(format!(
"drop_if_contains[{index}] 过长({} 字节,上限 {DROP_IF_CONTAINS_MAX_BYTES})",
needle.len()
));
}
if needles[..index].contains(needle) {
return Err(format!("drop_if_contains[{index}] {needle:?} 重复"));
}
}
Ok(())
}
impl ContainerFilters {
pub fn is_empty(&self) -> bool {
self.namespace_in.is_empty()
&& self.pod_not_in.is_empty()
&& self.name_in.is_empty()
&& self.name_not_in.is_empty()
}
pub fn used_dimensions(&self) -> Vec<&'static str> {
let mut used = Vec::new();
if !self.namespace_in.is_empty() {
used.push(FILTER_NAMESPACE_IN);
}
if !self.pod_not_in.is_empty() {
used.push(FILTER_POD_NOT_IN);
}
if !self.name_in.is_empty() {
used.push(FILTER_NAME_IN);
}
if !self.name_not_in.is_empty() {
used.push(FILTER_NAME_NOT_IN);
}
used
}
pub fn validate(&self) -> Result<(), String> {
for (field, values) in [
(FILTER_NAMESPACE_IN, &self.namespace_in),
(FILTER_POD_NOT_IN, &self.pod_not_in),
(FILTER_NAME_IN, &self.name_in),
(FILTER_NAME_NOT_IN, &self.name_not_in),
] {
for value in values {
if value.trim().is_empty() {
return Err(format!("{field} 里有空值"));
}
if value.trim() != value {
return Err(format!("{field} 里 {value:?} 带首尾空白"));
}
if value.contains(['*', '?', '[', ']']) {
return Err(format!(
"{field} 里 {value:?} 含通配符(这里是精确名字,不是 pattern)"
));
}
if value.len() > 253 {
return Err(format!("{field} 里 {value:?} 过长(>253)"));
}
}
}
Ok(())
}
}
fn default_multiline() -> String {
"none".to_string()
}
impl WorkSpec {
pub fn parse(spec: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(spec)
}
pub fn encode(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
pub fn metric_interval_seconds(&self) -> Option<i64> {
self.units
.iter()
.flat_map(|unit| unit.sources.iter())
.filter(|source| source.kind == "MetricInterval")
.filter_map(|source| parse_interval_seconds(&source.target))
.min()
}
pub fn collects_logs(&self) -> bool {
self.units
.iter()
.any(|unit| unit.capability == "collect_logs")
}
}
pub const EXECUTABLE_SOURCE_KINDS: &[&str] = &["FileGlob", "MetricInterval", "Exporter"];
pub fn has_glob_meta(target: &str) -> bool {
target.contains(['*', '?', '['])
}
pub fn is_explicit_path(target: &str) -> bool {
target.starts_with('/') && !has_glob_meta(target)
}
pub fn is_glob_path(target: &str) -> bool {
target.starts_with('/') && has_glob_meta(target) && !target.contains("**")
}
pub fn is_file_target(target: &str) -> bool {
is_explicit_path(target) || is_glob_path(target)
}
pub fn is_container_log_target(target: &str) -> bool {
target.starts_with('/') && target.split('/').any(|segment| segment == "containers")
}
pub const KERNEL_LOG_EXPORTER: &str = "journalctl-kernel";
pub fn missing_log_capabilities(
source: &WorkSpecSource,
declared: &LogsCapabilities,
) -> Option<Vec<String>> {
let declared_has = |bucket: &[String], value: &str| bucket.iter().any(|item| item == value);
let kind = source.kind.as_str();
let target = source.target.as_str();
match kind {
"FileGlob" => {
if !is_file_target(target) {
return None;
}
let mut missing = Vec::new();
let need = if is_glob_path(target) {
FILE_INPUT_GLOB
} else {
FILE_INPUT_ABSOLUTE_PATH
};
if !declared_has(&declared.file_inputs, need) {
missing.push(format!("file_inputs:{need}"));
}
if source.multiline != "none"
&& !declared_has(&declared.multiline_modes, &source.multiline)
{
missing.push(format!("multiline_modes:{}", source.multiline));
}
for dimension in source.filters.used_dimensions() {
if !declared_has(&declared.filters, dimension) {
missing.push(format!("filters:{dimension}"));
}
}
if !source.drop_if_contains.is_empty()
&& !declared_has(&declared.drop_if_contains, DROP_IF_CONTAINS_SUBSTRING)
{
missing.push(format!("drop_if_contains:{DROP_IF_CONTAINS_SUBSTRING}"));
}
Some(missing)
}
"Exporter" => {
let (id, _) = parse_exporter_target(target)?;
Some(if declared_has(&declared.exporters, id) {
Vec::new()
} else {
vec![format!("exporters:{id}")]
})
}
_ => None,
}
}
pub const FILE_INPUT_ABSOLUTE_PATH: &str = "absolute-path";
pub const FILE_INPUT_GLOB: &str = "glob";
pub const MULTILINE_MODES: &[&str] = &["none", "indented", "mysql_slow", "postgres_csv"];
pub const EXPORTER_IDS: &[&str] = &[
"journalctl-unit",
"journalctl-shutdown",
"last-reboot",
"nft-ruleset",
"iptables-save",
"smartctl",
"dmesg",
"auditd-execve",
KERNEL_LOG_EXPORTER,
];
pub fn parse_exporter_target(target: &str) -> Option<(&str, Option<&str>)> {
let (id, arg) = match target.split_once(':') {
Some((id, arg)) => (id, Some(arg)),
None => (target, None),
};
let id = id.trim();
if id.is_empty() || id.contains(char::is_whitespace) {
return None;
}
Some((id, arg))
}
pub fn is_known_exporter(target: &str) -> bool {
match parse_exporter_target(target) {
Some((id, _)) => EXPORTER_IDS.contains(&id),
None => false,
}
}
pub fn is_executable_source(kind: &str, target: &str) -> bool {
match kind {
"FileGlob" => is_file_target(target),
"MetricInterval" => true,
"Exporter" => is_known_exporter(target),
_ => false,
}
}
impl WorkSpecUnit {
pub fn file_sources(&self) -> Vec<&WorkSpecSource> {
self.sources
.iter()
.filter(|source| source.kind == "FileGlob")
.collect()
}
pub fn unsupported_sources(&self) -> Vec<&WorkSpecSource> {
self.sources
.iter()
.filter(|source| !is_executable_source(&source.kind, &source.target))
.collect()
}
pub fn has_executable_source(&self) -> bool {
self.sources
.iter()
.any(|source| is_executable_source(&source.kind, &source.target))
}
}
pub fn parse_interval_seconds(raw: &str) -> Option<i64> {
let raw = raw.trim();
if let Some(value) = raw.strip_suffix('s') {
return value.trim().parse::<i64>().ok().filter(|value| *value > 0);
}
if let Some(value) = raw.strip_suffix('m') {
let minutes = value.trim().parse::<i64>().ok()?;
return minutes.checked_mul(60).filter(|value| *value > 0);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capability_report::LogsCapabilities;
fn one_shot(status: &str, interruptible: bool) -> OneShotWork {
OneShotWork {
work_id: "work-1".to_string(),
agent_id: "agent-1".to_string(),
action: "upgrade".to_string(),
spec: "0.1.4".to_string(),
scheduled_at: "2026-09-23T00:00:00Z".to_string(),
deadline_at: "2026-09-24T00:00:00Z".to_string(),
timeout_seconds: 600,
interruptible,
status: status.to_string(),
paused_at: None,
paused_total_seconds: 0,
current_step: None,
completed_steps: vec![],
attempt: 0,
issued_by: "admin".to_string(),
issued_at: "2026-09-23T00:00:00Z".to_string(),
}
}
#[test]
fn terminal_one_shot_work_is_not_outstanding() {
for status in ONE_SHOT_TERMINAL_STATUSES {
assert!(!one_shot(status, true).is_outstanding(), "{status}");
}
for status in ["dispatched", "accepted", "running", "paused"] {
assert!(one_shot(status, true).is_outstanding(), "{status}");
}
}
#[test]
fn only_interruptible_running_work_can_pause() {
assert!(!one_shot("running", false).can_pause());
assert!(!one_shot("dispatched", true).can_pause());
assert!(!one_shot("succeeded", true).can_pause());
assert!(one_shot("accepted", true).can_pause());
assert!(one_shot("running", true).can_pause());
}
#[test]
fn work_kind_serializes_as_the_model_names() {
assert_eq!(
serde_json::to_string(&WorkKind::Standing).expect("serialize"),
"\"Standing\""
);
assert_eq!(
serde_json::to_string(&WorkKind::OneShot).expect("serialize"),
"\"OneShot\""
);
}
#[test]
fn only_explicit_absolute_paths_are_collectable_file_targets() {
assert!(is_explicit_path("/var/log/install.log"));
assert!(!is_explicit_path("/var/log/wifi.log*"));
assert!(!is_explicit_path("/Library/Logs/DiagnosticReports/*.ips"));
assert!(!is_explicit_path("~/Library/Logs/Homebrew/*"));
assert!(!is_explicit_path("relative/app.log"));
}
#[test]
fn file_targets_accept_single_level_globs_and_reject_recursion() {
assert!(is_glob_path("/var/log/containers/*.log"));
assert!(is_glob_path("/var/log/pods/*/*/*.log"));
assert!(is_glob_path("/var/lib/postgresql/*/data/log/*.csv"));
assert!(is_file_target("/var/log/containers/*.log"));
assert!(is_file_target("/var/log/auth.log"));
assert!(!is_glob_path("/var/log/**/*.log"));
assert!(!is_file_target("/var/log/**/*.log"));
assert!(!is_glob_path("~/Library/Logs/*.log"));
assert!(!is_file_target("relative/*.log"));
assert!(!is_file_target(""));
}
#[test]
fn executability_needs_both_a_supported_kind_and_a_supported_target() {
assert!(is_executable_source("MetricInterval", "15s"));
assert!(is_executable_source("FileGlob", "/var/log/app.log"));
assert!(is_executable_source(
"FileGlob",
"/var/log/containers/*.log"
));
assert!(!is_executable_source("FileGlob", "/var/log/**/*.log"));
assert!(!is_executable_source("FileGlob", "~/logs/*.log"));
assert!(is_executable_source("Exporter", "smartctl"));
assert!(is_executable_source("Exporter", "dmesg:panic"));
assert!(!is_executable_source("Exporter", "last,lastb"));
assert!(!is_executable_source("UnifiedLogPredicate", "syspolicyd"));
}
#[test]
fn exporter_targets_split_into_a_known_id_and_an_optional_arg() {
assert_eq!(parse_exporter_target("smartctl"), Some(("smartctl", None)));
assert_eq!(
parse_exporter_target("dmesg:panic"),
Some(("dmesg", Some("panic")))
);
assert_eq!(parse_exporter_target(":panic"), None);
assert_eq!(parse_exporter_target(" dm esg"), None);
assert!(is_known_exporter("dmesg:nvidia-xid"));
assert!(!is_known_exporter("nope"));
}
#[test]
fn the_kernel_log_exporter_is_in_the_contract_vocabulary() {
assert!(EXPORTER_IDS.contains(&KERNEL_LOG_EXPORTER));
assert!(is_known_exporter(KERNEL_LOG_EXPORTER));
assert!(is_executable_source("Exporter", KERNEL_LOG_EXPORTER));
assert!(!is_known_exporter("journalctl-kernelx"));
}
fn source(kind: &str, target: &str, multiline: &str) -> WorkSpecSource {
WorkSpecSource {
kind: kind.to_string(),
target: target.to_string(),
multiline: multiline.to_string(),
exclude_globs: Vec::new(),
filters: ContainerFilters::default(),
drop_if_contains: Vec::new(),
}
}
fn container_source(target: &str, filters: ContainerFilters) -> WorkSpecSource {
WorkSpecSource {
filters,
..source("FileGlob", target, "none")
}
}
fn missing(
kind: &str,
target: &str,
multiline: &str,
declared: &LogsCapabilities,
) -> Option<Vec<String>> {
missing_log_capabilities(&source(kind, target, multiline), declared)
}
fn full_logs() -> LogsCapabilities {
LogsCapabilities {
file_inputs: vec![
FILE_INPUT_ABSOLUTE_PATH.to_string(),
FILE_INPUT_GLOB.to_string(),
],
multiline_modes: MULTILINE_MODES
.iter()
.map(|mode| mode.to_string())
.collect(),
exporters: EXPORTER_IDS.iter().map(|id| id.to_string()).collect(),
filters: CONTAINER_FILTERS.iter().map(|di| di.to_string()).collect(),
drop_if_contains: DROP_IF_CONTAINS_MODES
.iter()
.map(|m| m.to_string())
.collect(),
..LogsCapabilities::default()
}
}
#[test]
fn capability_gaps_name_the_bucket_and_value() {
let old_agent = LogsCapabilities {
file_inputs: vec![FILE_INPUT_ABSOLUTE_PATH.to_string()],
..LogsCapabilities::default()
};
assert_eq!(
missing("FileGlob", "/var/log/app.log", "none", &old_agent),
Some(Vec::new())
);
assert_eq!(
missing("FileGlob", "/var/log/app/*.log", "none", &old_agent),
Some(vec!["file_inputs:glob".to_string()])
);
assert_eq!(
missing("FileGlob", "/var/log/a.log", "indented", &old_agent),
Some(vec!["multiline_modes:indented".to_string()])
);
assert_eq!(
missing("Exporter", KERNEL_LOG_EXPORTER, "none", &old_agent),
Some(vec![format!("exporters:{KERNEL_LOG_EXPORTER}")])
);
assert_eq!(
missing("Exporter", "dmesg:panic", "none", &full_logs()),
Some(Vec::new())
);
assert_eq!(
missing("FileGlob", "/var/log/containers/*.log", "none", &old_agent),
Some(vec!["file_inputs:glob".to_string()])
);
}
#[test]
fn container_filter_dimensions_are_negotiated_separately() {
let before_w2 = LogsCapabilities {
filters: Vec::new(),
..full_logs()
};
let namespace_only = ContainerFilters {
namespace_in: vec!["prod".to_string()],
pod_not_in: Vec::new(),
name_in: Vec::new(),
name_not_in: Vec::new(),
};
let target = "/var/log/containers/*.log";
assert_eq!(
missing_log_capabilities(&container_source(target, namespace_only), &before_w2),
Some(vec!["filters:namespace_in".to_string()])
);
let both = ContainerFilters {
namespace_in: vec!["prod".to_string()],
pod_not_in: vec!["debug-pod".to_string()],
name_in: Vec::new(),
name_not_in: Vec::new(),
};
assert_eq!(
missing_log_capabilities(&container_source(target, both.clone()), &before_w2),
Some(vec![
"filters:namespace_in".to_string(),
"filters:pod_not_in".to_string(),
])
);
assert_eq!(
missing_log_capabilities(&container_source(target, both), &full_logs()),
Some(Vec::new())
);
assert_eq!(
ContainerFilters {
namespace_in: vec!["a".to_string()],
pod_not_in: vec!["b".to_string()],
name_in: Vec::new(),
name_not_in: Vec::new(),
}
.used_dimensions(),
vec![FILTER_NAMESPACE_IN, FILTER_POD_NOT_IN]
);
assert!(ContainerFilters::default().used_dimensions().is_empty());
}
#[test]
fn noise_drop_needles_are_a_capability_of_their_own() {
let before_w4 = LogsCapabilities {
drop_if_contains: Vec::new(),
..full_logs()
};
let mut source = source("FileGlob", "/var/log/auth.log", "none");
source.drop_if_contains = vec!["pam_unix(cron:session)".to_string()];
assert_eq!(
missing_log_capabilities(&source, &before_w4),
Some(vec!["drop_if_contains:substring".to_string()])
);
assert_eq!(
missing_log_capabilities(&source, &full_logs()),
Some(Vec::new())
);
}
#[test]
fn drop_if_contains_is_substring_shaped_and_stays_out_of_the_wire_when_empty() {
let mut source = source("FileGlob", "/var/log/auth.log", "none");
assert!(
!serde_json::to_string(&source)
.expect("serialize")
.contains("drop_if_contains")
);
source.drop_if_contains = vec!["pam_unix(cron:session)".to_string()];
let json = serde_json::to_string(&source).expect("serialize");
assert!(
json.contains(r#""drop_if_contains":["pam_unix(cron:session)"]"#),
"{json}"
);
assert_eq!(
serde_json::from_str::<WorkSpecSource>(&json).expect("parse"),
source
);
assert!(validate_drop_if_contains(&["ok".to_string()]).is_ok());
assert!(validate_drop_if_contains(&[]).is_ok());
for bad in ["", " x", "x ", "line\nbreak", " nul\0"] {
assert!(
validate_drop_if_contains(&[bad.to_string()]).is_err(),
"{bad:?} should be rejected"
);
}
assert!(validate_drop_if_contains(&["x".repeat(DROP_IF_CONTAINS_MAX_BYTES + 1)]).is_err());
let too_many: Vec<String> = (0..=DROP_IF_CONTAINS_MAX_ITEMS)
.map(|i| format!("needle-{i}"))
.collect();
assert!(validate_drop_if_contains(&too_many).is_err());
assert!(validate_drop_if_contains(&["dup".to_string(), "dup".to_string()]).is_err());
}
#[test]
fn the_new_multiline_modes_are_in_the_contract_vocabulary() {
for mode in ["mysql_slow", "postgres_csv"] {
assert!(MULTILINE_MODES.contains(&mode), "{mode}");
}
let old_agent = LogsCapabilities {
multiline_modes: vec!["none".to_string(), "indented".to_string()],
..full_logs()
};
assert_eq!(
missing(
"FileGlob",
"/var/log/postgresql/x.csv",
"postgres_csv",
&old_agent
),
Some(vec!["multiline_modes:postgres_csv".to_string()])
);
assert_eq!(
missing(
"FileGlob",
"/var/log/mysql/slow.log",
"mysql_slow",
&old_agent
),
Some(vec!["multiline_modes:mysql_slow".to_string()])
);
}
#[test]
fn an_exporter_id_outside_the_vocabulary_is_reported_as_missing() {
assert_eq!(
missing("Exporter", "journalctl-kernelx", "none", &full_logs()),
Some(vec!["exporters:journalctl-kernelx".to_string()])
);
assert_eq!(
missing("Exporter", "dmesg:panic", "none", &old_style_logs()),
Some(vec!["exporters:dmesg".to_string()])
);
}
fn old_style_logs() -> LogsCapabilities {
LogsCapabilities {
file_inputs: vec![FILE_INPUT_ABSOLUTE_PATH.to_string()],
exporters: vec!["smartctl".to_string()],
..LogsCapabilities::default()
}
}
#[test]
fn capability_checks_say_nothing_about_things_they_do_not_own() {
assert_eq!(missing("MetricInterval", "15s", "none", &full_logs()), None);
assert_eq!(missing("FileGlob", "~/a/*.log", "none", &full_logs()), None);
assert_eq!(
missing("UnifiedLogPredicate", "syspolicyd", "none", &full_logs()),
None
);
}
#[test]
fn a_fully_declared_agent_is_missing_nothing_the_contract_calls_executable() {
let full = full_logs();
for target in ["/var/log/a.log", "/var/log/containers/*.log"] {
assert_eq!(
missing("FileGlob", target, "none", &full),
Some(Vec::new()),
"{target}"
);
}
for mode in MULTILINE_MODES {
assert_eq!(
missing("FileGlob", "/var/log/a.log", mode, &full),
Some(Vec::new()),
"{mode}"
);
}
for id in EXPORTER_IDS {
assert_eq!(
missing("Exporter", id, "none", &full),
Some(Vec::new()),
"{id}"
);
}
let filters = ContainerFilters {
namespace_in: vec!["prod".to_string()],
pod_not_in: vec!["debug-pod".to_string()],
name_in: vec!["mysql".to_string()],
name_not_in: vec!["fluent-bit".to_string()],
};
assert_eq!(
filters.used_dimensions(),
CONTAINER_FILTERS,
"`used_dimensions` 与 `CONTAINER_FILTERS` 漂了"
);
assert_eq!(
missing_log_capabilities(
&container_source("/var/log/containers/*.log", filters),
&full
),
Some(Vec::new())
);
}
#[test]
fn every_filter_dimension_alone_pins_is_empty_serialization_and_capability_name() {
let cases: [(ContainerFilters, &'static str); 4] = [
(
ContainerFilters {
namespace_in: vec!["prod".to_string()],
..Default::default()
},
FILTER_NAMESPACE_IN,
),
(
ContainerFilters {
pod_not_in: vec!["debug-pod".to_string()],
..Default::default()
},
FILTER_POD_NOT_IN,
),
(
ContainerFilters {
name_in: vec!["mysql".to_string()],
..Default::default()
},
FILTER_NAME_IN,
),
(
ContainerFilters {
name_not_in: vec!["fluent-bit".to_string()],
..Default::default()
},
FILTER_NAME_NOT_IN,
),
];
for (filters, dimension) in cases {
assert!(
!filters.is_empty(),
"{dimension} 非空时 is_empty 必须为 false"
);
assert_eq!(filters.used_dimensions(), vec![dimension], "{dimension}");
let json = serde_json::to_value(&filters).expect("serialize");
let object = json.as_object().expect("object");
assert_eq!(
object.keys().map(String::as_str).collect::<Vec<_>>(),
vec![dimension],
"{dimension}:序列化键名必须与能力名逐字一致"
);
assert_eq!(
object[dimension].as_array().map(Vec::len),
Some(1),
"{dimension}:该维的值要完整发出去"
);
}
assert!(ContainerFilters::default().is_empty());
assert_eq!(
serde_json::to_value(ContainerFilters::default()).expect("serialize"),
serde_json::json!({})
);
}
fn spec() -> WorkSpec {
WorkSpec {
units: vec![
WorkSpecUnit {
unit_id: "mac-host-metrics".to_string(),
capability: "collect_metrics".to_string(),
rule_ref: "agent_uplink".to_string(),
requires_privilege: "none".to_string(),
sources: vec![WorkSpecSource {
kind: "MetricInterval".to_string(),
target: "15s".to_string(),
multiline: "none".to_string(),
exclude_globs: Vec::new(),
filters: ContainerFilters::default(),
drop_if_contains: Vec::new(),
}],
},
WorkSpecUnit {
unit_id: "mac-privacy-tcc".to_string(),
capability: "collect_logs".to_string(),
rule_ref: "macos/tcc".to_string(),
requires_privilege: "fda".to_string(),
sources: vec![
WorkSpecSource {
kind: "Exporter".to_string(),
target: "sqlite-snapshot(TCC.db)".to_string(),
multiline: "none".to_string(),
exclude_globs: Vec::new(),
filters: ContainerFilters::default(),
drop_if_contains: Vec::new(),
},
WorkSpecSource {
kind: "FileGlob".to_string(),
target: "/var/log/tccd.log".to_string(),
multiline: "indented".to_string(),
exclude_globs: Vec::new(),
filters: ContainerFilters::default(),
drop_if_contains: Vec::new(),
},
],
},
],
}
}
#[test]
fn spec_round_trips_and_reports_what_agentd_can_do() {
let encoded = spec().encode().expect("encode");
let decoded = WorkSpec::parse(&encoded).expect("parse");
assert_eq!(decoded, spec());
assert!(decoded.collects_logs());
assert_eq!(decoded.metric_interval_seconds(), Some(15));
let files = decoded.units[1].file_sources();
assert_eq!(files.len(), 1);
assert_eq!(files[0].target, "/var/log/tccd.log");
assert_eq!(files[0].multiline, "indented");
assert_eq!(decoded.units[1].unsupported_sources().len(), 1);
assert_eq!(decoded.units[1].unsupported_sources()[0].kind, "Exporter");
}
#[test]
fn a_glob_file_source_is_collectable_but_recursive_globs_are_not() {
let mut unit = WorkSpecUnit {
unit_id: "linux-container-stdout".to_string(),
capability: "collect_logs".to_string(),
rule_ref: String::new(),
requires_privilege: "root".to_string(),
sources: vec![WorkSpecSource {
kind: "FileGlob".to_string(),
target: "/var/log/containers/*.log".to_string(),
multiline: "none".to_string(),
exclude_globs: vec!["/var/log/containers/*_kube-system_*.log".to_string()],
filters: ContainerFilters::default(),
drop_if_contains: Vec::new(),
}],
};
assert_eq!(unit.file_sources().len(), 1, "它是一条文件来源");
assert!(unit.unsupported_sources().is_empty());
assert!(unit.has_executable_source());
unit.sources[0].target = "/var/log/**/*.log".to_string();
assert_eq!(unit.unsupported_sources().len(), 1);
assert!(!unit.has_executable_source());
}
#[test]
fn exclude_globs_round_trip_and_stay_out_of_the_wire_when_empty() {
let mut source = WorkSpecSource {
kind: "FileGlob".to_string(),
target: "/var/log/containers/*.log".to_string(),
multiline: "none".to_string(),
exclude_globs: Vec::new(),
filters: ContainerFilters::default(),
drop_if_contains: Vec::new(),
};
assert_eq!(
serde_json::to_string(&source).expect("serialize"),
r#"{"kind":"FileGlob","target":"/var/log/containers/*.log","multiline":"none"}"#
);
source.exclude_globs = vec!["/var/log/containers/*_kube-system_*.log".to_string()];
let json = serde_json::to_string(&source).expect("serialize");
assert_eq!(
serde_json::from_str::<WorkSpecSource>(&json).expect("parse"),
source
);
let old = r#"{"kind":"FileGlob","target":"/a/*"}"#;
let parsed: WorkSpecSource = serde_json::from_str(old).expect("parse legacy source");
assert!(parsed.exclude_globs.is_empty());
assert!(parsed.filters.is_empty());
assert_eq!(parsed.multiline, "none");
}
#[test]
fn container_filters_round_trip_and_stay_out_of_the_wire_when_empty() {
let mut source = WorkSpecSource {
kind: "FileGlob".to_string(),
target: "/var/log/containers/*.log".to_string(),
multiline: "none".to_string(),
exclude_globs: Vec::new(),
filters: ContainerFilters::default(),
drop_if_contains: Vec::new(),
};
let json = serde_json::to_string(&source).expect("serialize");
assert!(!json.contains("filters"), "{json}");
source.filters = ContainerFilters {
namespace_in: vec!["prod".to_string()],
pod_not_in: vec!["debug-pod".to_string()],
name_in: Vec::new(),
name_not_in: Vec::new(),
};
let json = serde_json::to_string(&source).expect("serialize");
assert!(
json.contains(r#""filters":{"namespace_in":["prod"],"pod_not_in":["debug-pod"]}"#),
"{json}"
);
assert_eq!(
serde_json::from_str::<WorkSpecSource>(&json).expect("parse"),
source
);
let half = r#"{"kind":"FileGlob","target":"/var/log/containers/*.log","filters":{"namespace_in":["prod"]}}"#;
let parsed: WorkSpecSource = serde_json::from_str(half).expect("parse half filters");
assert_eq!(parsed.filters.namespace_in, vec!["prod".to_string()]);
assert!(parsed.filters.pod_not_in.is_empty());
}
#[test]
fn an_explicitly_empty_filters_object_is_not_serialized() {
let source = WorkSpecSource {
kind: "FileGlob".to_string(),
target: "/var/log/containers/*.log".to_string(),
multiline: "none".to_string(),
exclude_globs: Vec::new(),
filters: ContainerFilters {
namespace_in: Vec::new(),
pod_not_in: Vec::new(),
name_in: Vec::new(),
name_not_in: Vec::new(),
},
drop_if_contains: Vec::new(),
};
let json = serde_json::to_string(&source).expect("serialize");
assert!(!json.contains("filters"), "{json}");
}
#[test]
fn name_filters_are_a_capability_of_their_own() {
let include = ContainerFilters {
name_in: vec!["mysql".to_string()],
..ContainerFilters::default()
};
assert_eq!(include.used_dimensions(), vec![FILTER_NAME_IN]);
let exclude = ContainerFilters {
name_not_in: vec!["fluent-bit".to_string()],
..ContainerFilters::default()
};
assert_eq!(exclude.used_dimensions(), vec![FILTER_NAME_NOT_IN]);
assert!(
ContainerFilters {
name_in: vec!["mysql*".to_string()],
..ContainerFilters::default()
}
.validate()
.is_err()
);
assert!(
ContainerFilters {
name_not_in: vec![" ".to_string()],
..ContainerFilters::default()
}
.validate()
.is_err()
);
assert!(include.validate().is_ok());
}
#[test]
fn container_filters_keep_values_verbatim() {
let filters = ContainerFilters {
namespace_in: vec!["Prod".to_string()],
pod_not_in: vec!["debug-pod".to_string()],
name_in: Vec::new(),
name_not_in: Vec::new(),
};
let json = serde_json::to_string(&filters).expect("serialize");
let parsed: ContainerFilters = serde_json::from_str(&json).expect("parse");
assert_eq!(parsed.namespace_in, vec!["Prod".to_string()]);
assert_eq!(parsed.pod_not_in, vec!["debug-pod".to_string()]);
}
#[test]
fn container_filters_reject_unknown_keys() {
assert!(serde_json::from_str::<ContainerFilters>(r#"{"namespaces":["prod"]}"#).is_err());
assert!(serde_json::from_str::<ContainerFilters>(r#"{"namespace_in":["prod"]}"#).is_ok());
}
#[test]
fn container_filters_reject_values_that_can_never_match() {
let ok = |namespace: &[&str], pods: &[&str]| ContainerFilters {
namespace_in: namespace.iter().map(|v| v.to_string()).collect(),
pod_not_in: pods.iter().map(|v| v.to_string()).collect(),
name_in: Vec::new(),
name_not_in: Vec::new(),
};
assert!(
ok(&["prod", "kube-system"], &["debug-pod"])
.validate()
.is_ok()
);
assert!(ok(&[], &[]).is_empty());
assert!(ok(&[""], &[]).validate().is_err());
assert!(ok(&[" prod"], &[]).validate().is_err());
assert!(ok(&["prod*"], &[]).validate().is_err());
assert!(ok(&[], &["debug?pod"]).validate().is_err());
assert!(ok(&[&"x".repeat(254)], &[]).validate().is_err());
}
#[test]
fn a_source_without_multiline_decodes_as_single_line() {
let json = r#"{"units":[{"unit_id":"u","capability":"collect_logs","sources":[{"kind":"FileGlob","target":"/a/*"}]}]}"#;
let spec = WorkSpec::parse(json).expect("parse");
assert_eq!(spec.units[0].sources[0].multiline, "none");
assert_eq!(
spec.encode().expect("encode"),
r#"{"units":[{"unit_id":"u","capability":"collect_logs","rule_ref":"","requires_privilege":"","sources":[{"kind":"FileGlob","target":"/a/*","multiline":"none"}]}]}"#
);
}
#[test]
fn a_broken_spec_is_an_error_not_an_empty_work() {
assert!(WorkSpec::parse("mac-host-metrics").is_err());
assert!(WorkSpec::parse("{").is_err());
assert!(WorkSpec::parse(r#"{"units":[]}"#).unwrap().units.is_empty());
assert_eq!(
WorkSpec::parse(r#"{"units":[]}"#)
.unwrap()
.metric_interval_seconds(),
None
);
}
#[test]
fn interval_parsing_accepts_seconds_and_minutes_only() {
assert_eq!(parse_interval_seconds("15s"), Some(15));
assert_eq!(parse_interval_seconds(" 5s "), Some(5));
assert_eq!(parse_interval_seconds("5m"), Some(300));
assert_eq!(parse_interval_seconds("15"), None);
assert_eq!(parse_interval_seconds("0s"), None);
assert_eq!(parse_interval_seconds(""), None);
}
#[test]
fn interval_parsing_never_overflows_on_huge_values() {
assert_eq!(parse_interval_seconds("922337203685477580m"), None);
assert_eq!(parse_interval_seconds("153722867280912931m"), None);
assert_eq!(parse_interval_seconds("99999999999999999999s"), None);
assert_eq!(parse_interval_seconds("600m"), Some(36_000));
}
#[test]
fn metric_interval_ignores_unparseable_targets_instead_of_treating_them_as_zero() {
let unit = |target: &str| WorkSpecUnit {
unit_id: "u".to_string(),
capability: "collect_metrics".to_string(),
rule_ref: String::new(),
requires_privilege: String::new(),
sources: vec![WorkSpecSource {
kind: "MetricInterval".to_string(),
target: target.to_string(),
multiline: "none".to_string(),
exclude_globs: Vec::new(),
filters: ContainerFilters::default(),
drop_if_contains: Vec::new(),
}],
};
let mixed = WorkSpec {
units: vec![unit("garbage"), unit("30s")],
};
assert_eq!(mixed.metric_interval_seconds(), Some(30));
let all_bad = WorkSpec {
units: vec![unit("nope")],
};
assert_eq!(all_bad.metric_interval_seconds(), None);
}
}