From d69517847bf603948bd3cc8fa372342acd3e3a69 Mon Sep 17 00:00:00 2001 From: Xuewei Niu Date: Thu, 10 Jul 2025 16:01:04 +0800 Subject: [PATCH] systemd: Add unit test cases The tests cover `SystemdClient` and methods from `utils` mods. Please note that the `SystemdClient` tests do not run well in parallel, but work well in sequence. Please run them with `--test-threads=1`. Signed-off-by: Xuewei Niu --- Cargo.toml | 1 + Makefile | 14 +- src/lib.rs | 43 +++++ src/systemd/dbus/client.rs | 380 +++++++++++++++++++++++++++++++++++++ src/systemd/utils.rs | 37 ++++ 5 files changed, 473 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f25b3bf..ffba0e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ bit-vec = "0.6" [dev-dependencies] libc = "0.2.76" +rand = "0.8" [features] default = [] diff --git a/Makefile b/Makefile index 89fe2a5..91a69d9 100644 --- a/Makefile +++ b/Makefile @@ -20,8 +20,18 @@ build: debug # .PHONY: test -test: - cargo test -- --color always --nocapture +test: test-systemd + cargo test -- --color always --nocapture \ + --skip systemd::dbus::client::tests + +.PHONY: test-systemd +# Tests that manipulate cgroups should run in sequence, so that +# `--test-threads=1` is used. +test-systemd: + cargo test --package cgroups-rs --lib \ + -- systemd::dbus::client::tests \ + --color always --nocapture \ + --test-threads=1 .PHONY: check check: fmt clippy diff --git a/src/lib.rs b/src/lib.rs index 86762b2..362f4b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -48,3 +48,46 @@ impl From<&std::process::Child> for CgroupPid { CgroupPid { pid: u.id() as u64 } } } + +#[cfg(test)] +pub mod tests { + use std::fs; + use std::process::{Child, Command, Stdio}; + + /// Start a mock subprocess that will sleep forever + pub fn spawn_sleep_inf() -> Child { + let child = Command::new("sleep") + .arg("infinity") + .spawn() + .expect("Failed to start mock subprocess"); + child + } + + pub fn spawn_yes() -> Child { + let devnull = fs::File::create("/dev/null").expect("cannot open /dev/null"); + let child = Command::new("yes") + .stdout(Stdio::from(devnull)) + .spawn() + .expect("Failed to start mock subprocess"); + child + } + + pub fn systemd_version() -> Option { + let output = Command::new("systemd").arg("--version").output().ok()?; // Return None if command execution fails + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + // The first line is typically like "systemd 254 (254.5-1-arch)" + let first_line = stdout.lines().next()?; + let mut words = first_line.split_whitespace(); + + words.next()?; // Skip the "systemd" word + let version_str = words.next()?; // The version number as string + + version_str.parse::().ok() + } +} diff --git a/src/systemd/dbus/client.rs b/src/systemd/dbus/client.rs index 27305f0..8bbe245 100644 --- a/src/systemd/dbus/client.rs +++ b/src/systemd/dbus/client.rs @@ -190,3 +190,383 @@ fn ignore_no_such_unit(result: ZbusResult) -> ZbusResult { } result.map(|_| false) } + +#[cfg(test)] +pub mod tests { + //! Unit tests for the SystemdClient + //! + //! Not sure why the tests are going to fail if we run them in + //! parallel. Everything goes smoothly in serial. + //! + //! $ cargo test --package cgroups-rs --lib \ + //! -- systemd::dbus::client::tests \ + //! --show-output --test-threads=1 + + use std::fs; + use std::path::Path; + use std::process::Command; + use std::thread::sleep; + use std::time::Duration; + + use rand::distributions::Alphanumeric; + use rand::Rng; + + use crate::fs::hierarchies; + use crate::systemd::dbus::client::*; + use crate::systemd::props::PropertiesBuilder; + use crate::systemd::utils::expand_slice; + use crate::systemd::{DEFAULT_DESCRIPTION, DESCRIPTION, PIDS}; + use crate::tests::{spawn_sleep_inf, spawn_yes, systemd_version}; + + const TEST_SLICE: &str = "cgroupsrs-test.slice"; + + fn test_unit() -> String { + let rand_string: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(5) + .map(char::from) + .collect(); + format!("cri-pod{}.scope", rand_string) + } + + #[macro_export] + macro_rules! skip_if_no_systemd { + () => { + if $crate::tests::systemd_version().is_none() { + eprintln!("Test skipped, no systemd?"); + return; + } + }; + } + + fn systemd_show(unit: &str) -> String { + let output = Command::new("systemctl") + .arg("show") + .arg(unit) + .output() + .expect("Failed to execute systemctl show command"); + String::from_utf8_lossy(&output.stdout).to_string() + } + + fn start_default_cgroup(pid: CgroupPid, unit: &str) -> SystemdClient { + let mut props = PropertiesBuilder::default_cgroup(TEST_SLICE, unit).build(); + props.push((PIDS, Value::Array(vec![pid.pid as u32].into()))); + let cgroup = SystemdClient::new(unit, props).unwrap(); + // Stop the unit if it exists. + cgroup.stop().unwrap(); + + // Write the current process to the cgroup. + cgroup.start().unwrap(); + cgroup.add_process(pid, "/").unwrap(); + cgroup + } + + fn stop_cgroup(cgroup: &SystemdClient) { + cgroup.stop().unwrap(); + } + + #[test] + fn test_start() { + skip_if_no_systemd!(); + + let v2 = hierarchies::is_cgroup2_unified_mode(); + let unit = test_unit(); + let mut child = spawn_sleep_inf(); + let cgroup = start_default_cgroup(CgroupPid::from(child.id() as u64), &unit); + + let base = expand_slice(TEST_SLICE).unwrap(); + + // Check if the cgroup exists in the filesystem + let full_base = if v2 { + format!("/sys/fs/cgroup/{}", base) + } else { + format!("/sys/fs/cgroup/memory/{}", base) + }; + assert!( + Path::new(&full_base).exists(), + "Cgroup base path does not exist: {}", + full_base + ); + + // PIDs + let cgroup_procs_path = format!("{}/{}/cgroup.procs", full_base, &unit); + for i in 0..5 { + let content = fs::read_to_string(&cgroup_procs_path); + if let Ok(content) = &content { + if content.contains(&child.id().to_string()) { + break; + } + } + // Retry attempts exhausted, resulting in failure + if i == 4 { + let content = content.as_ref().unwrap(); + assert!( + content.contains(&child.id().to_string()), + "Cgroup procs does not contain the child process ID" + ); + } + // Wait 500ms before next retrying + sleep(Duration::from_millis(500)); + } + + // Check the unit from "systemctl show " + let output = systemd_show(&cgroup.unit); + + // Slice + assert!( + output + .lines() + .any(|line| line == format!("Slice={}", TEST_SLICE)), + "Slice not found" + ); + // Delegate + assert!( + output.lines().any(|line| line == "Delegate=yes"), + "Delegate not set" + ); + // DelegateControllers + // controllers: cpu cpuacct cpuset io blkio memory devices pids + let controllers = output + .lines() + .find(|line| line.starts_with("DelegateControllers=")) + .map(|line| line.trim_start_matches("DelegateControllers=")) + .unwrap(); + let controllers = controllers.split(' ').collect::>(); + assert!( + controllers.contains(&"cpu"), + "DelegateControllers cpu not set" + ); + assert!( + controllers.contains(&"cpuset"), + "DelegateControllers cpuset not set" + ); + if v2 { + assert!( + controllers.contains(&"io"), + "DelegateControllers io not set" + ); + } else { + assert!( + controllers.contains(&"blkio"), + "DelegateControllers blkio not set" + ); + } + assert!( + controllers.contains(&"memory"), + "DelegateControllers memory not set" + ); + assert!( + controllers.contains(&"pids"), + "DelegateControllers pids not set" + ); + + // CPUAccounting + assert!( + output.lines().any(|line| line == "CPUAccounting=yes"), + "CPUAccounting not set" + ); + // IOAccounting for v2, and BlockIOAccounting for v1 + if v2 { + assert!( + output.lines().any(|line| line == "IOAccounting=yes"), + "IOAccounting not set" + ); + } else { + assert!( + output.lines().any(|line| line == "BlockIOAccounting=yes"), + "BlockIOAccounting not set" + ); + } + // MemoryAccounting + assert!( + output.lines().any(|line| line == "MemoryAccounting=yes"), + "MemoryAccounting not set" + ); + // TasksAccounting + assert!( + output.lines().any(|line| line == "TasksAccounting=yes"), + "TasksAccounting not set" + ); + // ActiveState + assert!( + output.lines().any(|line| line == "ActiveState=active"), + "Unit is not active" + ); + + stop_cgroup(&cgroup); + child.wait().unwrap(); + } + + #[test] + fn test_stop() { + skip_if_no_systemd!(); + + let unit = test_unit(); + let mut child = spawn_sleep_inf(); + let cgroup = start_default_cgroup(CgroupPid::from(child.id() as u64), &unit); + + // Check ActiveState: expected to be "active" + let output = systemd_show(&cgroup.unit); + assert!( + output.lines().any(|line| line == "ActiveState=active"), + "Unit is not active" + ); + + stop_cgroup(&cgroup); + + // Check ActiveState: expected to be "inactive" + let output = systemd_show(&cgroup.unit); + assert!( + output.lines().any(|line| line == "ActiveState=inactive"), + "Unit is not inactive" + ); + + child.wait().unwrap(); + } + + #[test] + fn test_set_properties() { + skip_if_no_systemd!(); + + let unit = test_unit(); + let mut child = spawn_sleep_inf(); + let mut cgroup = start_default_cgroup(CgroupPid::from(child.id() as u64), &unit); + + let output = systemd_show(&cgroup.unit); + assert!( + output.lines().any(|line| line + == format!( + "Description={} {}:{}", + DEFAULT_DESCRIPTION, TEST_SLICE, unit + )), + "Initial description not set correctly" + ); + + let properties = [( + DESCRIPTION, + Value::Str("kata-container1 description".into()), + )]; + cgroup.set_properties(&properties).unwrap(); + assert!(cgroup.props.iter().any(|(k, v)| { + k == &DESCRIPTION && v == &Value::Str("kata-container1 description".into()) + })); + + let output = systemd_show(&cgroup.unit); + assert!( + output + .lines() + .any(|line| line == "Description=kata-container1 description"), + "Updated description not set correctly" + ); + + stop_cgroup(&cgroup); + child.wait().unwrap(); + } + + #[test] + fn test_freeze_and_thaw() { + skip_if_no_systemd!(); + + let unit = test_unit(); + let mut child = spawn_yes(); + let cgroup = start_default_cgroup(CgroupPid::from(child.id() as u64), &unit); + + // Freeze the unit + cgroup.freeze().unwrap(); + + let pid = child.id() as u64; + + let stat_path = format!("/proc/{}/stat", pid); + let content = fs::read_to_string(&stat_path).unwrap(); + // The process state is the third field, e.g.: + // 1234 (bash) S 1233 ... + // ^ + let mut content_iter = content.split_whitespace(); + assert_eq!( + content_iter.nth(2).unwrap(), + "S", + "Process should be in 'S' (sleeping) state after freezing" + ); + + // Thaw the unit + cgroup.thaw().unwrap(); + + // No more S now + let content = fs::read_to_string(&stat_path).unwrap(); + let mut content_iter = content.split_whitespace(); + assert_ne!( + content_iter.nth(2).unwrap(), + "S", + "Process should not be in 'S' (sleeping) state after thawing" + ); + + stop_cgroup(&cgroup); + child.wait().unwrap(); + } + + #[test] + fn test_systemd_version() { + skip_if_no_systemd!(); + + let unit = test_unit(); + let props = PropertiesBuilder::default_cgroup(TEST_SLICE, &unit).build(); + let cgroup = SystemdClient::new(&unit, props).unwrap(); + let version = cgroup.systemd_version().unwrap(); + + let expected_version = systemd_version().unwrap(); + assert_eq!(version, expected_version, "Systemd version mismatch"); + } + + #[test] + fn test_exists() { + skip_if_no_systemd!(); + + let unit = test_unit(); + let mut child = spawn_sleep_inf(); + let cgroup = start_default_cgroup(CgroupPid::from(child.id() as u64), &unit); + + assert!(cgroup.exists(), "Cgroup should exist after starting"); + + stop_cgroup(&cgroup); + child.wait().unwrap(); + } + + #[test] + fn test_add_process() { + skip_if_no_systemd!(); + + let unit = test_unit(); + let mut child = spawn_sleep_inf(); + let cgroup = start_default_cgroup(CgroupPid::from(child.id() as u64), &unit); + + let mut child1 = spawn_sleep_inf(); + let pid1 = CgroupPid::from(child1.id() as u64); + cgroup.add_process(pid1, "/").unwrap(); + + let cgroup_procs_path = format!( + "/sys/fs/cgroup/{}/{}/cgroup.procs", + expand_slice(TEST_SLICE).unwrap(), + unit + ); + for i in 0..5 { + let content = fs::read_to_string(&cgroup_procs_path); + if let Ok(content) = content { + assert!( + content.contains(&child1.id().to_string()), + "Cgroup procs does not contain the child1 process ID" + ); + break; + } + // Retry attempts exhausted, resulting in failure + if i == 4 { + content.unwrap(); + } + // Wait 500ms before next retrying + sleep(Duration::from_millis(500)); + } + + stop_cgroup(&cgroup); + child.wait().unwrap(); + child1.wait().unwrap(); + } +} diff --git a/src/systemd/utils.rs b/src/systemd/utils.rs index c654c59..0eec806 100644 --- a/src/systemd/utils.rs +++ b/src/systemd/utils.rs @@ -62,3 +62,40 @@ pub fn expand_slice(slice: &str) -> Result { Ok(slice_path) } + +#[cfg(test)] +mod tests { + use crate::systemd::utils::*; + + #[test] + fn test_is_slice_unit() { + assert!(is_slice_unit("test.slice")); + assert!(!is_slice_unit("test.scope")); + } + + #[test] + fn test_is_scope_unit() { + assert!(is_scope_unit("test.scope")); + assert!(!is_scope_unit("test.slice")); + } + + #[test] + fn test_expand_slice() { + assert_eq!(expand_slice("test.slice").unwrap(), "test.slice"); + assert_eq!( + expand_slice("test-1.slice").unwrap(), + "test.slice/test-1.slice" + ); + assert_eq!( + expand_slice("test-1-test-2.slice").unwrap(), + "test.slice/test-1.slice/test-1-test.slice/test-1-test-2.slice" + ); + assert_eq!( + expand_slice("slice-slice.slice").unwrap(), + "slice.slice/slice-slice.slice" + ); + assert_eq!(expand_slice("-.slice").unwrap(), ""); + assert!(expand_slice("invalid/slice").is_err()); + assert!(expand_slice("invalid-slice").is_err()); + } +}