From 165db77a14d057b2f3479a7e79c467520c93109a Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 4 May 2026 23:44:26 +0000 Subject: [PATCH] Add GET /api/v1/system/services/{name} endpoint for service health checks - Add ServiceStatus struct with name, display_name, active_state, sub_state, load_state, enabled_state, main_pid, healthy fields - Add get_service_status() to PackageManagerBackend trait - Implement get_service_status() in AptBackend with systemd and OpenRC support - Add get_service_status HTTP handler in system.rs - Add /system/services/{name} route - Add E2E test for service status endpoint - Bump version to 0.3.6 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/api/handlers/system.rs | 83 ++++++++- src/jobs/manager.rs | 14 +- src/jobs/websocket.rs | 41 +++-- src/packages/mod.rs | 159 ++++++++++++++++++ .../e2e/__pycache__/test_e2e.cpython-313.pyc | Bin 0 -> 45236 bytes tests/e2e/test_e2e.py | 32 ++++ 8 files changed, 298 insertions(+), 35 deletions(-) create mode 100644 tests/e2e/__pycache__/test_e2e.cpython-313.pyc diff --git a/Cargo.lock b/Cargo.lock index a228078..f6a1549 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1859,7 +1859,7 @@ dependencies = [ [[package]] name = "linux-patch-api" -version = "0.3.5" +version = "0.3.6" dependencies = [ "actix", "actix-rt", diff --git a/Cargo.toml b/Cargo.toml index d046025..5538822 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "linux-patch-api" -version = "0.3.5" +version = "0.3.6" edition = "2021" authors = ["Echo "] description = "Secure remote package management API for Linux systems" diff --git a/src/api/handlers/system.rs b/src/api/handlers/system.rs index 8a09e5c..fcd44cc 100644 --- a/src/api/handlers/system.rs +++ b/src/api/handlers/system.rs @@ -47,6 +47,19 @@ pub struct HealthData { pub version: String, } +/// Service status response data +#[derive(Debug, Serialize)] +pub struct ServiceStatusData { + pub name: String, + pub display_name: String, + pub active_state: String, + pub sub_state: String, + pub load_state: String, + pub enabled_state: String, + pub main_pid: Option, + pub healthy: bool, +} + /// Reboot request #[derive(Debug, Deserialize, Clone)] pub struct RebootRequest { @@ -228,12 +241,80 @@ pub async fn reboot_system( } } +/// Get service status +pub async fn get_service_status( + path: web::Path, + backend: web::Data>, + _req: HttpRequest, +) -> impl Responder { + let request_id = Uuid::new_v4().to_string(); + let service_name = path.into_inner(); + + info!( + request_id = %request_id, + service = %service_name, + "Getting service status" + ); + + // Validate service name + if service_name.is_empty() || service_name.contains('/') || service_name.contains("..") { + let response = ApiResponse::<()>::error( + "INVALID_SERVICE_NAME", + &format!("Invalid service name: {}", service_name), + None, + false, + ); + return HttpResponse::BadRequest().json(response); + } + + match backend.get_service_status(&service_name) { + Ok(Some(status)) => { + let response = ApiResponse::success(ServiceStatusData { + name: status.name, + display_name: status.display_name, + active_state: status.active_state, + sub_state: status.sub_state, + load_state: status.load_state, + enabled_state: status.enabled_state, + main_pid: status.main_pid, + healthy: status.healthy, + }); + HttpResponse::Ok().json(response) + } + Ok(None) => { + let response = ApiResponse::<()>::error( + "SERVICE_NOT_FOUND", + &format!("Service '{}' not found", service_name), + None, + false, + ); + HttpResponse::NotFound().json(response) + } + Err(e) => { + error!( + request_id = %request_id, + service = %service_name, + error = %e, + "Failed to get service status" + ); + let response = ApiResponse::<()>::error( + "SERVICE_STATUS_ERROR", + &format!("Failed to get service status: {}", e), + None, + true, + ); + HttpResponse::InternalServerError().json(response) + } + } +} + /// Configure routes for system endpoints pub fn configure_routes(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/system") .route("/info", web::get().to(get_system_info)) - .route("/reboot", web::post().to(reboot_system)), + .route("/reboot", web::post().to(reboot_system)) + .route("/services/{name}", web::get().to(get_service_status)), ) .route("/health", web::get().to(health_check)); } diff --git a/src/jobs/manager.rs b/src/jobs/manager.rs index f122cbc..043511e 100644 --- a/src/jobs/manager.rs +++ b/src/jobs/manager.rs @@ -8,7 +8,7 @@ use chrono::{DateTime, Utc}; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use tokio::sync::{RwLock, broadcast}; +use tokio::sync::{broadcast, RwLock}; use uuid::Uuid; /// Job status @@ -271,11 +271,7 @@ impl JobManager { if let Some(job) = jobs.get_mut(job_id) { job.complete(); - event_data = Some(( - job.status.clone(), - job.progress, - job.message.clone(), - )); + event_data = Some((job.status.clone(), job.progress, job.message.clone())); } else { event_data = None; } @@ -296,11 +292,7 @@ impl JobManager { if let Some(job) = jobs.get_mut(job_id) { job.fail(error); - event_data = Some(( - job.status.clone(), - job.progress, - job.message.clone(), - )); + event_data = Some((job.status.clone(), job.progress, job.message.clone())); } else { event_data = None; } diff --git a/src/jobs/websocket.rs b/src/jobs/websocket.rs index 0bc42fc..a43a9f7 100644 --- a/src/jobs/websocket.rs +++ b/src/jobs/websocket.rs @@ -256,10 +256,8 @@ impl Handler for WsJobActor { let event = msg.0; // Check if this client should receive this event - let should_forward = self.subscribed_all - || self - .subscribed_jobs - .contains(&event.job_id.to_string()); + let should_forward = + self.subscribed_all || self.subscribed_jobs.contains(&event.job_id.to_string()); if should_forward { let server_msg = WsServerMessage::from_job_status_event(&event); @@ -300,24 +298,22 @@ impl StreamHandler> for WsJobActor { // Parse as client message match serde_json::from_str::(&text) { Ok(client_msg) => match client_msg { - WsClientMessage::Subscribe { job_id } => { - match job_id { - Some(id) => { - self.subscribed_jobs.insert(id.clone()); - let msg = WsServerMessage::subscribed(&Some(id)); - if let Ok(json) = serde_json::to_string(&msg) { - ctx.text(json); - } - } - None => { - self.subscribed_all = true; - let msg = WsServerMessage::subscribed(&None); - if let Ok(json) = serde_json::to_string(&msg) { - ctx.text(json); - } + WsClientMessage::Subscribe { job_id } => match job_id { + Some(id) => { + self.subscribed_jobs.insert(id.clone()); + let msg = WsServerMessage::subscribed(&Some(id)); + if let Ok(json) = serde_json::to_string(&msg) { + ctx.text(json); } } - } + None => { + self.subscribed_all = true; + let msg = WsServerMessage::subscribed(&None); + if let Ok(json) = serde_json::to_string(&msg) { + ctx.text(json); + } + } + }, WsClientMessage::Unsubscribe { job_id } => { self.subscribed_jobs.remove(&job_id); let msg = WsServerMessage::unsubscribed(&job_id); @@ -333,7 +329,10 @@ impl StreamHandler> for WsJobActor { text = %text, "Invalid WebSocket client message" ); - let msg = WsServerMessage::error("invalid_message", &format!("Invalid message: {}", e)); + let msg = WsServerMessage::error( + "invalid_message", + &format!("Invalid message: {}", e), + ); if let Ok(json) = serde_json::to_string(&msg) { ctx.text(json); } diff --git a/src/packages/mod.rs b/src/packages/mod.rs index 29180c3..c96b9c8 100644 --- a/src/packages/mod.rs +++ b/src/packages/mod.rs @@ -64,6 +64,19 @@ pub struct SystemInfo { pub pending_reboot: bool, } +/// Service status information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceStatus { + pub name: String, + pub display_name: String, + pub active_state: String, + pub sub_state: String, + pub load_state: String, + pub enabled_state: String, + pub main_pid: Option, + pub healthy: bool, +} + /// Package manager backend trait pub trait PackageManagerBackend: Send + Sync { fn list_packages(&self, filter: Option<&str>) -> Result>; @@ -75,6 +88,7 @@ pub trait PackageManagerBackend: Send + Sync { fn apply_patches(&self, packages: Option<&[String]>) -> Result<()>; fn get_system_info(&self) -> Result; fn reboot_system(&self, delay_seconds: u64) -> Result<()>; + fn get_service_status(&self, name: &str) -> Result>; } /// Package specification for installation @@ -480,6 +494,151 @@ impl PackageManagerBackend for AptBackend { Ok(()) } + + fn get_service_status(&self, name: &str) -> Result> { + // Validate service name to prevent shell injection + if name.is_empty() || name.contains('/') || name.contains("..") { + return Err(anyhow::anyhow!("Invalid service name: {}", name)); + } + + // Determine init system and query accordingly + let is_systemd = std::path::Path::new("/run/systemd/system").exists(); + let is_openrc = std::path::Path::new("/sbin/openrc").exists(); + + if is_systemd { + get_systemd_service_status(name) + } else if is_openrc { + get_openrc_service_status(name) + } else { + Err(anyhow::anyhow!( + "No supported init system detected (systemd or OpenRC required)" + )) + } + } +} + +/// Query systemd service status via systemctl +fn get_systemd_service_status(name: &str) -> Result> { + let output = Command::new("systemctl") + .args([ + "show", + name, + "--property=Id,Description,ActiveState,SubState,LoadState,UnitFileState,MainPID", + "--no-pager", + ]) + .output() + .context("Failed to execute systemctl command")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + + // If systemctl returns non-zero or empty output, service doesn't exist + if !output.status.success() || stdout.trim().is_empty() { + return Ok(None); + } + + let mut id = String::new(); + let mut description = String::new(); + let mut active_state = String::new(); + let mut sub_state = String::new(); + let mut load_state = String::new(); + let mut unit_file_state = String::new(); + let mut main_pid: Option = None; + + for line in stdout.lines() { + if let Some((key, value)) = line.split_once('=') { + match key { + "Id" => id = value.to_string(), + "Description" => description = value.to_string(), + "ActiveState" => active_state = value.to_string(), + "SubState" => sub_state = value.to_string(), + "LoadState" => load_state = value.to_string(), + "UnitFileState" => unit_file_state = value.to_string(), + "MainPID" => { + main_pid = value.parse::().ok().filter(|&p| p > 0); + } + _ => {} + } + } + } + + // If LoadState is not-found or bad-setting, service doesn't exist + if load_state == "not-found" || load_state == "bad-setting" || id.is_empty() { + return Ok(None); + } + + let healthy = active_state == "active" && sub_state == "running"; + + Ok(Some(ServiceStatus { + name: id, + display_name: description, + active_state, + sub_state, + load_state, + enabled_state: unit_file_state, + main_pid, + healthy, + })) +} + +/// Query OpenRC service status via rc-service +fn get_openrc_service_status(name: &str) -> Result> { + let output = Command::new("rc-service") + .args([name, "status"]) + .output() + .context("Failed to execute rc-service command")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // rc-service returns error if service doesn't exist + if !output.status.success() { + if stderr.contains("does not exist") || stdout.contains("does not exist") { + return Ok(None); + } + return Err(anyhow::anyhow!("rc-service failed: {}", stderr)); + } + + // Parse rc-service status output + let status_line = stdout.lines().next().unwrap_or("").to_lowercase(); + + let (active_state, sub_state, healthy) = + if status_line.contains("started") || status_line.contains("running") { + ("active".to_string(), "running".to_string(), true) + } else if status_line.contains("stopped") || status_line.contains("not running") { + ("inactive".to_string(), "dead".to_string(), false) + } else if status_line.contains("crashed") || status_line.contains("failed") { + ("failed".to_string(), "failed".to_string(), false) + } else { + ("unknown".to_string(), "unknown".to_string(), false) + }; + + // Check if service is enabled using rc-update + let enabled_output = Command::new("rc-update") + .args(["show", "default"]) + .output() + .ok(); + + let enabled_state = enabled_output + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| { + if s.lines().any(|l| l.trim().starts_with(name)) { + "enabled".to_string() + } else { + "disabled".to_string() + } + }) + .unwrap_or_else(|| "unknown".to_string()); + + Ok(Some(ServiceStatus { + name: name.to_string(), + display_name: name.to_string(), + active_state, + sub_state, + load_state: "loaded".to_string(), + enabled_state, + main_pid: None, + healthy, + })) } impl Default for AptBackend { diff --git a/tests/e2e/__pycache__/test_e2e.cpython-313.pyc b/tests/e2e/__pycache__/test_e2e.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2c1e93421717b8dc6b98aea0c428ecd287a9b0c0 GIT binary patch literal 45236 zcmeIb3sfBGl_r>v$Ojpjyk7zd1rkD}BoYJ&Nk~YAct}V*Bmz`YK^0CxA}B#ZMS@hy zD%-f-9wS$|i<-8_)U&g&y#Fh!$@F!TXp&5#S+ra*OJ?@8NEY_AN>=vdBo0sO1>1SMWIyka90JAXE;!Gp zNonlecENR?mw5JWx!{)EES*R440w8WYQ^f$Jtp{<=-=8~kD$Hj&m(5#T`&!#AzhaC z-BYH=(cVORCE6zC4CIIo_Lhq`Cwt4oTN-=I7hNJRx^)n|!Fw)y z&+}QO0x@4I6bqysVxd$tU@tW7F^M~hOk&Xyi&EkdwP2}O4J}6K&Ml!kS;}1qE78KF zUE=P65>Xh~EtU@O1G{a=sm?C$DKb6FNy0!;omt#FuuCjM_`ZQs#3`wBiscmgtXbMq zViGTi`v-Q46$qzz-hsV{Q(Wf~52$gPU_NyQpA*dIh{5M1^QkxZG%}x~2A@;R=a|9gH1j!b@OgsyG#GrE zn9m7=&l%=((%{p~d>X|Lu?6GXim`2DW+F@ z-0LXUUn$K5gNQU!Q&U~1N2~GG)YhboR%eV>>pOg`+Us)lg;14=-VRZ?H8wq}*HvkE zr&1B2XL52{Xl@dQ0@C!@$k>n{E%dp10@ui&S}UM3A)%v5>=pbY(*a5FkBE zo2rypvF!V1K)N;=3Owz4k3dbA*Ahz`_D}nV#!*_#HZm3%ANHDK92zrWv6!Z|I0Yh(UkRo}Il z;Pi}8f%<#BT#O6)Cjv2UbTTv@6m3Q6wcl{gnFiI2*YkQqna9Db={T(=n*{u z8%#Yx9TIr0F)L~iv#~Cu2U5grqk-}9$@d68j~&7!AEIv4ew_O9{s3OSZW=IQzcbNZ zCt3HI2Dky6XxWdD0XyET^d@qWgZbOomtArWq#?|~V!5yz@uKs%S#kq{+r%^$$0d2L z^Imt%)r(2f69~ScPy1fa;p%~`)6F0M4ifVNIjBs z2!6y!(wMT59I#`w93qF&at=7)tBq*dfJ+-u(Jt}q%c+jGn|&$cY6mVuFNEA=nf!yVimDhxD)E63Z8;c%)DXMD zOMRt~GSeI8E-%L_DbZj`01GDRFdR%;E)*Ca2~iaVsRC}yHjRlk9?MDS6Cd+@8sQ=8 z$yw9=jGR|qxbwn7@3;D2>yKnqEY*GY`)*9Unsn`{sdX$komAzRb4r?=0+_rl)l=+khSDqg_P<93o9|C;rn0oITkr zj|2yCT@1-^Ju((Y(;NsXz}zG9;zT!#<$g>o4~yk_OssSkD_zV0A_L?c3oFZ;6H8~? zHL#RswvoMe9nA@sv29)1+Juow?8Ku1AqlJTNiIZ{h~$YmrpG1%lQYvp#+gs=Y#sdy zFCd1@edctNXz8=0n`*f}(>Q#U-<8bq`1-pdjK|m2mFjUy&wl+*?X8E3P5SAG81jv; zvHlD#T7RbYrV%-Yu=pryVRaU)VYik-e~!mX7OmUF&Kr$7&Ea$~`b9X{m}w1g5CFa3EwV` z?y7t<^!{4KQMhXta zM;%ol`i*+^{RKGNLF052n^l$|*Jtf9rbZh2VeX}b${Nuav>`k`ZzI}saQuU#3Q4tPqP6k@+| z3;S9qW~DhnGa*E^ps~_NOHI6WwA6rUN_G|wMk_1t8@pcJHJ`D_E!>pT%jc{c*@fRY z@alnut4rK>-EX*~*+=Fa8`v0Lyz}CE#@@Fx_Aa(XGA^tX$Qc(_=}mTCU?VdWUxmmx zJ_ZNyvE3MegvSc6vs7a&;=K~Ho^9=Y!z}e8;Tu+^_0({Q_S6uKRJ@^9VDGW)q!|gn z+lcf&p-^ZZD*oB8e0HI4u_cmGyM+4I%FfzPwZ7cNZn0Mag=dnk!bz+mkZ_>lIZ1US z&lf3fVkIvx_C_-5mM+7QopqmTC9N0xwymReYG!LU!te9cqycgWJNUGQ*WS|F(c0U( zU5D9+1LJ||KrAn*%M|b5+S+MX7N;W_^-DM5$j*ADs`0%6(P>uFta<+!ob6^}DhOa8 z)J;+?5_>Jm+O!RggAA1F;AKFuBm#l4+@xx=s9&O(v}a&GI^6`>)}2KWXVE*(ovgkp z3^2f8f&pxCgUXZ$qP%w!PHJ>3<`^6to*V)R!0@i)l=M6~&2VB);J;IoLDZKPl=Kui zgX9d6Lt8hicuI_m%9O~VXrVXZ{1em9Io|`$X)9Y0H%)jfS=nP{=-2eMnQOImm={8u zCVDRIRh}#7)=zduPIi94d^cTYTipVZ;jvV)e&9spz=;p&W7BT79a+esfFn!C)(@VH z96U(@M<`(16h{|oDaFyHE9;e~B9*5o#nE3Q#V*#htoopyK&Qvj7&SyRnbd{0Y?;t( zmW*QVeBD$9$aKjZ1m=-rItQE}XuVQ>0wKegO`R_64wgPEa1u+%A^;QTO@gT(p~|n% zBw7jl^~d3%wr3C;OTS^zaU$1Gk=Rd5p>nS)=mZ4fR1N#3VF5H4o3KJ4cr!2#s=z1ouuVQBFw#p< zsdWKJX4hme;Pcvm`OFM~%@vBd)E#bcY#8GW6cgEkVK#|=j@el9aAD^d(52vY6!hH8 zm;_cCW8E|el7v{{(*0^)`-RZx=m& zNgHi=&;EEtwu{(>Hn_%6D}`St?WOv1)3-qqF#@X;-@w$U8PXJmWoVTLX90YtP|Q4% zgtKMcRC{(%>qjY!l)r=j&>z8>HT^QbbGG{zo}F{%4c`6L#*MtnCI6Br=Nx|9efWXR zl)mc&yUCqDKfG}K)yZ#9uk(i@{GpAkn(x=Ga4T1TkP)ptw_bZGQhQ0R>5(%;*(u)R z^AP)FB){U#z3aR$!uvL|>b~E*QnvD9q@g2vq;vhq<;aoCa@~`1#ueFlMTvhRlE44W zoOQl3!dGr&)qcNaC3odUn_MbVU$ivbLO%NdG^pn62ZAAGTo6Q*OMKaD@l7TolC+WnX2gk;7J3|QQv>>H zOSS2VHZ2DYSosg2b*bapt4$ivNtu^J%S&{gKo$IeBz-o~P5=dZ)gbY2K#>h$+5db1z8*g#I>~)7vUz`Lp*)L#b31F>`jSEBI zxC12tfkhBUC0livG%^MrFN;o_jPIeR%0D$$b@Ol)atKwucNbL-=buMzQMC)6_x(!;DdaZ(`$*gAv8%`a_N~3}><03=1HmRYrKs z9P&B=H~{$ARv+V)EC+`shXb*+*5`);j4O>Of#<;@el`TM(H0sH1g2tH=ey4gUhMAZ z7;NwAZSA?-)B)n-1yo;pksL;J+K?S+M~+}LtMgXkgPJ*rwo3$Qejop#zlJkwde@!# zO8ZyZU%D{cde4>qiuWttmkuu2BCcJFj;L$@Mn>K%;XC1l&ZY9DLb<3Wno&E)VZE>O z#Sy-EAr$5J+{-SOOX`+;*N?PDjm?601b-MjArPjh|6wU$%1=G^ze4ile|P~&%8`#&f$@wotZ^$Qj9p4#QJ%g0xm z7L$>Nmz~=7@VZL=#{8(fVaXYK%dsJ}$oA8ZlROWP+Uni`L`TpuNx3 zXimjK6uC+dy4j+$L{gKkJ#pBgRRZhLuCJ~>J60vti~UkT7=48W9VLK%drW=SIy1;O z8-4aZqqc07-eNOEGq)h(Bdm!G!AHN4m zP5p63j>F_{M-5${Lc7djTAz!MxP-REVVvHgcw6G5wc8}Rg03c4*A3-4V%C{cpK1;H z6g4EWCFvKhAx&-ei`Vdf)@nFnYP9Y)jSxiL*E16YKqZuGJfNK40n-EaH8iPUdpKMP z`P#t7y3l^{Vrz@n%GQj+*{0Kw_h>bY{T24tUYxEqi6Sr|Dq4@h&P&4fwx;%u2B9nL zXzl6g?r9LbF6GdJamHye#_WFZ@PotBm$c-u^d=kv;hb?Y*h+kTg)jYO3MN*%msj}K z$|4?_2@WX`@fC_dxCAkv=v+y9nLex&q3bb6VB9}N2TSHah*mh*71~ebrs4Q=|)lwiwi-Ki|jt_6EdpYJN#&Tf{UfTy!y) zXLSeb8!6mKVF*i1CwjEd6G%ObzC((1y#``NittQBc$OBflnhKPJMQD5y1*wV?vtVV z@lgxU*FyKn0bQA{0S8pCOa1SOM3>9>_maX1?t4eO_k;`!IEh;(A+wYuQ za7Osj#j8=?Cv&56O}BipSMKYNTpW;}4n!`F$lcfF>d`2IK;ZJZ3*5rx*W6LQOy#Yt`LYclt<#Hbue})M56avXx$;UBE`M;tvwM+Sy!^U*=}JVXmvn zcI3=ex#=nSsUdlIR34j*3{P#MU(IJM4@{XsNlR8{eJh6`9se!d_5#m!!4#gMF z(MV21Y6jk7KiM$35G$!RIYxy#%7rC-3w{Ip5rAf<%X3Ixcg2=EtMlp1I>2e9ruHFEz^C z0lDZvlshojxWVsOxD@3}WUgKo>Z5S^k`2$^#iqq)U+-Kzy}WP5{HL}b*j9%BgkS#b zYWrRP&jLRU$h`yqZc^@iM&<`)Zg4A5;LsloCh-qT2!l6`?VN8^A(; zoSccH9Sq3y#|cgi^@@|H3fki_fR^-lT3!Vf8MRg{^UUx`<1(M}58Z&kSx8^wst+VH zL({~ZW&)BL=hpWyQ(NL%O2hj0P@2Jbz#@iOS>ceXQOjX#J)EyCOwC*HOOjs^xp|nQ z1u~%^aJ|6&3h#)=)Oq24R>y~q4wMSKDV!!~k-#rv=Szubf^?HAM5k}kzk-v5A7r!j zo7jSXgJ`sPL+`>dtl)|$@BIWTxGL(Z`6w$G3paOR;7>8`H~beHBKf}WbL;$(2!BN8dgP-$QMmk(4NvZT??U;j1B?43 zc?aa&gG)$<7)Q3QRa&QPt^NnR{6iu}FuysP*`f`kg|_nS@MWhyY-MS_L~=0S_{QmC zYk?1+OSKkGD;RnsI|LzCr@lBw#>OG$3myc7yEI>YG{*26>**gCS7P9&G zTmQiJid6Ftes-FZK`U5=Rs(c*>X5k)VQk)}F%nlHid~v-5^>8XII&VGm!6BlgWpX=q&3PH6OOoxa739eCY&%tXyYMy=bi+wSZeXP4VoLeWFb^1+tXGpnJW+SrRoe)-yHq<2gfpOuf?h$4uu-QX1w^wbQGQ@}oq2mYk|=(AKoWm4osn-4pM z&jQx1DfNMMpBV>D7Lxl(c}%bD9i@!fiR0r`aRAqOgpf>T(hswfGwer}o;basN7TY0 zh!8)##~$U@b`26BP?^49VkFLY#7k#89H(Pu@MEk~F_S`)=$0(4G%!@Oc-bt69K#yR zO$^&orjmaiOP6m*R}fFf!nR4r>J_y0V?S1YS8VaN+WYLHQlCPWufY0!hvti(F6y)Q zWN0b%r&z2DCM5Q{xU)YV4e59F+2EGwIWm^a?(h#uq*F*<@YrSB6=YEG3nBlAdYGl{ ziWN-V$qT)+%fyZz19dk&>W9V}1mU$fDl*Abel)=X#8Y|4BQxXUw|%ZAmC3AfW`!|m z_X$JN7*u{BX+R8Ua9L0p${1&80fEk?n0&&-43y(;O-eU>uC6yM${`u;#BIHASfEZc zDGdSIKm&=+-X~%;vL|t_siGa>fxasSB zsm#^NduyX``BDP;AC=3lq~Ifb*-~1RKP+?4$w&I+%TLMu{>bHP@=!2xc~b71l53xf zB8WeXb4(n9mfsmzsEBy>&bHli;Yc*<+QX_p_dGV73sHW*%-xg^wylQl9((Jvt4RNh zJa}z=a3nG~B43}Bp9@6?r{!m6Wbe%=BJ%q;JO$r$&wp<5>e9`n(dDsK>*~3;JS(4< z`EHr(-il>8F=PzO`tLAg+hbX}oG8Ia(IQ%_q0o?s!QurS4*VxX0fRykB;$9ZBpt2K6Km4UAwI8q+Q-R_Dt z4*#*&X}gyJ{%2{vXd^M=<2#4mO=8Zr@ITks+hQIaoQnB+jtu@6gsUJezXkm3v{v5~ zlUVg@VBPmHD|xS|k_hg0Nitjs(q|^=ZE~XI5CYh%14-!}@?3`#v(K=c8JLv50H1J? zT2=};CK2+kKrp8g3qlpYAU`@;0{|ML_dh2m2~_w)4PF?qa7OuR zBF6aY`<|>10S{*6gRPJ&t9WZ-bx`hoT7JgA{!Ae9Oh6t9%2QJGnUMVSwCtUUax)Ab z?{+Y$&OP80e9^{? zZJ&DB=~ck%6=sLk>1@F(kL~FLz?#xv6mnXF->Pp z5Y+^V(|D&*EGwzZiml7|LrVbpqOQXqi9#_-?=;HgjZvWaR9V z@|9=h@yY1fDY@-Ah~`L&xK693odk8xTP~6)PpjpUDl`QaR2n-EbcN5PSSF`K z0UR4(ZZ?jNT{j5%X-8C=4h%tgE8j)IDG^AlS)bYX{aE{t!i?m1~k&D zKx74(OF|EQt;oL92*v3bO?rogrQM?5b`2ESDQqwjF}I><`QJj2@=P0l_$=-#{IVkCt%wP z05_JYW~Sj#A*d4n-$*O{3{E&NX}Ks3G0>~2sMPSTi_Aiq(n3E}E~g*@5@(rtE$_`^ z=Dha@aKeqos8ZlrMatGsZB!+0D}s~p{-E|!PkJ@gsG`K=B+H9#QPigL(BDeo{%cSv zxapT*3Tqn2@l3Iq5xaNLMoot7FR0Nv|BXo8Wa?ItLSZ#PcPTsSL&W|E%4KJ{oB=8RocXppFD{&0&)6qBkIMUxM&V}cgM|M4$g4XSMk87Kp$m}Z zBgT`QaTIYj(md;FJ0fX27M_Zx?cd1ix;wqzJs9a8l)FA7XZU5O|9;lNrIz(WCnJYW z$_E?eMpa33>n;)p2^DDV_PYpy36(3AYN2rkl+gxiGesO)Y8n{$h+^+{4GlAhz2mGu zDaRDtDiP;23dkTppbDxRxKs=!WFW;5o_MaWrWZA_^h^d2vcl!YU5i+`n3jz7f19ZT zYIxnl_)?=drxkIOBh~LckNSYqPI`3)OfQXXA{0|+s6*|AdC&$D&em`|R-!JzWkB$3 zRDNh%bSzy*$5=cwhW#5f(f>E(Z`hzJmqWz4fp|4`9D^d>N07)q7G{}waVvX86I)^I6;U~W%w)`-xbTXGP8k;$*}Y2?Ii#VkdSS$WBY@M5#~gYi zuJ2)4wfd{1XOg3ZNuNV^N-}zdbVBHy!C85A>Nj= zZB&$@WU4Ac%%QeV2lq)rBKd6YlaL6K4(cb}4$S{=(;E0^s5RC=dfn_<$a0WY!h&b< z`r=hNzY46xkF^vueC?ARdN(~{PQQst|JO68uRFZXa9a{n?QzpnQGiYm&7fH-SutoS zCt>(-irJ(|c%;q^num;M6~`bX8%+rU9dtsc2%PU=Gf2rGX%eL&_ID}8aN;BapK76T zsbDEh-cbVyhfiw~scOavauD&o{NI15DIr>Gt#RsbJ3yzXL?{YvwoeJr*{PyTJq@6h zPK1cHlM2ilsKh@@K)@3xOqB$+^J@^m(f44*SP04U#KR2IIX#gb1$>7ZY49w4h+Y&J1rOFBY*aX>O1%3+l zFbQ_!G=FS*e+V;qnsm;5E`17z)8fqlt_~)F0$Ia5;)mW;Go^U-w41@(hdC*xdUzLu zmfN*m`a8-##r#&pR<&eFGu(~Y{R5ieghVr(IOB){C~@4puxs(w;@Hw{x!`z|ZgkJ^ z6LKY)n3H#DUcSucWt^S(8``twvbrc=Cvy|>F+~XBTI4d+_MVMgz9ILG%k>jc1o3qn z{G&E)I)!}{wH2p$NDd$+0?H^B1uoP1UWz!02q+YVw}~a%8BOB|Z(MBZZ9cd4s0)(4 z`ox0u?U^ZpY8WcT8Y4k9jmFoZDuj}rue=qwCM3XCMaEJo2MVk)O@^Ax_D z%}u4cYGWDX2j6D$R$$nR%zBX-J7dTD3-nIRh7b*hhBPR$$x3pDb*ibQ92D_3O{}p15?MBe2am}h;7@rJYXEvP^=o$t3 z;b0aj)nQCkO$d)f)qQV~MRNat`+evhP>zxrQj(6YQz*T#*NAzjJigRD&fvK!$RA6K zkJz@9+D?XNt7E3vSl89?r28!Y z5eJn)FXld~B`_b6w_P|Sv@EG?IHSJ{IK}Ll+QBg4RT%!01Uf;35~@Ze(JxFGTU5ZfO#?mp815Ga4}>U$v%@+kCQW1tJ* zinF;5Vjv$a>5_=$ED;IUCXoUllgeehd61}V3H~j7$3DO)g{xDnz^%1WTF=<$iZ}=n z4!R85dnNSwpUIgerwUH^SgU$up*Kn;Zvaej?SMiWG4uG;0*$ZVAcU(@q@01qg~Is9 zd5P9?joY7Zq;RTOS2IhCOkR!*bwIT=k-4wv;xS3KIVGI!ynjKpPvrGWOq|V*03zbG z9a&L(U>&6$kU&A6srpGp=-2-f{~?^a(xwbq+tQ`pNM4PcTf324ywE4-?0uB1?fp3C z|8&%GU?U5bCf3jNM9%ccO`@F9D?592?!6dI6Ugj-YO7@%c{`ZCXhkIN;9T3iY#88K zu8CyV&oyskWX~VF^VvmbG^1i;$0fPvihSv+T=0~f`P4?%6RWxFO_w4~m*gi@uK#^! zuUzO`nqIFu9jQ7k`<_^}tk(VH_*=*C_TC*@JuROR<^0~Lvv+Q0E5ZcOk`S+L<*~#%?ye;mPvJBws$-g=)%y{Ksewubu z9~C5ro8s47;L@Np&9qkOW8-QuVQ2L}s0R~FBLU`Of;f%?#0xRFK=uQf`F>(xgmB$O zkkpHYaU%mRyZ}(Vh>LYXSi23xeydar{Ts5-Rl}3h5|4xsd}hxTT$0{V& zDKBXCKs9g-N@tu^s)0oTlK<>NA9x}QI{yO&pjd#VQ_v^SNm3IzWMl9>nk5W=g)5qk zxx!Su);OkQ5y0VUso@xrHHKI*0Wyv;gurlP3V@nWQ$wEWeFtPvU%jcw>t=SBr537C zJ2@n1YcTCh%*B`sXS0eW#!$>DiXE+Fdn}frS6%VodjkI9Uge?$W~Le7pMkkn%9BwU zgn-0>nnGqk!GkIUn~z%eN~Ku27GM#fw-6oL18!4B!Fqbh+vz3IbO8*UyrS8&zumQI z;cbN*8ToVv$VPhp{LOE?@ahZm6N`rzFMp^1_5Q`qXi*BrO$ zGNp}~<-!D*({o+UAB{Rk=Vt!)o;!Ely*uLGz3#4vxGUa-$>+w%fyVU%osk2b|Ni;) z?rV|mYwO*?NH=5_Cf_$%Y~AMj8C~~b_{?+1^VN*aH1hd-C^f(^8S_yLeEo|iQ&WlQ zt&*m!W{dTQRpw@!=|^Um$+7;(Vx@Omc5{I>fj1h5W9jG#g4qnydV}o#UYwHOr`)>lhBq5JvfG_RD%2NRTK^ummMLAiya=e}+U$88<|cYH`T051fPdwwr;pi<(kG&Teu9a-KyB z)%*zoQW`Ui(6aO8TCV8rNmrvSnu=`O8Au8GeNl83QW2-8_4KBj?g= z$3}MV?1gust>uDp`Z~WS!tYt<%bd zG_SU-xAaC@dgbOmxo=X=pNcxCKE_sJwp}uBWL(s@5(oMGy<+21-Ac;Q?6>$P(;43M zLmqocmGwtE&CNBYANkDmUS*~Cn(P)XaW5f!mC^ox1lNcY{|5Ap0aXFPx79Z`9{U3i zrd=-Sr@=-hdY2$Rhuwoj#*==KiBWWmF*oS9tv$=fbbaR-bEo|UD3=1q(j^>Mp|a^1GDHjrP-5|)A}*pxmQwV1BP1Vk zt)o5H4+qp(<|owU?vl$-thB73?1-G~kWX~VWnEFOE73seMuPSuc0j04DY8u?i&x}) zUzD%f$|~PBUyWb2P#?+IGkcC~)trm)yB71APIZIqZHRJ^9Ndo!L=g0e^{(U9mGifk zNmNW;sgeZqH(~OB0Z#dB%g@UyqUqj$*|PB5eBsM4yxuZu*SN0 zPCj%-E@_T(%`(4JDedvPWu0<)!%E%yi3^bv7vzQxxvVqFbyBbA7Jj4s)%M@LuxO9u z?q707au3gT-pc|bFPc>i%nD}WMkD<0MQE(~z+kjpu*fwXcb}54d?wQAmpiV>*KWw; zH`m9*k?}CXUsRMx+AQZtHDtofbVeUT}=$5eUDMW%*)lyy805)S))pWLvD(D5Tl>$*DvV6_dR1M)CLpbS- zC5LC|;riC5#Z!(<^m_%g4KL0{*n;A0Ga6a(O&hG$RP0xE<5V{7oYZj=g#uDDWwq82 z>ZclB%GG-ieyYJ2wpkZ2Nm#%#rrMu+9u+>e<~t1Xv_=1gYF=!txwtbvUYey}pgpP) z1BQ?S0V#?5Fk?IDOS0m-J5hy6wZn^x1#XT(vO%{T&`a9Es<&p|C9WtEF4hEdlGBGm zqq>MVGa?X6S94)^L4)#N#L87I3(z8AM?1Q#I6LrSH$}l#FF3#0vdFecAY7zLlN)`N z3$divk&muYs2VC)!omgGCo?&t-;fnPyj>d%vUing-4#n&y7aJ7a5ziE6*TS5tp?%Y zitV!Y`Ks_2m>#O4E4^N*&A9{+-&_kn*VW(pACBUqq zt4$=Ng7I*Pb1Th}bBCNTIXB7KOU}Q7lO!sth$xhP2#HB`%k|w+ zxIFIoQxOk2zdg#I-`d&_q3M}IM$lkGOfEYb<&To`0Dsg_+7nT}={HgugmfBYLUtPN zd&(Ajm&#usST2w3gIU;ym5SB6yY{zE$`_xBG!M#WJ|lnTS^37KJasd2;}&-B+lp=X zamBX#4P~EqGt=(2))d;F1Z%NXo8Ff&g*15sQrp{Bl>zcz${87`H_W!=pXgxVuas@w zkC4CJmi9Al-H+is7`AS!s8f8pJu{eVPuiGZ_l#{X9>d0?**IfX#~!hLYVj0m(#6`D z$=V4k52~Fs7Ph5K#fBQoKWKyW0$B*lWzunZ;lE0dEKs4eqI?IwA_MN~R~)FjTu(Oj zbhUS#Z4lV$7dz-ul=_$nKT_aR3`elr>IEg!Qdf77g6ZZQ<*cluN$gd4!;qQt3(7tA zNC*RCkVS{S;5gaJ3JAk6^+`u)Dp%Pr)K>%Cg8MrRp*pAsGR;B~U1MSkiWvL2!Ur82 z5rP3aAcGjfIOa-QxT3YDRVfq-cQezIp#H%5RGWB{SvebGX`zhhQM3=0vO-LFffN+| zxM56JHcaR;CeV;StAQAR!x1%MIk$*tdWIBe) zY*@vxmr{&=6~-|v=Fic7K$;9-z-F3?c5Y#C=DZBbg>l(xx*Cmc zm`J!I<3pJ9`Y5+$sI=81t$7*D?M%vbiUG&LNbhH4|9GT#LKcH^&196D++vAm!&AO^ zb8%Gm4aoe{GWRr-KZv=M2|ftMHtsLL_ha;Y@>FL)BrU5JH!x!K6O5M&LMu@z z0dT{qm4f#)El0e&YU5WUCZvVkVFl)Gn>)(Y5b1=J@oOdEs}o63kJhc%_t}o91Xxm> z44p99HcqB~9b-Zqy-(t#NmB0F`ekJaDUG$v>5rdHZW|}JA4HAv+cr*KfBbxM+c^3C z@e|2y;}rDA&mgyrQ`o;VWqtG>*9vf$8|DKd_^cw8irUoVb zWhqk{Yp}b&{IPL_er*fJ6@c5!uG0PkkIiRK|Dngm+1u|+8Aoqz**2}+*I%77rLnc; z{WXt`v%kOYv2iN;>mM7(+kY%&9K9t6^p^C+FGKX>)H`8qAM9_?^AQh;mB(#ZdwMQD zJ(up8KCb7a7F&OcRfHM&(X$9YrTc4dYB=VrA>V2}-|g$8&xY=!hO~!M*8?!ICUp%E zQmbFlngm-?_5dUunzZ`sPmQ4=prs(#>%s@vwS-{*Yd8+9tX}sMZyD&7c3C0A$$S#c z6ehw00e_mQQYATLp*LoBJ#1_IRDNMzU??P%#9)HtPpJfIhqMYOY!`%X5#GdP4i~yA zD=UR_$`xMa8WID)uv_(PrYq3Gyo%nmL$)cLtI@n7yLe2f08_ymE@oL>R4uS~YX(Zo%WZ2gm zE@1hd$GJzRKZu)JU@R93FzAysmJb3#;Vf!3MbX=p+sy9iP4dm}(UcPtW2d*PI)>y8 zHL7fO*g+p1+Hq7ki&aEa?KH6}6n9Mu%_PHM5PaxS4Qzx#ifnH})OhLnthOrlT|qR9SD8ubOjJU8 zy=LhZOyn8bVFclqulx^xz4D46sKOvZMP(&ne3dFfJ~HmV?v;pqogvtY`2T#7#ZN(4 zsY#e2z65$|2I7^eio&9!QzsZy4jmH01&VwjG&FHtf;0wcZ&E@CdA+4EM{iRPluty7 zb~A}UY0QQw5C{1^^7sxpe@G7TccgzqZ*<28yL^DzQJ3!1=YJySJ#zk<9I_20JtW6W z$w&)bA}wNx6fK!n8#^pw@_Q1AYA}T%rv1RAlqI4blsiigQ!c95$Z_&*Am;>}m|a1p zm8(qY=xfWZ%^tdv6go-lTGEho2C2SDG|B$~2Uj=a-kPT(9sy=_(~CE9A%y&Dm-I72deYh1JPIkC3y$nwoK?iBl!^AD~0npSaf>3KC_#Tw^T zVot8@KfZ#HGiuE0wVKYmNO@WRZoiB09`(C&tt#QWYVC0AD&lvk;l8!%=GDt`OlWW`+HTS)1T-jeZvhO)F=L;69 z)()Ot&A&VRwo|<4^vLP;?>HIDF6Wg~cTTbE&lZn<=hW+`mM=y3*57ky$=T=E+!r8E zI{)Ot}S{9H;gIdp385$ zpS;hxzTEMJj^FNrVUgJ`MdFkrWvGuFnM+qA9f>}h}|8U z1r5Rsi76qEU;h5f0$twS+A@PF#(ci{C84RKL%4|RzFS+mAhPiUHRli&&5&;*29quT zSI~6A5ez#bk$zy}vFIm|Ma(Qov+#>Khi8<_1(I!ndDylNeiPm69g^s3?su_>LMQQp zy)ea|=2$O07%4m`7aUqTvvg~{>P)2SjO=S(JuJIg*SI!)=eqC7eP!_F!8!9rQSrAb zzE$yR-tyJe(w}(W@+@uZ$?IM%Gc>=;(5(- zWrt#`vRAQH*-JtPEjSX!gTHL~0I~;9TJAg2zx=r`d~U%Ub?jJp@>|cm_RNw$x~qz6 znf{Jbco(2ObI&`@G6l?{>*GHH2n@{r5}Y_A+b$It-NO_tkDG(Ekce}a^n(pU0H;f4 zV$Q)%AqPS)bv!4D#f$uL>$Wl6q6cx(5l6`-PEzYdYUE_l+#d%sJ@G4Bw#L)I@i1QW z%TB}$J$2Hy5(k#4037-vQwIe)^Xy~PAWKjE32G1rn;)YF**atI6VxCs@F?aOdPeun z-L|LoICS*g&jUJe%yj*m-Jb%|;uWsuW>Jvm(3tC0E~SDNW`lSp=` z&ICsM*uNVDr1Ii;(lInTNwPRGhZ+*&a9`6@*pt9)h`B=3xKU{kN*;l5E#WEbY*u3{ zu$<$UC*demF|UK0nP8$7Bs4e$mYAJHX(oeGH`Ohzvht>KU=_Ah-VCQ-Rz3u1B~Hyu zhl^T!db)cWpx&csrI5}G6Osj@_D|8kaETUyY2Iv!1j{>W21+=N%XC3oU|ai>ySq|o z4GvdMPgde#==U(!+=S?!W+fZq9ucmleqIRN#_2V~2ipv2Wfpjnms@)XO%wY%JDYm0 zN-b!!L^py+ZR9Xakr2+X6^317=Bls*RyYVp47-8$b)#!AJir5KM1hJLP`()&U&0H; z?#@;fIrJ9CoEYjUzZ43@(wn5~GsKp=NS_kqnudo55z73aGef@gjS+8d8f6P|U zod}PNS#b~@=`1--6ilpn=`_XR(3uJ=L}#TiY)}IHGN70-D->o8m}wq` z5*F%Bi`iK7*DJg6~T<96LXC`l%A0o^M-(fbe1YKi?@R@;Y$%u&urTU=UV3qB3!|SGdq@9y^)!>;i=wC zGua9sm~1vX?oqJuT7-g)jN%Q?nN0@^&T-^;>xC4{@P*ZG|h9(N>{%(j5Y zcQfK$F&0Whedd8Tk1l!T^tv_Yk$X=573Wu+^I0$R_xSvEeiuZB*ZJL$g=H~)Z@#cJ zA*Y{Qb2cW#%u`BO=Sv{d%VJi(`RvkwoZhhJJYk3l%v^TWu5opQZ?}A{U>=`EYg{pX zc7F5V!Y*dBe-YUpxAZldqoq-BWAndscIQ zQv6o&st4M!*}1b9pou@*^_Tg(=3Mu(cQ3jWySgjGe>(ny@o4siImbO`-u(4%z4+RT zYtBO(&YX2;QN&raaCFUy8xFG>L!&7Q3F(6Gl)PTDT>4IFEiUc)O4kG4R8Ru>Zzn%@ zA__~vrBO#QA#evN;Le&qGkibo zj;$WJ+k|W5umAM=-K+BDr{(Nt{>(l2z-G$cg;7jPpZENcFH+)@+n$tnU6IqTt~sB2 zmvemi+;5%xXTG^h?{F21emD%Jk2wbiX)X>9dd+QKOIw@wj5I;hSET8Chu*Hx+n4YL z=nmQXp>cu%A&IaKiTEOPP1^M6_}Dd7fo}Tt6iz`TqDh$+UCc2fjU%`=mN`5YB7MNY zTYf2sI3cDz7t6Vrc{k#KD&K9_dM{mA?FG z(JR^mFHw~;$+p?W?v9SZ-uBMc?!I1$Sjam2S0YSFDkUdO&T(>nhn&6SByoj5MTRh$ zj52V5OX-ubhDohJVLvmjFfnlzctt=$!s($&q9bcIXbKE*o}8E@`IuAE1ft;26#CB? zlufJIY<|Fb%-s7WCUfR5O#ClQF1Y{ERPpDg!ap|^|D`G87p8QG*O{H`roy*Pg$o7i zI}S#699-XVCbHwqnyLAL+XRh+!k4<%-K7zC>EhXl``~QahARiM@2-+r$3}X=!l6id z`7FQT$zRwR@$8#*LB%`kz((e70#fOFAEepbc9`DC+NXK#gjaehuLs3Byk*w0dB~Jm zFzZIO^73U<)+HTMMbq2j%F{4(N#S&Ig z%X0GvRAI~JPLKIB<^_~W&t>Ze?71oM=K6)tQPle7y%e*4bEm`HY+g#=G|_XV_5=3Z z%;n8~^I{jp^qZGM6xDC0xNYV|q^IW!GN9*XZlU?2nJPd}ssKGV>)qxq^CI%4=Q8r8 z=VnQ!>Pt`ZrRQe3%RFgW2yU9_xt#L>du|rl)zaumrO|UUyTIIQrZ&)%8bQy^I+wX- z;S^<1vou8MYc`9V<}>C+_|bD2p7h+zccAwdQ4~E_P!v5kbKT}ktP|)-oj}jc5(njg zCeV{AOV7<*CybX+*U*!?hMtt~(IxZ&9xKfh^C0>5Sf4=!3V&HEM_X*%v(uA|Ag?^or=&EC!D%_f|ngrn9S RKj)lZYTLA$xFQB0{(nur?9~7O literal 0 HcmV?d00001 diff --git a/tests/e2e/test_e2e.py b/tests/e2e/test_e2e.py index ff0975d..e14dcea 100644 --- a/tests/e2e/test_e2e.py +++ b/tests/e2e/test_e2e.py @@ -604,6 +604,37 @@ def test_job_lifecycle(client: PatchAPIClient) -> str: return f"Full lifecycle OK: install job={job_id}, remove job={remove_job_id}" +def test_service_status(client: PatchAPIClient) -> str: + """GET /api/v1/system/services/{name} - Test service status endpoint.""" + # Test with a known service (ssh) + resp = client.get("/api/v1/system/services/ssh") + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}" + data = resp.json() + err = validate_envelope(data, "service_status") + assert err is None, f"Envelope validation failed: {err}" + assert data["success"] is True + assert "name" in data["data"], "Missing name field" + assert "active_state" in data["data"], "Missing active_state field" + assert "healthy" in data["data"], "Missing healthy field" + assert isinstance(data["data"]["healthy"], bool), "healthy must be boolean" + + # Test with non-existent service + resp = client.get("/api/v1/system/services/nonexistent-service-12345") + assert resp.status_code == 404, f"Expected 404, got {resp.status_code}" + data = resp.json() + assert data["success"] is False + assert data["error"]["code"] == "SERVICE_NOT_FOUND" + + # Test with invalid service name + resp = client.get("/api/v1/system/services/../../etc/passwd") + assert resp.status_code == 400, f"Expected 400, got {resp.status_code}" + data = resp.json() + assert data["success"] is False + assert data["error"]["code"] == "INVALID_SERVICE_NAME" + + return f"Service status OK: ssh={data['data']['name']}, state={data['data']['active_state']}, healthy={data['data']['healthy']}" + + def test_reboot_endpoint(client: PatchAPIClient) -> str: """POST /api/v1/system/reboot - Test reboot endpoint. @@ -653,6 +684,7 @@ def run_all_tests(target_key: str, skip_reboot: bool = False, verbose: bool = Fa print("\n--- Health & System ---") run_test(results, "Health Check", test_health_endpoint, client) run_test(results, "System Info", test_system_info, client) + run_test(results, "Service Status (ssh)", test_service_status, client) # ---- Category 2: Package Operations ---- print("\n--- Package Operations ---")