use anyhow::{anyhow, Result}; use async_trait::async_trait; use bread_shared::{now_unix_ms, AdapterSource, RawEvent}; use futures_util::StreamExt; use serde_json::json; use std::collections::HashMap; use tokio::sync::mpsc; use tracing::{debug, info, warn}; use zbus::zvariant::{OwnedObjectPath, OwnedValue}; use zbus::{Connection, Message, MessageStream}; use super::Adapter; const MANAGER_DEST: &str = "org.freedesktop.systemd1"; const MANAGER_PATH: &str = "/org/freedesktop/systemd1"; const MANAGER_IFACE: &str = "org.freedesktop.systemd1.Manager"; const UNIT_IFACE: &str = "org.freedesktop.systemd1.Unit"; const PROPS_IFACE: &str = "org.freedesktop.DBus.Properties"; /// Watches an allowlist of `systemd --user` units on the session bus and emits /// start/stop/failure lifecycle events. /// /// Only units named in the allowlist are tracked — subscribing to every user /// unit's transitions is noisy (timers, transient scopes, etc. fire /// constantly), so we filter down to what the user's config explicitly named. #[derive(Clone, Debug)] pub struct SystemdAdapter { units: Vec, } impl SystemdAdapter { pub fn new(units: Vec) -> Self { Self { units } } } #[async_trait] impl Adapter for SystemdAdapter { fn name(&self) -> &'static str { "systemd" } async fn run(&self, tx: mpsc::Sender) -> Result<()> { info!("systemd adapter starting"); let conn = Connection::session() .await .map_err(|e| anyhow!("systemd session bus unavailable: {e}"))?; // Job/property signals aren't delivered until a client asks the manager // to start tracking them. conn.call_method( Some(MANAGER_DEST), MANAGER_PATH, Some(MANAGER_IFACE), "Subscribe", &(), ) .await .map_err(|e| anyhow!("systemd Manager.Subscribe failed: {e}"))?; // Resolve each allowlisted unit name to its object path up front, so // PropertiesChanged messages (which arrive addressed by path, not name) // can be matched back to a unit without a lookup on every message. A // unit that fails to resolve (not currently loaded, typo'd name, etc.) // is skipped rather than failing the whole adapter — it simply won't be // watched for `unit.failed` until the adapter restarts. let mut path_to_unit: HashMap = HashMap::new(); for unit in &self.units { match get_unit_path(&conn, unit).await { Ok(path) => { debug!("systemd resolved unit '{unit}' -> {path}"); path_to_unit.insert(path, unit.clone()); } Err(e) => { warn!("systemd: could not resolve unit '{unit}' (not loaded?): {e}"); } } } let mut stream = MessageStream::from(&conn); while let Some(result) = stream.next().await { match result { Ok(message) => { if let Some(event) = handle_message(&conn, &message, &self.units, &path_to_unit).await { if tx.send(event).await.is_err() { return Ok(()); } } } Err(e) => debug!("systemd stream error: {e}"), } } Ok(()) } } /// Resolve a unit name to its `/org/freedesktop/systemd1/unit/...` object path /// via `Manager.GetUnit`. async fn get_unit_path(conn: &Connection, unit_name: &str) -> Result { let msg = conn .call_method( Some(MANAGER_DEST), MANAGER_PATH, Some(MANAGER_IFACE), "GetUnit", &(unit_name,), ) .await?; let path: OwnedObjectPath = msg.body()?; Ok(path.as_str().to_string()) } /// Read the current `ActiveState` property (`"active"`, `"inactive"`, /// `"failed"`, ...) off a resolved unit object path. async fn query_active_state(conn: &Connection, unit_path: &str) -> Option { let msg = conn .call_method( Some(MANAGER_DEST), unit_path, Some(PROPS_IFACE), "Get", &(UNIT_IFACE, "ActiveState"), ) .await .ok()?; let value: OwnedValue = msg.body().ok()?; serde_json::to_value(&value) .ok()? .as_str() .map(|s| s.to_string()) } async fn handle_message( conn: &Connection, message: &Message, units: &[String], path_to_unit: &HashMap, ) -> Option { let header = message.header().ok()?; let interface = header.interface().ok()??.as_str().to_string(); let member = header.member().ok()??.as_str().to_string(); let path = header .path() .ok() .flatten() .map(|p| p.as_str().to_string()) .unwrap_or_default(); // Start/stop: a job affecting one of our allowlisted units has completed. // JobRemoved alone doesn't say whether the job was a start or a stop (or // what it settled on), so we re-query ActiveState once the job is done to // find out what actually happened. "failed" is deliberately left to the // PropertiesChanged branch below, so each transition is only emitted once. if interface == MANAGER_IFACE && member == "JobRemoved" { let (_id, _job_path, unit_name, _result): (u32, OwnedObjectPath, String, String) = message.body().ok()?; if !units.iter().any(|u| u == &unit_name) { return None; } let unit_path = get_unit_path(conn, &unit_name).await.ok()?; let state = query_active_state(conn, &unit_path).await?; let kind = active_state_to_kind(&state)?; return Some(RawEvent { source: AdapterSource::Systemd, kind: kind.to_string(), payload: json!({ "unit": unit_name }), timestamp: now_unix_ms(), }); } // Failed: ActiveState flipped to "failed" on a unit we resolved at startup. // This covers unit failures that occur without an explicit job completing // from our point of view (e.g. a crash detected asynchronously). if interface == PROPS_IFACE && member == "PropertiesChanged" { let unit_name = path_to_unit.get(&path)?; let (iface, changed, _invalidated): (String, HashMap, Vec) = message.body().ok()?; if iface != UNIT_IFACE { return None; } let changed_json = serde_json::to_value(&changed).ok()?; if !is_failed_transition(&changed_json) { return None; } let result = failure_result(&changed_json); return Some(RawEvent { source: AdapterSource::Systemd, kind: "unit.failed".to_string(), payload: json!({ "unit": unit_name, "result": result }), timestamp: now_unix_ms(), }); } None } /// Map a unit's `ActiveState` to the lifecycle event kind it represents. /// `"failed"` is intentionally excluded — that transition is reported via the /// dedicated `PropertiesChanged` branch instead, so it isn't double-reported /// once here and once there. Intermediate states (`activating`, `deactivating`, /// `reloading`) aren't a resting state yet, so they're ignored too. fn active_state_to_kind(state: &str) -> Option<&'static str> { match state { "active" => Some("unit.started"), "inactive" | "dead" => Some("unit.stopped"), _ => None, } } /// Whether a decoded `PropertiesChanged` payload represents a transition into /// the `failed` active state. fn is_failed_transition(changed: &serde_json::Value) -> bool { changed .get("ActiveState") .and_then(|v| v.as_str()) .map(|s| s == "failed") .unwrap_or(false) } /// Extract the `Result` property (e.g. `"exit-code"`, `"timeout"`) from a /// decoded `PropertiesChanged` payload, if it was included in this batch. fn failure_result(changed: &serde_json::Value) -> Option { changed .get("Result") .and_then(|v| v.as_str()) .map(|s| s.to_string()) } #[cfg(test)] mod tests { use super::*; #[test] fn active_state_to_kind_maps_active_to_started() { assert_eq!(active_state_to_kind("active"), Some("unit.started")); } #[test] fn active_state_to_kind_maps_inactive_and_dead_to_stopped() { assert_eq!(active_state_to_kind("inactive"), Some("unit.stopped")); assert_eq!(active_state_to_kind("dead"), Some("unit.stopped")); } #[test] fn active_state_to_kind_excludes_failed() { // Failed transitions are reported via PropertiesChanged instead, so // JobRemoved handling must not also emit for this state. assert_eq!(active_state_to_kind("failed"), None); } #[test] fn active_state_to_kind_ignores_transitional_states() { assert_eq!(active_state_to_kind("activating"), None); assert_eq!(active_state_to_kind("deactivating"), None); assert_eq!(active_state_to_kind("reloading"), None); } #[test] fn is_failed_transition_detects_failed_active_state() { let changed = json!({ "ActiveState": "failed", "SubState": "failed" }); assert!(is_failed_transition(&changed)); } #[test] fn is_failed_transition_ignores_other_active_states() { let changed = json!({ "ActiveState": "active" }); assert!(!is_failed_transition(&changed)); } #[test] fn is_failed_transition_ignores_unrelated_property_changes() { // A PropertiesChanged batch that doesn't touch ActiveState at all // (e.g. just MemoryCurrent ticking) must not be treated as a failure. let changed = json!({ "MemoryCurrent": 12345 }); assert!(!is_failed_transition(&changed)); } #[test] fn failure_result_extracts_reason_when_present() { let changed = json!({ "ActiveState": "failed", "Result": "exit-code" }); assert_eq!(failure_result(&changed), Some("exit-code".to_string())); } #[test] fn failure_result_is_none_when_absent() { let changed = json!({ "ActiveState": "failed" }); assert_eq!(failure_result(&changed), None); } }