diff --git a/proxy_agent_extension/src/constants.rs b/proxy_agent_extension/src/constants.rs index 1e8ffb24..a273d4ad 100644 --- a/proxy_agent_extension/src/constants.rs +++ b/proxy_agent_extension/src/constants.rs @@ -72,8 +72,11 @@ pub const MAX_TIME_BEFORE_STALE_STATUS_SECS: u64 = 5 * 60; pub const EBPF_CORE: &str = "EbpfCore"; pub const EBPF_EXT: &str = "NetEbpfExt"; +pub const EBPF_SVC: &str = "eBPFSvc"; pub const EBPF_SUBSTATUS_NAME: &str = "EbpfStatus"; +pub const PROXY_AGENT_SERVICE_SUBSTATUS_NAME: &str = "ProxyAgentServiceStatus"; + pub const MAX_CONNECTION_SUMMARY_LEN: usize = 100; pub const MAX_FAILED_AUTH_SUMMARY_LEN: usize = 50; // Max KB of substatus string for connection summary and failed authentication summary diff --git a/proxy_agent_extension/src/service_main.rs b/proxy_agent_extension/src/service_main.rs index 7205b70f..8baaa7c1 100644 --- a/proxy_agent_extension/src/service_main.rs +++ b/proxy_agent_extension/src/service_main.rs @@ -212,6 +212,10 @@ async fn monitor_thread() { let mut restored_in_error = false; let mut proxy_agent_update_reported: Option = None; let loop_interval = Duration::from_secs(15); + // Last known timestamp (as reported by the GPA aggregate status itself) used to annotate + // the immediate eBPF/GPA-service overrides below; kept from the previous iteration whenever + // the current iteration's fetch fails outright. + let mut last_known_status_timestamp = String::new(); loop { let current_seq_no: String = common::get_current_seq_no(&exe_path); @@ -287,13 +291,16 @@ async fn monitor_thread() { } // Step 3: Read and evaluate the proxy agent aggregate status - report_proxy_agent_aggregate_status( + if let Some(status_timestamp) = report_proxy_agent_aggregate_status( &proxy_agent_file_version_in_extension, &mut status, &mut status_state_obj, &mut service_state, ) - .await; + .await + { + last_known_status_timestamp = status_timestamp; + } // Step 4: Restore (on error) or purge (on success) the backed-up proxy agent, once if !restored_in_error { @@ -313,13 +320,41 @@ async fn monitor_thread() { proxy_agent_update_reported = None; } - // Step 6: Report eBPF driver status (Windows only) + // Step 6: Report eBPF (Windows only) and GuestProxyAgent service (cross-platform) + // runtime status, computed fresh every loop_interval tick (same cadence as everything + // else in this loop, currently 15s) so the status file always reflects the current + // machine state. + // + // Step 7: Apply an immediate top-level status/message override when eBPF (Windows, + // highest priority) or the GuestProxyAgent service itself (both platforms) is + // unhealthy. This is still necessary even though the substatus data above is always + // fresh: the top-level status/message is derived from a *different* source (the GPA + // aggregate status file/wire-server response via Step 3), which has its own 5-minute + // staleness threshold and 20-iteration debounce designed to avoid flapping on transient + // network blips - it has no visibility into eBPF or the GuestProxyAgent service at all. + // Without this override, a locally-confirmed eBPF/service failure would still take + // several minutes to surface at the top level; see `apply_service_health_overrides`. + let gpa_service_substatus = compute_gpa_service_substatus(); #[cfg(windows)] { - report_ebpf_status(&mut status); + let ebpf_substatus = compute_ebpf_substatus(); + apply_service_health_overrides( + &mut status, + &ebpf_substatus, + &gpa_service_substatus, + &last_known_status_timestamp, + ); + status.substatus.push(ebpf_substatus); } + #[cfg(not(windows))] + apply_gpa_service_status_override( + &mut status, + &gpa_service_substatus, + &last_known_status_timestamp, + ); + status.substatus.push(gpa_service_substatus); - // Step 7: Write the final status file and sleep + // Step 8: Write the final status file and sleep common::report_status( status_folder_path.to_path_buf(), &cache_seq_no.to_string(), @@ -354,58 +389,48 @@ fn write_state_event( fn build_ebpf_substatus( core: &proxy_agent_shared::service::ServiceStatusInfo, ext: &proxy_agent_shared::service::ServiceStatusInfo, + svc: &proxy_agent_shared::service::ServiceStatusInfo, ) -> SubStatus { - use proxy_agent_shared::service::ServiceState; - - let (status, code, message) = match (&core.state, &ext.state) { - (Some(core_state), Some(ext_state)) => { - let both_running = - *core_state == ServiceState::Running && *ext_state == ServiceState::Running; - if both_running { - ( - constants::SUCCESS_STATUS.to_string(), - constants::STATUS_CODE_OK, - format!( - "EbpfCore: {}, NetEbpfExt: {}", - core.summary(), - ext.summary() - ), - ) - } else { - ( - constants::ERROR_STATUS.to_string(), - constants::STATUS_CODE_NOT_OK, - format!( - "EbpfCore: {}, NetEbpfExt: {}", - core.summary(), - ext.summary() - ), - ) - } - } - (None, None) => ( - constants::ERROR_STATUS.to_string(), - constants::STATUS_CODE_NOT_OK, - "EbpfCore: unsuccessfully queried, NetEbpfExt: unsuccessfully queried.".to_string(), - ), - (None, _) => ( - constants::ERROR_STATUS.to_string(), - constants::STATUS_CODE_NOT_OK, - format!( - "EbpfCore: unsuccessfully queried, NetEbpfExt: {}", - ext.summary() - ), - ), - (_, None) => ( + use proxy_agent_shared::service::classify_service_state; + + let (core_running, core_transitioning) = classify_service_state(core.state.as_ref()); + let (ext_running, ext_transitioning) = classify_service_state(ext.state.as_ref()); + let (svc_running, svc_transitioning) = classify_service_state(svc.state.as_ref()); + + let all_running = core_running && ext_running && svc_running; + // "Down" means confirmed not-running and not actively transitioning toward Running. + let any_down = (!core_running && !core_transitioning) + || (!ext_running && !ext_transitioning) + || (!svc_running && !svc_transitioning); + + let (status, code) = if all_running { + ( + constants::SUCCESS_STATUS.to_string(), + constants::STATUS_CODE_OK, + ) + } else if any_down { + ( constants::ERROR_STATUS.to_string(), constants::STATUS_CODE_NOT_OK, - format!( - "EbpfCore: {}, NetEbpfExt: unsuccessfully queried.", - core.summary() - ), - ), + ) + } else { + // None are confirmed down, but at least one is still starting up (StartPending / + // ContinuePending) - a normal, usually brief condition during boot or a restart. + // Report Transitioning instead of Error so the immediate top-level override + // (`apply_ebpf_status_override`) does not fire on this benign condition. + ( + constants::TRANSITIONING_STATUS.to_string(), + constants::STATUS_CODE_OK, + ) }; + let message = format!( + "EbpfCore: {}, NetEbpfExt: {}, eBPFSvc: {}", + core.summary(), + ext.summary(), + svc.summary() + ); + SubStatus { name: constants::EBPF_SUBSTATUS_NAME.to_string(), status, @@ -418,16 +443,133 @@ fn build_ebpf_substatus( } #[cfg(windows)] -fn report_ebpf_status(status_obj: &mut StatusObj) { +fn compute_ebpf_substatus() -> SubStatus { let core_status = service::check_service_status(constants::EBPF_CORE); logger::write(format!("check_service_status: {}", core_status.message())); let ext_status = service::check_service_status(constants::EBPF_EXT); logger::write(format!("check_service_status: {}", ext_status.message())); - let mut substatus = status_obj.substatus.clone(); - substatus.push(build_ebpf_substatus(&core_status, &ext_status)); - status_obj.substatus = substatus; + let svc_status = service::check_service_status(constants::EBPF_SVC); + logger::write(format!("check_service_status: {}", svc_status.message())); + + build_ebpf_substatus(&core_status, &ext_status, &svc_status) +} + +/// Builds the cross-platform `ProxyAgentServiceStatus` substatus for the GuestProxyAgent +/// service itself (Windows SCM service or Linux systemd unit). +fn build_proxy_agent_service_substatus( + info: &proxy_agent_shared::service::ServiceRuntimeStatus, +) -> SubStatus { + let (status, code) = if info.is_running { + ( + constants::SUCCESS_STATUS.to_string(), + constants::STATUS_CODE_OK, + ) + } else if info.is_transitioning { + // Actively starting up (Windows StartPending/ContinuePending, or systemd + // "activating") - a normal, usually brief condition during boot or a restart. + // Report Transitioning instead of Error so the immediate top-level override + // (`apply_gpa_service_status_override`) does not fire on this benign condition. + ( + constants::TRANSITIONING_STATUS.to_string(), + constants::STATUS_CODE_OK, + ) + } else { + ( + constants::ERROR_STATUS.to_string(), + constants::STATUS_CODE_NOT_OK, + ) + }; + + SubStatus { + name: constants::PROXY_AGENT_SERVICE_SUBSTATUS_NAME.to_string(), + status, + code, + formattedMessage: FormattedMessage { + lang: constants::LANG_EN_US.to_string(), + message: format!( + "{}: {}", + constants::PROXY_AGENT_SERVICE_NAME, + info.summary() + ), + }, + } +} + +fn compute_gpa_service_substatus() -> SubStatus { + let info = + proxy_agent_shared::service::check_service_run_status(constants::PROXY_AGENT_SERVICE_NAME); + logger::write(format!("check_service_run_status: {}", info.message())); + build_proxy_agent_service_substatus(&info) +} + +/// If `ebpf_substatus` reports Error, unconditionally overrides `status`'s top-level +/// status/code/message to surface the eBPF detail plus the last known status timestamp and the +/// current time. Bypasses the debounce state machine intentionally. Returns true if it applied +/// the override (used by the caller to give this priority over the GPA-service override). +#[cfg(windows)] +fn apply_ebpf_status_override( + status: &mut StatusObj, + ebpf_substatus: &SubStatus, + last_known_status_timestamp: &str, +) -> bool { + if ebpf_substatus.status != constants::ERROR_STATUS { + return false; + } + status.status = constants::ERROR_STATUS.to_string(); + status.code = constants::STATUS_CODE_NOT_OK; + status.formattedMessage.message = format!( + "{}. Last status timestamp: {}, Current time: {}", + ebpf_substatus.formattedMessage.message, + last_known_status_timestamp, + misc_helpers::get_current_utc_time() + ); + true +} + +/// If `gpa_service_substatus` reports Error, unconditionally overrides `status`'s top-level +/// status/code/message to surface the GuestProxyAgent service detail plus the last known status +/// timestamp and the current time. Bypasses the debounce state machine intentionally, mirroring +/// `apply_ebpf_status_override`. Cross-platform (Windows and Linux). Returns true if it applied +/// the override. +fn apply_gpa_service_status_override( + status: &mut StatusObj, + gpa_service_substatus: &SubStatus, + last_known_status_timestamp: &str, +) -> bool { + if gpa_service_substatus.status != constants::ERROR_STATUS { + return false; + } + status.status = constants::ERROR_STATUS.to_string(); + status.code = constants::STATUS_CODE_NOT_OK; + status.formattedMessage.message = format!( + "{}. Last status timestamp: {}, Current time: {}", + gpa_service_substatus.formattedMessage.message, + last_known_status_timestamp, + misc_helpers::get_current_utc_time() + ); + true +} + +/// Applies the Windows-only priority ordering between the two immediate overrides: eBPF errors +/// win over GuestProxyAgent-service errors, since an unhealthy eBPF is frequently the underlying +/// reason the GuestProxyAgent service itself cannot start, making it the more specific/actionable +/// signal. Only falls through to the GPA-service override when eBPF itself did not report Error. +#[cfg(windows)] +fn apply_service_health_overrides( + status: &mut StatusObj, + ebpf_substatus: &SubStatus, + gpa_service_substatus: &SubStatus, + last_known_status_timestamp: &str, +) { + if !apply_ebpf_status_override(status, ebpf_substatus, last_known_status_timestamp) { + apply_gpa_service_status_override( + status, + gpa_service_substatus, + last_known_status_timestamp, + ); + } } fn backup_proxy_agent(setup_tool: &String) { @@ -533,12 +675,15 @@ async fn get_proxy_agent_aggregate_status( } } +/// Reads and evaluates the proxy agent aggregate status, returning the raw status timestamp +/// (formatted) it observed when the fetch succeeded at all, or `None` when the fetch failed +/// outright (callers should keep whatever timestamp they last observed in that case). async fn report_proxy_agent_aggregate_status( proxy_agent_file_version_in_extension: &String, status: &mut StatusObj, status_state_obj: &mut common::StatusState, service_state: &mut ServiceState, -) { +) -> Option { let proxy_agent_aggregate_status_top_level: GuestProxyAgentAggregateStatus; // Attempt to get the proxy agent aggregate status from the GPA Proxy Server. // If the GPA Proxy Server is not available, fall back to reading the status from the file. @@ -566,6 +711,10 @@ async fn report_proxy_agent_aggregate_status( service_state, ); proxy_agent_aggregate_status_top_level = proxy_agent_aggregate_status; + let status_timestamp = proxy_agent_aggregate_status_top_level + .get_status_timestamp() + .ok() + .map(|ts| ts.to_string()); extension_substatus( proxy_agent_aggregate_status_top_level, proxy_agent_file_version_in_extension, @@ -573,6 +722,7 @@ async fn report_proxy_agent_aggregate_status( status_state_obj, service_state, ); + status_timestamp } Err(e) => { let error_message = format!("{e}"); @@ -617,6 +767,7 @@ async fn report_proxy_agent_aggregate_status( }, ] }; + None } } } @@ -1220,7 +1371,7 @@ mod tests { #[tokio::test] #[cfg(windows)] - async fn test_report_ebpf_status() { + async fn test_compute_ebpf_substatus() { let mut status = make_test_status_obj( constants::SUCCESS_STATUS, constants::STATUS_CODE_OK, @@ -1256,7 +1407,7 @@ mod tests { }, ]; - super::report_ebpf_status(&mut status); + status.substatus.push(super::compute_ebpf_substatus()); assert_eq!( status.substatus[0].name, constants::PLUGIN_CONNECTION_NAME.to_string() @@ -1274,42 +1425,66 @@ mod tests { constants::EBPF_SUBSTATUS_NAME.to_string() ); - // Verify the eBPF substatus message includes service status info + // Verify the eBPF substatus message includes all three services, and that status/code + // are internally consistent (adaptive to whatever eBPF-for-Windows state the test + // runner happens to have installed). let ebpf_substatus = &status.substatus[3]; let ebpf_message = &ebpf_substatus.formattedMessage.message; - if ebpf_message.contains("unsuccessfully queried") { - // At least one service not installed — status should be Error - assert_eq!( - ebpf_substatus.status, - constants::ERROR_STATUS, - "Expected Error status when a service is not installed" - ); + assert!( + ebpf_message.contains("EbpfCore:"), + "Expected message to contain 'EbpfCore:', got: {ebpf_message}" + ); + assert!( + ebpf_message.contains("NetEbpfExt:"), + "Expected message to contain 'NetEbpfExt:', got: {ebpf_message}" + ); + assert!( + ebpf_message.contains("eBPFSvc:"), + "Expected message to contain 'eBPFSvc:', got: {ebpf_message}" + ); + if ebpf_substatus.status == constants::SUCCESS_STATUS { + assert_eq!(ebpf_substatus.code, constants::STATUS_CODE_OK); + } else if ebpf_substatus.status == constants::TRANSITIONING_STATUS { + // A service could legitimately be caught mid-start on the test runner; code stays + // OK while Transitioning, consistent with the existing set_error/set_success + // code/status coupling convention used elsewhere in this file. + assert_eq!(ebpf_substatus.code, constants::STATUS_CODE_OK); } else { - // Both services found — message should contain status details for each driver - assert!( - ebpf_message.contains("EbpfCore:"), - "Expected message to contain 'EbpfCore:', got: {ebpf_message}" - ); - assert!( - ebpf_message.contains("NetEbpfExt:"), - "Expected message to contain 'NetEbpfExt:', got: {ebpf_message}" - ); - // Status depends on whether both services are running - if ebpf_message.contains("Running") && !ebpf_message.contains("Stopped") { - assert_eq!( - ebpf_substatus.status, - constants::SUCCESS_STATUS, - "Expected Success when both services are running" - ); - assert_eq!(ebpf_substatus.code, constants::STATUS_CODE_OK); - } else { - assert_eq!( - ebpf_substatus.status, - constants::ERROR_STATUS, - "Expected Error when at least one service is not running" - ); - assert_eq!(ebpf_substatus.code, constants::STATUS_CODE_NOT_OK); - } + assert_eq!(ebpf_substatus.status, constants::ERROR_STATUS); + assert_eq!(ebpf_substatus.code, constants::STATUS_CODE_NOT_OK); + } + } + + #[test] + fn test_compute_gpa_service_substatus() { + // Cross-platform (unlike compute_ebpf_substatus, not gated to Windows): exercises the + // real check_service_run_status call (SCM on Windows, systemctl on Linux) against + // whatever GuestProxyAgent service state the test runner happens to have, and verifies + // the result is well-formed and internally consistent regardless of that state. This + // backfills test coverage for a function introduced in the prior commit that previously + // had no dedicated test (only its pure `build_proxy_agent_service_substatus` helper was + // tested). + let substatus = super::compute_gpa_service_substatus(); + assert_eq!( + substatus.name, + constants::PROXY_AGENT_SERVICE_SUBSTATUS_NAME + ); + assert!( + substatus + .formattedMessage + .message + .starts_with(&format!("{}: ", constants::PROXY_AGENT_SERVICE_NAME)), + "Expected message to start with '{}: ', got: {}", + constants::PROXY_AGENT_SERVICE_NAME, + substatus.formattedMessage.message + ); + if substatus.status == constants::SUCCESS_STATUS { + assert_eq!(substatus.code, constants::STATUS_CODE_OK); + } else if substatus.status == constants::TRANSITIONING_STATUS { + assert_eq!(substatus.code, constants::STATUS_CODE_OK); + } else { + assert_eq!(substatus.status, constants::ERROR_STATUS); + assert_eq!(substatus.code, constants::STATUS_CODE_NOT_OK); } } @@ -1331,23 +1506,61 @@ mod tests { } } - // 1. Both not installed + let running = || Some(ServiceState::Running); + let stopped = || Some(ServiceState::Stopped); + + // 1. All three not installed let sub = super::build_ebpf_substatus( &make_info(constants::EBPF_CORE, None), &make_info(constants::EBPF_EXT, None), + &make_info(constants::EBPF_SVC, None), + ); + assert_eq!(sub.status, constants::ERROR_STATUS, "All not installed"); + assert_eq!(sub.code, constants::STATUS_CODE_NOT_OK); + let msg = &sub.formattedMessage.message; + assert!( + msg.contains(constants::EBPF_CORE) + && msg.contains(constants::EBPF_EXT) + && msg.contains(constants::EBPF_SVC), + "Expected all three service names in message, got: {msg}" + ); + + // 2. Core+Ext running, eBPFSvc not installed → still Error (all three required) + let sub = super::build_ebpf_substatus( + &make_info(constants::EBPF_CORE, running()), + &make_info(constants::EBPF_EXT, running()), + &make_info(constants::EBPF_SVC, None), + ); + assert_eq!( + sub.status, + constants::ERROR_STATUS, + "eBPFSvc not installed should still be Error even if Core+Ext are healthy" ); - assert_eq!(sub.status, constants::ERROR_STATUS, "Both not installed"); assert_eq!(sub.code, constants::STATUS_CODE_NOT_OK); let msg = &sub.formattedMessage.message; assert!( - msg.contains(constants::EBPF_CORE) && msg.contains(constants::EBPF_EXT), - "Expected both driver names in message, got: {msg}" + msg.contains("eBPFSvc: NotInstalled"), + "Expected eBPFSvc: NotInstalled in message, got: {msg}" ); - // 2. Core not installed, Ext running + // 3. Core+Ext running, eBPFSvc stopped → Error + let sub = super::build_ebpf_substatus( + &make_info(constants::EBPF_CORE, running()), + &make_info(constants::EBPF_EXT, running()), + &make_info(constants::EBPF_SVC, stopped()), + ); + assert_eq!( + sub.status, + constants::ERROR_STATUS, + "eBPFSvc stopped should be Error even if Core+Ext are healthy" + ); + assert_eq!(sub.code, constants::STATUS_CODE_NOT_OK); + + // 4. Core not installed, Ext+Svc running → Error let sub = super::build_ebpf_substatus( &make_info(constants::EBPF_CORE, None), - &make_info(constants::EBPF_EXT, Some(ServiceState::Running)), + &make_info(constants::EBPF_EXT, running()), + &make_info(constants::EBPF_SVC, running()), ); assert_eq!(sub.status, constants::ERROR_STATUS, "Core not installed"); assert_eq!(sub.code, constants::STATUS_CODE_NOT_OK); @@ -1358,70 +1571,516 @@ mod tests { ); assert!( msg.contains("Running"), - "Expected Ext summary (Running) in message, got: {msg}" + "Expected Ext/Svc summary (Running) in message, got: {msg}" ); - // 3. Core running, Ext not installed + // 5. Ext not installed, Core+Svc running → Error let sub = super::build_ebpf_substatus( - &make_info(constants::EBPF_CORE, Some(ServiceState::Running)), + &make_info(constants::EBPF_CORE, running()), &make_info(constants::EBPF_EXT, None), + &make_info(constants::EBPF_SVC, running()), ); assert_eq!(sub.status, constants::ERROR_STATUS, "Ext not installed"); assert_eq!(sub.code, constants::STATUS_CODE_NOT_OK); - let msg = &sub.formattedMessage.message; - assert!( - msg.contains("Running"), - "Expected Core summary (Running) in message, got: {msg}" - ); - assert!( - msg.contains(constants::EBPF_EXT), - "Expected NetEbpfExt in message, got: {msg}" - ); - // 4. Both running → success + // 6. All three running → Success let sub = super::build_ebpf_substatus( - &make_info(constants::EBPF_CORE, Some(ServiceState::Running)), - &make_info(constants::EBPF_EXT, Some(ServiceState::Running)), + &make_info(constants::EBPF_CORE, running()), + &make_info(constants::EBPF_EXT, running()), + &make_info(constants::EBPF_SVC, running()), ); - assert_eq!(sub.status, constants::SUCCESS_STATUS, "Both running"); + assert_eq!(sub.status, constants::SUCCESS_STATUS, "All three running"); assert_eq!(sub.code, constants::STATUS_CODE_OK); let msg = &sub.formattedMessage.message; assert!( - msg.contains("EbpfCore:") && msg.contains("NetEbpfExt:"), - "Expected both driver labels in message, got: {msg}" + msg.contains("EbpfCore:") && msg.contains("NetEbpfExt:") && msg.contains("eBPFSvc:"), + "Expected all three driver labels in message, got: {msg}" ); - // 5. Core stopped, Ext running → error + // 7. Core stopped, Ext+Svc running → Error let sub = super::build_ebpf_substatus( - &make_info(constants::EBPF_CORE, Some(ServiceState::Stopped)), - &make_info(constants::EBPF_EXT, Some(ServiceState::Running)), + &make_info(constants::EBPF_CORE, stopped()), + &make_info(constants::EBPF_EXT, running()), + &make_info(constants::EBPF_SVC, running()), ); assert_eq!( sub.status, constants::ERROR_STATUS, - "Core stopped, Ext running" + "Core stopped, Ext+Svc running" ); assert_eq!(sub.code, constants::STATUS_CODE_NOT_OK); - // 6. Core running, Ext stopped → error + // 8. Core running, Ext stopped, Svc running → Error let sub = super::build_ebpf_substatus( - &make_info(constants::EBPF_CORE, Some(ServiceState::Running)), - &make_info(constants::EBPF_EXT, Some(ServiceState::Stopped)), + &make_info(constants::EBPF_CORE, running()), + &make_info(constants::EBPF_EXT, stopped()), + &make_info(constants::EBPF_SVC, running()), ); assert_eq!( sub.status, constants::ERROR_STATUS, - "Core running, Ext stopped" + "Core running, Ext stopped, Svc running" ); assert_eq!(sub.code, constants::STATUS_CODE_NOT_OK); - // 7. Both stopped → error + // 9. All three stopped → Error let sub = super::build_ebpf_substatus( - &make_info(constants::EBPF_CORE, Some(ServiceState::Stopped)), - &make_info(constants::EBPF_EXT, Some(ServiceState::Stopped)), + &make_info(constants::EBPF_CORE, stopped()), + &make_info(constants::EBPF_EXT, stopped()), + &make_info(constants::EBPF_SVC, stopped()), ); - assert_eq!(sub.status, constants::ERROR_STATUS, "Both stopped"); + assert_eq!(sub.status, constants::ERROR_STATUS, "All three stopped"); assert_eq!(sub.code, constants::STATUS_CODE_NOT_OK); + + // 10. Core starting up (StartPending), Ext+Svc running → Transitioning, not Error. + // Regression test: a service mid-boot/mid-restart must not immediately flip the + // top-level extension status to Error (see apply_ebpf_status_override, which only + // fires on ERROR_STATUS). + let sub = super::build_ebpf_substatus( + &make_info(constants::EBPF_CORE, Some(ServiceState::StartPending)), + &make_info(constants::EBPF_EXT, running()), + &make_info(constants::EBPF_SVC, running()), + ); + assert_eq!( + sub.status, + constants::TRANSITIONING_STATUS, + "Core starting up should be Transitioning, not Error" + ); + assert_eq!(sub.code, constants::STATUS_CODE_OK); + + // 11. Svc resuming (ContinuePending), Core+Ext running → Transitioning, not Error. + let sub = super::build_ebpf_substatus( + &make_info(constants::EBPF_CORE, running()), + &make_info(constants::EBPF_EXT, running()), + &make_info(constants::EBPF_SVC, Some(ServiceState::ContinuePending)), + ); + assert_eq!( + sub.status, + constants::TRANSITIONING_STATUS, + "Svc resuming should be Transitioning, not Error" + ); + assert_eq!(sub.code, constants::STATUS_CODE_OK); + + // 12. Core starting up (StartPending) AND Ext confirmed stopped → Error wins over + // Transitioning, since at least one service is confirmed down. + let sub = super::build_ebpf_substatus( + &make_info(constants::EBPF_CORE, Some(ServiceState::StartPending)), + &make_info(constants::EBPF_EXT, stopped()), + &make_info(constants::EBPF_SVC, running()), + ); + assert_eq!( + sub.status, + constants::ERROR_STATUS, + "A confirmed-down service should still report Error even if another is transitioning" + ); + assert_eq!(sub.code, constants::STATUS_CODE_NOT_OK); + } + + #[test] + fn test_build_proxy_agent_service_substatus() { + use proxy_agent_shared::service::ServiceRuntimeStatus; + + // Running → Success + let info = ServiceRuntimeStatus { + service_name: constants::PROXY_AGENT_SERVICE_NAME.to_string(), + is_installed: true, + is_running: true, + is_transitioning: false, + state_display: "Running".to_string(), + start_type_display: "AutoStart".to_string(), + }; + let sub = super::build_proxy_agent_service_substatus(&info); + assert_eq!(sub.name, constants::PROXY_AGENT_SERVICE_SUBSTATUS_NAME); + assert_eq!(sub.status, constants::SUCCESS_STATUS); + assert_eq!(sub.code, constants::STATUS_CODE_OK); + assert_eq!( + sub.formattedMessage.message, + format!( + "{}: Running, AutoStart", + constants::PROXY_AGENT_SERVICE_NAME + ) + ); + + // Stopped → Error + let info = ServiceRuntimeStatus { + service_name: constants::PROXY_AGENT_SERVICE_NAME.to_string(), + is_installed: true, + is_running: false, + is_transitioning: false, + state_display: "Stopped".to_string(), + start_type_display: "AutoStart".to_string(), + }; + let sub = super::build_proxy_agent_service_substatus(&info); + assert_eq!(sub.status, constants::ERROR_STATUS); + assert_eq!(sub.code, constants::STATUS_CODE_NOT_OK); + assert_eq!( + sub.formattedMessage.message, + format!( + "{}: Stopped, AutoStart", + constants::PROXY_AGENT_SERVICE_NAME + ) + ); + + // Disabled (installed but not running, start type Disabled) → Error + let info = ServiceRuntimeStatus { + service_name: constants::PROXY_AGENT_SERVICE_NAME.to_string(), + is_installed: true, + is_running: false, + is_transitioning: false, + state_display: "Stopped".to_string(), + start_type_display: "Disabled".to_string(), + }; + let sub = super::build_proxy_agent_service_substatus(&info); + assert_eq!(sub.status, constants::ERROR_STATUS); + assert_eq!(sub.code, constants::STATUS_CODE_NOT_OK); + + // Not installed → Error, "NotInstalled" summary + let info = ServiceRuntimeStatus { + service_name: constants::PROXY_AGENT_SERVICE_NAME.to_string(), + is_installed: false, + is_running: false, + is_transitioning: false, + state_display: "NotInstalled".to_string(), + start_type_display: "NotInstalled".to_string(), + }; + let sub = super::build_proxy_agent_service_substatus(&info); + assert_eq!(sub.status, constants::ERROR_STATUS); + assert_eq!(sub.code, constants::STATUS_CODE_NOT_OK); + assert_eq!( + sub.formattedMessage.message, + format!("{}: NotInstalled", constants::PROXY_AGENT_SERVICE_NAME) + ); + + // Starting up (StartPending on Windows / "activating" on Linux) → Transitioning, not + // Error. Regression test: a service mid-boot/mid-restart must not immediately flip the + // top-level extension status to Error (see apply_gpa_service_status_override, which + // only fires on ERROR_STATUS). + let info = ServiceRuntimeStatus { + service_name: constants::PROXY_AGENT_SERVICE_NAME.to_string(), + is_installed: true, + is_running: false, + is_transitioning: true, + state_display: "StartPending".to_string(), + start_type_display: "AutoStart".to_string(), + }; + let sub = super::build_proxy_agent_service_substatus(&info); + assert_eq!( + sub.status, + constants::TRANSITIONING_STATUS, + "A service starting up should be Transitioning, not Error" + ); + assert_eq!(sub.code, constants::STATUS_CODE_OK); + assert_eq!( + sub.formattedMessage.message, + format!( + "{}: StartPending, AutoStart", + constants::PROXY_AGENT_SERVICE_NAME + ) + ); + } + + #[test] + #[cfg(windows)] + fn test_apply_ebpf_status_override() { + let make_ebpf_sub = |status: &str, message: &str| SubStatus { + name: constants::EBPF_SUBSTATUS_NAME.to_string(), + status: status.to_string(), + code: if status == constants::ERROR_STATUS { + constants::STATUS_CODE_NOT_OK + } else { + constants::STATUS_CODE_OK + }, + formattedMessage: FormattedMessage { + lang: constants::LANG_EN_US.to_string(), + message: message.to_string(), + }, + }; + + // eBPF Error overrides an otherwise-Success status + let mut status = make_test_status_obj( + constants::SUCCESS_STATUS, + constants::STATUS_CODE_OK, + "ProxyAgent extension is reporting successful status.", + ); + let ebpf_sub = make_ebpf_sub( + constants::ERROR_STATUS, + "EbpfCore: Running, AutoStart, NetEbpfExt: Stopped, AutoStart, eBPFSvc: Running, AutoStart", + ); + let overridden = super::apply_ebpf_status_override( + &mut status, + &ebpf_sub, + "2026-08-21 8:13:38.104 +00:00:00", + ); + assert!(overridden); + assert_eq!(status.status, constants::ERROR_STATUS); + assert_eq!(status.code, constants::STATUS_CODE_NOT_OK); + assert!(status + .formattedMessage + .message + .contains("NetEbpfExt: Stopped")); + assert!(status + .formattedMessage + .message + .contains("Last status timestamp: 2026-08-21 8:13:38.104 +00:00:00")); + assert!(status.formattedMessage.message.contains("Current time:")); + + // eBPF Error overrides an already-Error stale message too + let mut status = make_test_status_obj( + constants::ERROR_STATUS, + constants::STATUS_CODE_NOT_OK, + "Proxy agent aggregate status file is stale. Status timestamp: ..., Current time: ...", + ); + let overridden = super::apply_ebpf_status_override( + &mut status, + &ebpf_sub, + "2026-08-21 8:13:38.104 +00:00:00", + ); + assert!(overridden); + assert!(!status.formattedMessage.message.contains("stale")); + assert!(status + .formattedMessage + .message + .contains("NetEbpfExt: Stopped")); + + // eBPF healthy leaves the existing message untouched + let mut status = make_test_status_obj( + constants::SUCCESS_STATUS, + constants::STATUS_CODE_OK, + "ProxyAgent extension is reporting successful status.", + ); + let healthy_ebpf_sub = make_ebpf_sub( + constants::SUCCESS_STATUS, + "EbpfCore: Running, AutoStart, NetEbpfExt: Running, AutoStart, eBPFSvc: Running, AutoStart", + ); + let overridden = + super::apply_ebpf_status_override(&mut status, &healthy_ebpf_sub, "irrelevant"); + assert!(!overridden); + assert_eq!(status.status, constants::SUCCESS_STATUS); + assert_eq!( + status.formattedMessage.message, + "ProxyAgent extension is reporting successful status." + ); + + // eBPF Transitioning (e.g. a service mid-boot/mid-restart) must NOT trigger the + // override - regression test for the reviewer finding that this override previously + // fired immediately on any non-Running state, including benign transitional ones. + let mut status = make_test_status_obj( + constants::SUCCESS_STATUS, + constants::STATUS_CODE_OK, + "ProxyAgent extension is reporting successful status.", + ); + let transitioning_ebpf_sub = make_ebpf_sub( + constants::TRANSITIONING_STATUS, + "EbpfCore: Running, AutoStart, NetEbpfExt: StartPending, AutoStart, eBPFSvc: Running, AutoStart", + ); + let overridden = + super::apply_ebpf_status_override(&mut status, &transitioning_ebpf_sub, "irrelevant"); + assert!( + !overridden, + "Transitioning eBPF substatus must not trigger the immediate override" + ); + assert_eq!(status.status, constants::SUCCESS_STATUS); + assert_eq!( + status.formattedMessage.message, + "ProxyAgent extension is reporting successful status." + ); + } + + #[test] + fn test_apply_gpa_service_status_override() { + let make_gpa_sub = |status: &str, message: &str| SubStatus { + name: constants::PROXY_AGENT_SERVICE_SUBSTATUS_NAME.to_string(), + status: status.to_string(), + code: if status == constants::ERROR_STATUS { + constants::STATUS_CODE_NOT_OK + } else { + constants::STATUS_CODE_OK + }, + formattedMessage: FormattedMessage { + lang: constants::LANG_EN_US.to_string(), + message: message.to_string(), + }, + }; + + // GPA-service Error overrides an otherwise-Success status immediately (no gating on + // top-level already being Error) + let mut status = make_test_status_obj( + constants::SUCCESS_STATUS, + constants::STATUS_CODE_OK, + "ProxyAgent extension is reporting successful status.", + ); + let gpa_sub = make_gpa_sub( + constants::ERROR_STATUS, + &format!( + "{}: Stopped, AutoStart", + constants::PROXY_AGENT_SERVICE_NAME + ), + ); + let overridden = super::apply_gpa_service_status_override( + &mut status, + &gpa_sub, + "2026-08-21 8:13:38.104 +00:00:00", + ); + assert!(overridden); + assert_eq!(status.status, constants::ERROR_STATUS); + assert_eq!(status.code, constants::STATUS_CODE_NOT_OK); + assert!(status + .formattedMessage + .message + .contains("Stopped, AutoStart")); + assert!(status + .formattedMessage + .message + .contains("Last status timestamp: 2026-08-21 8:13:38.104 +00:00:00")); + assert!(status.formattedMessage.message.contains("Current time:")); + + // GPA-service healthy leaves the existing message untouched + let mut status = make_test_status_obj( + constants::SUCCESS_STATUS, + constants::STATUS_CODE_OK, + "ProxyAgent extension is reporting successful status.", + ); + let healthy_gpa_sub = make_gpa_sub( + constants::SUCCESS_STATUS, + &format!( + "{}: Running, AutoStart", + constants::PROXY_AGENT_SERVICE_NAME + ), + ); + let overridden = + super::apply_gpa_service_status_override(&mut status, &healthy_gpa_sub, "irrelevant"); + assert!(!overridden); + assert_eq!( + status.formattedMessage.message, + "ProxyAgent extension is reporting successful status." + ); + + // GPA-service Transitioning (e.g. mid-boot/mid-restart) must NOT trigger the override - + // regression test for the reviewer finding that this override previously fired + // immediately on any non-Running state, including benign transitional ones. + let mut status = make_test_status_obj( + constants::SUCCESS_STATUS, + constants::STATUS_CODE_OK, + "ProxyAgent extension is reporting successful status.", + ); + let transitioning_gpa_sub = make_gpa_sub( + constants::TRANSITIONING_STATUS, + &format!( + "{}: StartPending, AutoStart", + constants::PROXY_AGENT_SERVICE_NAME + ), + ); + let overridden = super::apply_gpa_service_status_override( + &mut status, + &transitioning_gpa_sub, + "irrelevant", + ); + assert!( + !overridden, + "Transitioning GPA-service substatus must not trigger the immediate override" + ); + assert_eq!(status.status, constants::SUCCESS_STATUS); + assert_eq!( + status.formattedMessage.message, + "ProxyAgent extension is reporting successful status." + ); + } + + #[test] + #[cfg(windows)] + fn test_apply_service_health_overrides_priority() { + let make_sub = |name: &str, status: &str, message: &str| SubStatus { + name: name.to_string(), + status: status.to_string(), + code: if status == constants::ERROR_STATUS { + constants::STATUS_CODE_NOT_OK + } else { + constants::STATUS_CODE_OK + }, + formattedMessage: FormattedMessage { + lang: constants::LANG_EN_US.to_string(), + message: message.to_string(), + }, + }; + + let error_ebpf_sub = make_sub( + constants::EBPF_SUBSTATUS_NAME, + constants::ERROR_STATUS, + "EbpfCore: Stopped, AutoStart, NetEbpfExt: Running, AutoStart, eBPFSvc: Running, AutoStart", + ); + let healthy_ebpf_sub = make_sub( + constants::EBPF_SUBSTATUS_NAME, + constants::SUCCESS_STATUS, + "EbpfCore: Running, AutoStart, NetEbpfExt: Running, AutoStart, eBPFSvc: Running, AutoStart", + ); + let error_gpa_sub = make_sub( + constants::PROXY_AGENT_SERVICE_SUBSTATUS_NAME, + constants::ERROR_STATUS, + &format!( + "{}: Stopped, AutoStart", + constants::PROXY_AGENT_SERVICE_NAME + ), + ); + let healthy_gpa_sub = make_sub( + constants::PROXY_AGENT_SERVICE_SUBSTATUS_NAME, + constants::SUCCESS_STATUS, + &format!( + "{}: Running, AutoStart", + constants::PROXY_AGENT_SERVICE_NAME + ), + ); + + // Both unhealthy -> eBPF wins (message shows eBPF detail, not GPA-service detail) + let mut status = make_test_status_obj( + constants::SUCCESS_STATUS, + constants::STATUS_CODE_OK, + "ProxyAgent extension is reporting successful status.", + ); + super::apply_service_health_overrides(&mut status, &error_ebpf_sub, &error_gpa_sub, "ts"); + assert_eq!(status.status, constants::ERROR_STATUS); + assert!(status + .formattedMessage + .message + .contains("EbpfCore: Stopped")); + assert!( + !status + .formattedMessage + .message + .contains(constants::PROXY_AGENT_SERVICE_NAME), + "GPA-service detail should not appear when eBPF already overrode the message, got: {}", + status.formattedMessage.message + ); + + // eBPF healthy, GPA-service unhealthy -> falls through to the GPA-service override + let mut status = make_test_status_obj( + constants::SUCCESS_STATUS, + constants::STATUS_CODE_OK, + "ProxyAgent extension is reporting successful status.", + ); + super::apply_service_health_overrides(&mut status, &healthy_ebpf_sub, &error_gpa_sub, "ts"); + assert_eq!(status.status, constants::ERROR_STATUS); + assert!(status + .formattedMessage + .message + .contains("GuestProxyAgent: Stopped")); + + // Both healthy -> no override at all, message left untouched + let mut status = make_test_status_obj( + constants::SUCCESS_STATUS, + constants::STATUS_CODE_OK, + "ProxyAgent extension is reporting successful status.", + ); + super::apply_service_health_overrides( + &mut status, + &healthy_ebpf_sub, + &healthy_gpa_sub, + "ts", + ); + assert_eq!(status.status, constants::SUCCESS_STATUS); + assert_eq!( + status.formattedMessage.message, + "ProxyAgent extension is reporting successful status." + ); } #[tokio::test] diff --git a/proxy_agent_shared/src/service.rs b/proxy_agent_shared/src/service.rs index a3a0815c..c3367da4 100644 --- a/proxy_agent_shared/src/service.rs +++ b/proxy_agent_shared/src/service.rs @@ -175,6 +175,8 @@ pub fn check_service_status(service_name: &str) -> windows_service::ServiceStatu } } +#[cfg(windows)] +pub use windows_service::classify_service_state; #[cfg(windows)] pub use windows_service::set_default_failure_actions; #[cfg(windows)] @@ -182,6 +184,57 @@ pub use windows_service::ServiceState; #[cfg(windows)] pub use windows_service::ServiceStatusInfo; +/// Cross-platform runtime status of a system service (Windows SCM or Linux systemd), +/// used for reporting service health that is meaningful on both platforms (e.g. the +/// GuestProxyAgent service itself). Unlike `ServiceStatusInfo` (Windows-only, used for +/// the Windows-specific eBPF driver/service substatus), this type has an implementation +/// on every platform. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceRuntimeStatus { + pub service_name: String, + pub is_installed: bool, + pub is_running: bool, + /// True when the service is actively transitioning *toward* a running state (e.g. + /// Windows `StartPending`/`ContinuePending`, or systemd `activating`). This is a normal, + /// usually brief condition during boot or a service restart and is intentionally treated + /// as distinct from a confirmed failure (`is_running == false && is_transitioning == + /// false`), so callers don't have to treat "still starting up" the same as "actually down". + pub is_transitioning: bool, + /// Human-readable running state, e.g. "Running", "Stopped", "Failed". + pub state_display: String, + /// Human-readable start type, e.g. "AutoStart", "OnDemand", "Disabled". + pub start_type_display: String, +} + +impl ServiceRuntimeStatus { + /// Human-readable summary, e.g. "Running, AutoStart" or "NotInstalled". + pub fn summary(&self) -> String { + if self.is_installed { + format!("{}, {}", self.state_display, self.start_type_display) + } else { + "NotInstalled".to_string() + } + } + + /// Log-friendly message including the service name and summary. + pub fn message(&self) -> String { + format!("service: {} status: {}", self.service_name, self.summary()) + } +} + +/// Checks the runtime status (running state + start type) of a service in a cross-platform +/// way. Uses the Windows SCM on Windows and `systemctl` on Linux. +pub fn check_service_run_status(service_name: &str) -> ServiceRuntimeStatus { + #[cfg(windows)] + { + windows_service::query_service_run_status(service_name) + } + #[cfg(not(windows))] + { + linux_service::check_service_run_status(service_name) + } +} + #[cfg(test)] mod tests { #[test] @@ -266,4 +319,50 @@ mod tests { _ = super::stop_and_delete_service(service_name).await.unwrap(); } } + + #[test] + fn test_check_service_run_status_not_installed() { + // Cross-platform: a service name that certainly does not exist should report + // not-installed/not-running on both Windows and Linux. + let status = super::check_service_run_status("gpa-test-service-that-does-not-exist"); + assert!(!status.is_installed); + assert!(!status.is_running); + assert!( + !status.is_transitioning, + "A not-installed service must not be reported as transitioning" + ); + assert_eq!(status.summary(), "NotInstalled"); + assert!(status.message().contains("NotInstalled")); + } + + #[tokio::test] + async fn test_check_service_run_status_windows() { + #[cfg(windows)] + { + let service_name = "test_check_service_run_status"; + // try delete the service if it exists + _ = super::stop_and_delete_service(service_name).await; + + let exe_path = std::env::current_exe().unwrap(); + let result = super::install_service(service_name, service_name, vec![], exe_path); + assert!(result.is_ok()); + + let status = super::check_service_run_status(service_name); + assert!(status.is_installed); + // The test exe cannot actually run as a service, so it should be reported as + // installed-but-not-running. + assert!(!status.is_running); + // Stopped is a confirmed-down state, not a transitioning one. + assert!(!status.is_transitioning); + assert_eq!(status.state_display, "Stopped"); + let summary = status.summary(); + assert!( + summary.contains("AutoStart"), + "Expected summary to contain 'AutoStart', got: {summary}" + ); + + // clean up + super::stop_and_delete_service(service_name).await.unwrap(); + } + } } diff --git a/proxy_agent_shared/src/service/linux_service.rs b/proxy_agent_shared/src/service/linux_service.rs index 3e71bb9c..3f0e37f4 100644 --- a/proxy_agent_shared/src/service/linux_service.rs +++ b/proxy_agent_shared/src/service/linux_service.rs @@ -187,3 +187,150 @@ pub fn check_service_installed(service_name: &str) -> (bool, String) { (false, message) } } + +/// Maps the trimmed stdout of `systemctl is-active ` to +/// (is_running, is_transitioning, state_display). +/// `activating` mirrors Windows `StartPending`/`ContinuePending`: the unit is heading *toward* +/// active and this is a normal, usually brief condition during boot or a restart, so it is +/// intentionally distinguished from a confirmed failure. `deactivating` mirrors Windows +/// `StopPending`: the unit is heading *away* from active, which is treated as a confirmed down +/// state (not transitioning), since it's actionable information worth surfacing immediately. +/// Pure function so it is unit-testable without shelling out to `systemctl`. +fn map_is_active_output(output: &str) -> (bool, bool, String) { + match output.trim() { + "active" => (true, false, "Running".to_string()), + "inactive" => (false, false, "Stopped".to_string()), + "failed" => (false, false, "Failed".to_string()), + "activating" => (false, true, "Activating".to_string()), + "deactivating" => (false, false, "Deactivating".to_string()), + other => (false, false, capitalize_first(other)), + } +} + +/// Maps the trimmed stdout of `systemctl is-enabled ` to a start-type display string, +/// using Windows-like vocabulary ("AutoStart"/"Disabled") so the reported message shape is +/// consistent across platforms. Pure function so it is unit-testable without shelling out. +fn map_is_enabled_output(output: &str) -> String { + match output.trim() { + "enabled" | "enabled-runtime" => "AutoStart".to_string(), + "disabled" => "Disabled".to_string(), + "masked" => "Disabled".to_string(), + "static" => "OnDemand".to_string(), + other => capitalize_first(other), + } +} + +fn capitalize_first(s: &str) -> String { + if s.is_empty() { + return "Unknown".to_string(); + } + let mut chars = s.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => "Unknown".to_string(), + } +} + +/// Checks a service's runtime status (running state + start type) using `systemctl`, +/// in the cross-platform `ServiceRuntimeStatus` shape. +pub fn check_service_run_status(service_name: &str) -> crate::service::ServiceRuntimeStatus { + let (is_installed, _) = check_service_installed(service_name); + if !is_installed { + return crate::service::ServiceRuntimeStatus { + service_name: service_name.to_string(), + is_installed: false, + is_running: false, + is_transitioning: false, + state_display: "NotInstalled".to_string(), + start_type_display: "NotInstalled".to_string(), + }; + } + + let (is_running, is_transitioning, state_display) = + match misc_helpers::execute_command("systemctl", vec!["is-active", service_name], -1) { + Ok(output) => map_is_active_output(&output.stdout()), + Err(e) => { + logger_manager::write_info(format!( + "check_service_run_status: failed to query is-active for {service_name}: {e}" + )); + (false, false, "Unknown".to_string()) + } + }; + + let start_type_display = + match misc_helpers::execute_command("systemctl", vec!["is-enabled", service_name], -1) { + Ok(output) => map_is_enabled_output(&output.stdout()), + Err(e) => { + logger_manager::write_info(format!( + "check_service_run_status: failed to query is-enabled for {service_name}: {e}" + )); + "Unknown".to_string() + } + }; + + crate::service::ServiceRuntimeStatus { + service_name: service_name.to_string(), + is_installed: true, + is_running, + is_transitioning, + state_display, + start_type_display, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn map_is_active_output_test() { + assert_eq!( + map_is_active_output("active\n"), + (true, false, "Running".to_string()) + ); + assert_eq!( + map_is_active_output("inactive\n"), + (false, false, "Stopped".to_string()) + ); + assert_eq!( + map_is_active_output("failed\n"), + (false, false, "Failed".to_string()) + ); + // "activating" is transitioning toward Running - not a confirmed failure. + assert_eq!( + map_is_active_output("activating\n"), + (false, true, "Activating".to_string()) + ); + // "deactivating" is heading away from Running - treated as a confirmed down state, + // consistent with Windows StopPending, since it's actionable to know immediately. + assert_eq!( + map_is_active_output("deactivating\n"), + (false, false, "Deactivating".to_string()) + ); + assert_eq!( + map_is_active_output("unknown\n"), + (false, false, "Unknown".to_string()) + ); + } + + #[test] + fn map_is_enabled_output_test() { + assert_eq!(map_is_enabled_output("enabled\n"), "AutoStart".to_string()); + assert_eq!(map_is_enabled_output("disabled\n"), "Disabled".to_string()); + assert_eq!(map_is_enabled_output("masked\n"), "Disabled".to_string()); + assert_eq!(map_is_enabled_output("static\n"), "OnDemand".to_string()); + assert_eq!( + map_is_enabled_output("some-other-state\n"), + "Some-other-state".to_string() + ); + } + + #[test] + fn check_service_run_status_not_installed_test() { + let status = check_service_run_status("gpa-test-service-that-does-not-exist"); + assert!(!status.is_installed); + assert!(!status.is_running); + assert!(!status.is_transitioning); + assert_eq!(status.summary(), "NotInstalled"); + } +} diff --git a/proxy_agent_shared/src/service/windows_service.rs b/proxy_agent_shared/src/service/windows_service.rs index b8f552c5..faff2905 100644 --- a/proxy_agent_shared/src/service/windows_service.rs +++ b/proxy_agent_shared/src/service/windows_service.rs @@ -221,6 +221,64 @@ pub fn query_service_config(service_name: &str) -> Result { .map_err(|e| Error::WindowsService(e, std::io::Error::last_os_error())) } +/// Classifies a Windows service state into (is_running, is_transitioning). +/// `StartPending`/`ContinuePending` are transitioning *toward* Running - a normal, usually +/// brief condition during boot or a service restart, distinct from a confirmed failure. +/// `StopPending`/`PausePending`/`Paused`/`Stopped` (and no state at all) are treated as a +/// confirmed down state, since they are heading away from - or already away from - Running. +/// Pure function so it is unit-testable without a real SCM service. Takes `Option<&ServiceState>` +/// (rather than owning it) so callers don't need `ServiceState` to implement `Copy`/`Clone`, and +/// so it can be reused as-is by `proxy_agent_extension`'s eBPF substatus classification (see +/// `pub use` re-export below). +pub fn classify_service_state(state: Option<&ServiceState>) -> (bool, bool) { + match state { + Some(ServiceState::Running) => (true, false), + Some(ServiceState::StartPending) | Some(ServiceState::ContinuePending) => (false, true), + _ => (false, false), + } +} + +/// Queries a service's runtime status in the cross-platform `ServiceRuntimeStatus` shape, +/// re-mapping the same data already fetched by `check_service_status`/`query_service_config`. +pub fn query_service_run_status(service_name: &str) -> crate::service::ServiceRuntimeStatus { + match query_service_status(service_name) { + Ok(status) => { + let start_type_display = match query_service_config(service_name) { + Ok(config) => format!("{:?}", config.start_type), + Err(e) => { + logger_manager::write_info(format!( + "Failed to query config for service '{service_name}': {e}", + )); + "Unknown".to_string() + } + }; + let (is_running, is_transitioning) = + classify_service_state(Some(&status.current_state)); + crate::service::ServiceRuntimeStatus { + service_name: service_name.to_string(), + is_installed: true, + is_running, + is_transitioning, + state_display: format!("{:?}", status.current_state), + start_type_display, + } + } + Err(e) => { + logger_manager::write_info(format!( + "Failed to query status for service '{service_name}': {e}. Treating as not installed.", + )); + crate::service::ServiceRuntimeStatus { + service_name: service_name.to_string(), + is_installed: false, + is_running: false, + is_transitioning: false, + state_display: "NotInstalled".to_string(), + start_type_display: "NotInstalled".to_string(), + } + } + } +} + pub fn update_service( service_name: &str, service_display_name: &str, @@ -336,6 +394,43 @@ mod tests { use std::{path::PathBuf, process::Command}; use windows_service::service::ServiceState; + #[test] + fn classify_service_state_test() { + // Running -> healthy + assert_eq!( + super::classify_service_state(Some(&ServiceState::Running)), + (true, false) + ); + // StartPending/ContinuePending -> transitioning toward Running, not a confirmed failure + assert_eq!( + super::classify_service_state(Some(&ServiceState::StartPending)), + (false, true) + ); + assert_eq!( + super::classify_service_state(Some(&ServiceState::ContinuePending)), + (false, true) + ); + // Stopped/StopPending/PausePending/Paused -> confirmed down, not transitioning + assert_eq!( + super::classify_service_state(Some(&ServiceState::Stopped)), + (false, false) + ); + assert_eq!( + super::classify_service_state(Some(&ServiceState::StopPending)), + (false, false) + ); + assert_eq!( + super::classify_service_state(Some(&ServiceState::PausePending)), + (false, false) + ); + assert_eq!( + super::classify_service_state(Some(&ServiceState::Paused)), + (false, false) + ); + // No state (not installed / query failed) -> confirmed down, not transitioning + assert_eq!(super::classify_service_state(None), (false, false)); + } + #[tokio::test] async fn test_install_service() { const TEST_SERVICE_NAME: &str = "test_nt_service";