diff --git a/src/app/rpc.rs b/src/app/rpc.rs index 199e709..f23ccda 100644 --- a/src/app/rpc.rs +++ b/src/app/rpc.rs @@ -5,16 +5,23 @@ use jsonrpsee::core::{RpcResult, SubscriptionResult}; use jsonrpsee::proc_macros::rpc; use jsonrpsee::types::{ErrorCode, ErrorObjectOwned}; use jsonrpsee::PendingSubscriptionSink; -use serde_json::{Map, Value}; +use serde_json::{json, Map, Value}; use std::collections::HashMap; use std::str::FromStr; use tokio_stream::StreamExt; +use crate::zinit::errors::ZInitError; +use anyhow::Error as AnyError; + use super::api::Api; // Custom error codes for Zinit const SERVICE_NOT_FOUND: i32 = -32000; +const SERVICE_ALREADY_MONITORED: i32 = -32001; const SERVICE_IS_UP: i32 = -32002; +const SERVICE_IS_DOWN: i32 = -32003; +const INVALID_SIGNAL: i32 = -32004; +const CONFIG_ERROR: i32 = -32005; const SHUTTING_DOWN: i32 = -32006; const SERVICE_ALREADY_EXISTS: i32 = -32007; const SERVICE_FILE_ERROR: i32 = -32008; @@ -22,6 +29,135 @@ const SERVICE_FILE_ERROR: i32 = -32008; // Include the OpenRPC specification const OPENRPC_SPEC: &str = include_str!("../../openrpc.json"); +fn code_name_from(code: i32) -> &'static str { + match code { + SERVICE_NOT_FOUND => "ServiceNotFound", + SERVICE_ALREADY_MONITORED => "ServiceAlreadyMonitored", + SERVICE_IS_UP => "ServiceIsUp", + SERVICE_IS_DOWN => "ServiceIsDown", + INVALID_SIGNAL => "InvalidSignal", + CONFIG_ERROR => "ConfigError", + SHUTTING_DOWN => "ShuttingDown", + SERVICE_ALREADY_EXISTS => "ServiceAlreadyExists", + SERVICE_FILE_ERROR => "ServiceFileError", + _ => "InternalError", + } +} + +// Map ZInit/anyhow error -> JSON-RPC ErrorObjectOwned with structured details. +fn zinit_err_to_rpc( + err: AnyError, + action: &str, + service: Option<&str>, + default_code: i32, +) -> ErrorObjectOwned { + // Choose code/name/message from domain errors when possible + let (code, code_name, top_msg) = match err.downcast_ref::() { + Some(ZInitError::UnknownService { name }) => ( + SERVICE_NOT_FOUND, + "ServiceNotFound", + format!("service '{}' not found", name), + ), + Some(ZInitError::ServiceAlreadyMonitored { name }) => ( + SERVICE_ALREADY_MONITORED, + "ServiceAlreadyMonitored", + format!("service '{}' already monitored", name), + ), + Some(ZInitError::ServiceIsUp { name }) => ( + SERVICE_IS_UP, + "ServiceIsUp", + format!("service '{}' is up", name), + ), + Some(ZInitError::ServiceIsDown { name }) => ( + SERVICE_IS_DOWN, + "ServiceIsDown", + format!("service '{}' is down", name), + ), + Some(ZInitError::ShuttingDown) => ( + SHUTTING_DOWN, + "ShuttingDown", + "system is shutting down".to_string(), + ), + Some(ZInitError::InvalidStateTransition { message }) => { + (-32009, "InvalidStateTransition", message.clone()) + } + Some(ZInitError::DependencyError { message }) => { + (-32010, "DependencyError", message.clone()) + } + Some(ZInitError::ProcessError { message }) => (-32011, "ProcessError", message.clone()), + None => (default_code, code_name_from(default_code), err.to_string()), + }; + + // Build cause_chain from std::error::Error sources. + let mut cause_chain: Vec = Vec::new(); + let mut src = err.source(); + while let Some(s) = src { + cause_chain.push(s.to_string()); + src = s.source(); + } + + let retryable = matches!(code, SERVICE_ALREADY_MONITORED | SERVICE_IS_DOWN); + + let data = json!({ + "code_name": code_name, + "service": service, + "action": action, + "retryable": retryable, + "cause_chain": if cause_chain.is_empty() { serde_json::Value::Null } else { serde_json::Value::Array(cause_chain.into_iter().map(serde_json::Value::String).collect()) }, + "hint": match code { + SERVICE_NOT_FOUND => "Ensure the service is monitored or the name is correct", + SERVICE_ALREADY_MONITORED => "The service is already monitored; call service_forget or control it with start/stop", + SERVICE_IS_UP => "Service is already up; use service_stop or restart", + SERVICE_IS_DOWN => "Service is down; start it before sending signals or requesting stats", + SHUTTING_DOWN => "Wait for shutdown to complete or avoid mutating operations during shutdown", + SERVICE_FILE_ERROR => "Check filesystem permissions, disk space, and path", + _ => "Enable verbose logs and inspect cause_chain for details" + } + }); + + let message = format!("{}: {}", action, top_msg); + ErrorObjectOwned::owned(code, message, Some(data)) +} + +fn invalid_service_name_error(action: &str, name: &str) -> ErrorObjectOwned { + ErrorObjectOwned::owned( + ErrorCode::InvalidParams.code(), + format!("{action}: invalid service name '{}'", name), + Some(json!({ + "code_name": "InvalidServiceName", + "action": action, + "service": name, + "hint": "Name must not contain '/', '\\\\' or '.'" + })), + ) +} + +fn invalid_signal_error(signal: &str) -> ErrorObjectOwned { + ErrorObjectOwned::owned( + INVALID_SIGNAL, + format!("parsing signal: invalid signal '{}'", signal), + Some(json!({ + "code_name": "InvalidSignal", + "action": "parsing signal", + "signal": signal, + "hint": "Use a valid POSIX signal like SIGTERM or SIGKILL" + })), + ) +} + +fn service_file_error(action: &str, name: &str, detail: &str) -> ErrorObjectOwned { + ErrorObjectOwned::owned( + SERVICE_FILE_ERROR, + format!("{action}: {detail}"), + Some(json!({ + "code_name": "ServiceFileError", + "action": action, + "service": name, + "hint": "Check permissions and filesystem availability" + })), + ) +} + /// RPC methods for discovery. #[rpc(server, client)] pub trait ZinitRpcApi { @@ -92,30 +228,34 @@ pub trait ZinitServiceApi { #[async_trait] impl ZinitServiceApiServer for Api { async fn list(&self) -> RpcResult> { - let services = self - .zinit - .list() - .await - .map_err(|_| ErrorObjectOwned::from(ErrorCode::InternalError))?; + let services = self.zinit.list().await.map_err(|e| { + zinit_err_to_rpc(e, "listing services", None, ErrorCode::InternalError.code()) + })?; let mut map: HashMap = HashMap::new(); for service in services { - let state = self - .zinit - .status(&service) - .await - .map_err(|_| ErrorObjectOwned::from(ErrorCode::InternalError))?; + let state = self.zinit.status(&service).await.map_err(|e| { + zinit_err_to_rpc( + e, + "getting status", + Some(&service), + ErrorCode::InternalError.code(), + ) + })?; map.insert(service, format!("{:?}", state.state)); } Ok(map) } async fn status(&self, name: String) -> RpcResult { - let status = self - .zinit - .status(&name) - .await - .map_err(|_| ErrorObjectOwned::from(ErrorCode::InternalError))?; + let status = self.zinit.status(&name).await.map_err(|e| { + zinit_err_to_rpc( + e, + "getting status", + Some(&name), + ErrorCode::InternalError.code(), + ) + })?; let result = Status { name: name.clone(), @@ -125,11 +265,11 @@ impl ZinitServiceApiServer for Api { after: { let mut after = HashMap::new(); for service in status.service.after { - let status = match self.zinit.status(&service).await { + let dep_state = match self.zinit.status(&service).await { Ok(dep) => dep.state, Err(_) => crate::zinit::State::Unknown, }; - after.insert(service, format!("{:?}", status)); + after.insert(service, format!("{:?}", dep_state)); } after }, @@ -139,47 +279,82 @@ impl ZinitServiceApiServer for Api { } async fn start(&self, name: String) -> RpcResult<()> { - self.zinit - .start(name) - .await - .map_err(|_| ErrorObjectOwned::from(ErrorCode::ServerError(SERVICE_IS_UP))) + self.zinit.start(name.clone()).await.map_err(|e| { + zinit_err_to_rpc( + e, + "starting service", + Some(&name), + ErrorCode::InternalError.code(), + ) + }) } async fn stop(&self, name: String) -> RpcResult<()> { - self.zinit - .stop(name) - .await - .map_err(|_| ErrorObjectOwned::from(ErrorCode::InternalError)) + self.zinit.stop(name.clone()).await.map_err(|e| { + zinit_err_to_rpc( + e, + "stopping service", + Some(&name), + ErrorCode::InternalError.code(), + ) + }) } async fn monitor(&self, name: String) -> RpcResult<()> { - if let Ok((name_str, service)) = config::load(format!("{}.yaml", name)) - .map_err(|_| ErrorObjectOwned::from(ErrorCode::InternalError)) - { - self.zinit - .monitor(name_str, service) - .await - .map_err(|_| ErrorObjectOwned::from(ErrorCode::InternalError)) - } else { - Err(ErrorObjectOwned::from(ErrorCode::InternalError)) + // Validate service name + if name.contains('/') || name.contains('\\') || name.contains('.') { + return Err(invalid_service_name_error("monitoring service", &name)); } - } - async fn forget(&self, name: String) -> RpcResult<()> { + let (name_str, service) = config::load(format!("{}.yaml", name)).map_err(|e| { + ErrorObjectOwned::owned( + CONFIG_ERROR, + format!("monitoring service: failed to load '{}.yaml'", name), + Some(json!({ + "code_name": "ConfigError", + "action": "loading service config", + "service": name, + "hint": "Verify the YAML file exists and is valid" + })), + ) + })?; + self.zinit - .forget(name) + .monitor(name_str.clone(), service) .await - .map_err(|_| ErrorObjectOwned::from(ErrorCode::InternalError)) + .map_err(|e| { + zinit_err_to_rpc( + e, + "monitoring service", + Some(&name_str), + ErrorCode::InternalError.code(), + ) + }) + } + + async fn forget(&self, name: String) -> RpcResult<()> { + self.zinit.forget(name.clone()).await.map_err(|e| { + zinit_err_to_rpc( + e, + "forgetting service", + Some(&name), + ErrorCode::InternalError.code(), + ) + }) } async fn kill(&self, name: String, signal: String) -> RpcResult<()> { if let Ok(sig) = nix::sys::signal::Signal::from_str(&signal.to_uppercase()) { - self.zinit - .kill(name, sig) - .await - .map_err(|_e| ErrorObjectOwned::from(ErrorCode::InternalError)) + self.zinit.kill(name.clone(), sig).await.map_err(|e| { + zinit_err_to_rpc( + e, + "sending signal", + Some(&name), + ErrorCode::InternalError.code(), + ) + }) } else { - Err(ErrorObjectOwned::from(ErrorCode::InternalError)) + Err(invalid_signal_error(&signal)) } } @@ -190,29 +365,56 @@ impl ZinitServiceApiServer for Api { // Validate service name (no path traversal, valid characters) if name.contains('/') || name.contains('\\') || name.contains('.') { - return Err(ErrorObjectOwned::from(ErrorCode::InternalError)); + return Err(invalid_service_name_error("creating service", &name)); } // Construct the file path - let file_path = PathBuf::from(format!("{}.yaml", name)); + let file_path = PathBuf::from(format!("{}.yaml", &name)); // Check if the service file already exists if file_path.exists() { - return Err(ErrorObjectOwned::from(ErrorCode::ServerError( + return Err(ErrorObjectOwned::owned( SERVICE_ALREADY_EXISTS, - ))); + format!("creating service: Service '{}' already exists", name), + Some(json!({ + "code_name": "ServiceAlreadyExists", + "action": "creating service file", + "service": name, + "hint": "Use a different name or delete the existing service first" + })), + )); } // Convert the JSON content to YAML - let yaml_content = serde_yaml::to_string(&content) - .map_err(|_| ErrorObjectOwned::from(ErrorCode::InternalError))?; + let yaml_content = serde_yaml::to_string(&content).map_err(|e| { + ErrorObjectOwned::owned( + CONFIG_ERROR, + "creating service: failed to convert content to YAML".to_string(), + Some(json!({ + "code_name": "ConfigError", + "action": "serializing service config", + "service": name, + "hint": "Ensure content is valid according to schema" + })), + ) + })?; // Write the YAML content to the file - let mut file = fs::File::create(&file_path) - .map_err(|_| ErrorObjectOwned::from(ErrorCode::ServerError(SERVICE_FILE_ERROR)))?; - - file.write_all(yaml_content.as_bytes()) - .map_err(|_| ErrorObjectOwned::from(ErrorCode::ServerError(SERVICE_FILE_ERROR)))?; + let mut file = fs::File::create(&file_path).map_err(|_| { + service_file_error( + "creating service file", + &name, + "failed to create service file", + ) + })?; + + file.write_all(yaml_content.as_bytes()).map_err(|_| { + service_file_error( + "writing service file", + &name, + "failed to write service file", + ) + })?; Ok(format!("Service '{}' created successfully", name)) } @@ -223,22 +425,34 @@ impl ZinitServiceApiServer for Api { // Validate service name (no path traversal, valid characters) if name.contains('/') || name.contains('\\') || name.contains('.') { - return Err(ErrorObjectOwned::from(ErrorCode::InternalError)); + return Err(invalid_service_name_error("deleting service", &name)); } // Construct the file path - let file_path = PathBuf::from(format!("{}.yaml", name)); + let file_path = PathBuf::from(format!("{}.yaml", &name)); // Check if the service file exists if !file_path.exists() { - return Err(ErrorObjectOwned::from(ErrorCode::ServerError( + return Err(ErrorObjectOwned::owned( SERVICE_NOT_FOUND, - ))); + format!("deleting service: Service '{}' not found", name), + Some(json!({ + "code_name": "ServiceNotFound", + "action": "deleting service file", + "service": name, + "hint": "Ensure the service file exists" + })), + )); } // Delete the file - fs::remove_file(&file_path) - .map_err(|_| ErrorObjectOwned::from(ErrorCode::ServerError(SERVICE_FILE_ERROR)))?; + fs::remove_file(&file_path).map_err(|_| { + service_file_error( + "deleting service file", + &name, + "failed to delete service file", + ) + })?; Ok(format!("Service '{}' deleted successfully", name)) } @@ -249,40 +463,70 @@ impl ZinitServiceApiServer for Api { // Validate service name (no path traversal, valid characters) if name.contains('/') || name.contains('\\') || name.contains('.') { - return Err(ErrorObjectOwned::from(ErrorCode::InternalError)); + return Err(invalid_service_name_error("getting service", &name)); } // Construct the file path - let file_path = PathBuf::from(format!("{}.yaml", name)); + let file_path = PathBuf::from(format!("{}.yaml", &name)); // Check if the service file exists if !file_path.exists() { - return Err(ErrorObjectOwned::from(ErrorCode::ServerError( + return Err(ErrorObjectOwned::owned( SERVICE_NOT_FOUND, - ))); + format!("getting service: Service '{}' not found", name), + Some(json!({ + "code_name": "ServiceNotFound", + "action": "reading service file", + "service": name, + "hint": "Ensure the service file exists" + })), + )); } // Read the file content - let yaml_content = fs::read_to_string(&file_path) - .map_err(|_| ErrorObjectOwned::from(ErrorCode::ServerError(SERVICE_FILE_ERROR)))?; + let yaml_content = fs::read_to_string(&file_path).map_err(|_| { + service_file_error("reading service file", &name, "failed to read service file") + })?; // Parse YAML to JSON - let yaml_value: serde_yaml::Value = serde_yaml::from_str(&yaml_content) - .map_err(|_| ErrorObjectOwned::from(ErrorCode::InternalError))?; + let yaml_value: serde_yaml::Value = serde_yaml::from_str(&yaml_content).map_err(|_| { + ErrorObjectOwned::owned( + CONFIG_ERROR, + "getting service: failed to parse YAML".to_string(), + Some(json!({ + "code_name": "ConfigError", + "action": "parsing service file", + "service": name, + "hint": "Ensure the YAML is valid" + })), + ) + })?; // Convert YAML value to JSON value - let json_value = serde_json::to_value(yaml_value) - .map_err(|_| ErrorObjectOwned::from(ErrorCode::InternalError))?; + let json_value = serde_json::to_value(yaml_value).map_err(|_| { + ErrorObjectOwned::owned( + CONFIG_ERROR, + "getting service: failed to convert YAML to JSON".to_string(), + Some(json!({ + "code_name": "ConfigError", + "action": "converting YAML to JSON", + "service": name + })), + ) + })?; Ok(json_value) } async fn stats(&self, name: String) -> RpcResult { - let stats = self - .zinit - .stats(&name) - .await - .map_err(|_| ErrorObjectOwned::from(ErrorCode::InternalError))?; + let stats = self.zinit.stats(&name).await.map_err(|e| { + zinit_err_to_rpc( + e, + "collecting stats", + Some(&name), + ErrorCode::InternalError.code(), + ) + })?; let result = Stats { name: name.clone(), @@ -330,21 +574,25 @@ impl ZinitSystemApiServer for Api { self.zinit .shutdown() .await - .map_err(|_e| ErrorObjectOwned::from(ErrorCode::ServerError(SHUTTING_DOWN))) + .map_err(|e| zinit_err_to_rpc(e, "system shutdown", None, SHUTTING_DOWN)) } async fn reboot(&self) -> RpcResult<()> { - self.zinit - .reboot() - .await - .map_err(|_| ErrorObjectOwned::from(ErrorCode::InternalError)) + self.zinit.reboot().await.map_err(|e| { + zinit_err_to_rpc(e, "system reboot", None, ErrorCode::InternalError.code()) + }) } async fn start_http_server(&self, address: String) -> RpcResult { // Call the method from the API implementation match crate::app::api::Api::start_http_server(self, address).await { Ok(result) => Ok(result), - Err(_) => Err(ErrorObjectOwned::from(ErrorCode::InternalError)), + Err(e) => Err(zinit_err_to_rpc( + AnyError::from(e), + "starting http server", + None, + ErrorCode::InternalError.code(), + )), } } @@ -352,7 +600,12 @@ impl ZinitSystemApiServer for Api { // Call the method from the API implementation match crate::app::api::Api::stop_http_server(self).await { Ok(_) => Ok(()), - Err(_) => Err(ErrorObjectOwned::from(ErrorCode::InternalError)), + Err(e) => Err(zinit_err_to_rpc( + AnyError::from(e), + "stopping http server", + None, + ErrorCode::InternalError.code(), + )), } } } diff --git a/zinit-client/src/lib.rs b/zinit-client/src/lib.rs index b01fef6..4031d03 100644 --- a/zinit-client/src/lib.rs +++ b/zinit-client/src/lib.rs @@ -21,9 +21,21 @@ pub enum ClientError { #[error("service not found: {0}")] ServiceNotFound(String), + #[error("service already monitored: {0}")] + ServiceAlreadyMonitored(String), + #[error("service is already up: {0}")] ServiceIsUp(String), + #[error("service is down: {0}")] + ServiceIsDown(String), + + #[error("invalid signal: {0}")] + InvalidSignal(String), + + #[error("config error: {0}")] + ConfigError(String), + #[error("system is shutting down")] ShuttingDown, @@ -42,16 +54,68 @@ pub enum ClientError { impl From for ClientError { fn from(err: RpcError) -> Self { - // Parse the error code if available - if let RpcError::Call(err) = &err { - match err.code() { - -32000 => return ClientError::ServiceNotFound(err.message().to_string()), - -32002 => return ClientError::ServiceIsUp(err.message().to_string()), - -32006 => return ClientError::ShuttingDown, - -32007 => return ClientError::ServiceAlreadyExists(err.message().to_string()), - -32008 => return ClientError::ServiceFileError(err.message().to_string()), - _ => {} - } + if let RpcError::Call(call) = &err { + let code = call.code(); + let message = call.message().to_string(); + + // Try to parse structured data payload if present + let details: Option = call + .data() + .and_then(|raw| serde_json::from_str(raw.get()).ok()); + + let code_name = details + .as_ref() + .and_then(|d| d.get("code_name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + + let service = details + .as_ref() + .and_then(|d| d.get("service")) + .and_then(|v| v.as_str()); + + let action = details + .as_ref() + .and_then(|d| d.get("action")) + .and_then(|v| v.as_str()); + + let hint = details + .as_ref() + .and_then(|d| d.get("hint")) + .and_then(|v| v.as_str()); + + let chain = details.as_ref().and_then(|d| d.get("cause_chain")).cloned(); + + let human = match (service, action, hint) { + (Some(s), Some(a), Some(h)) => { + format!( + "{}[{}]: {} while {} '{}'. Hint: {}. Details: {:?}", + code_name, code, message, a, s, h, chain + ) + } + (Some(s), Some(a), None) => { + format!( + "{}[{}]: {} while {} '{}'. Details: {:?}", + code_name, code, message, a, s, chain + ) + } + _ => { + format!("{}[{}]: {}. Details: {:?}", code_name, code, message, chain) + } + }; + + return match code { + -32000 => ClientError::ServiceNotFound(human), + -32001 => ClientError::ServiceAlreadyMonitored(human), + -32002 => ClientError::ServiceIsUp(human), + -32003 => ClientError::ServiceIsDown(human), + -32004 => ClientError::InvalidSignal(human), + -32005 => ClientError::ConfigError(human), + -32006 => ClientError::ShuttingDown, + -32007 => ClientError::ServiceAlreadyExists(human), + -32008 => ClientError::ServiceFileError(human), + _ => ClientError::RpcError(human), + }; } match err {