diff --git a/Cargo.lock b/Cargo.lock index 89ee505..33b7e7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3591,7 +3591,7 @@ dependencies = [ [[package]] name = "sunbeam-sdk" -version = "0.8.0" +version = "0.9.0" dependencies = [ "base64", "bytes", diff --git a/sunbeam-sdk/Cargo.toml b/sunbeam-sdk/Cargo.toml index 665881e..6d4b474 100644 --- a/sunbeam-sdk/Cargo.toml +++ b/sunbeam-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sunbeam-sdk" -version = "0.9.0" +version = "0.10.0" edition = "2024" description = "Sunbeam SDK — reusable library for cluster management" repository = "https://src.sunbeam.pt/studio/cli" diff --git a/sunbeam-sdk/src/monitoring/grafana.rs b/sunbeam-sdk/src/monitoring/grafana.rs new file mode 100644 index 0000000..73e0222 --- /dev/null +++ b/sunbeam-sdk/src/monitoring/grafana.rs @@ -0,0 +1,425 @@ +//! Grafana API client. + +use crate::client::{AuthMethod, HttpTransport, ServiceClient}; +use crate::error::Result; +use reqwest::Method; +use super::types::{self, *}; + +/// Client for the Grafana HTTP API (`/api`). +pub struct GrafanaClient { + pub(crate) transport: HttpTransport, +} + +impl ServiceClient for GrafanaClient { + fn service_name(&self) -> &'static str { + "grafana" + } + + fn base_url(&self) -> &str { + &self.transport.base_url + } + + fn from_parts(base_url: String, auth: AuthMethod) -> Self { + Self { + transport: HttpTransport::new(&base_url, auth), + } + } +} + +impl GrafanaClient { + /// Build a GrafanaClient from domain (e.g. `https://grafana.{domain}/api`). + pub fn connect(domain: &str) -> Self { + let base_url = format!("https://grafana.{domain}/api"); + Self::from_parts(base_url, AuthMethod::None) + } + + // -- Dashboards --------------------------------------------------------- + + /// Create a new dashboard. + pub async fn create_dashboard( + &self, + body: &serde_json::Value, + ) -> Result { + self.transport + .json(Method::POST, "dashboards/db", Some(body), "grafana create dashboard") + .await + } + + /// Get a dashboard by UID. + pub async fn get_dashboard(&self, uid: &str) -> Result { + self.transport + .json( + Method::GET, + &format!("dashboards/uid/{uid}"), + Option::<&()>::None, + "grafana get dashboard", + ) + .await + } + + /// Update an existing dashboard (same endpoint as create). + pub async fn update_dashboard( + &self, + body: &serde_json::Value, + ) -> Result { + self.transport + .json(Method::POST, "dashboards/db", Some(body), "grafana update dashboard") + .await + } + + /// Delete a dashboard by UID. + pub async fn delete_dashboard(&self, uid: &str) -> Result<()> { + self.transport + .send( + Method::DELETE, + &format!("dashboards/uid/{uid}"), + Option::<&()>::None, + "grafana delete dashboard", + ) + .await + } + + /// List all dashboards. + pub async fn list_dashboards(&self) -> Result> { + self.transport + .json( + Method::GET, + "search?type=dash-db", + Option::<&()>::None, + "grafana list dashboards", + ) + .await + } + + /// Search dashboards by query. + pub async fn search_dashboards( + &self, + query: &str, + ) -> Result> { + self.transport + .json( + Method::GET, + &format!("search?query={}", types::urlencode(query)), + Option::<&()>::None, + "grafana search dashboards", + ) + .await + } + + // -- Datasources -------------------------------------------------------- + + /// List all datasources. + pub async fn list_datasources(&self) -> Result> { + self.transport + .json(Method::GET, "datasources", Option::<&()>::None, "grafana list datasources") + .await + } + + /// Get a datasource by numeric ID. + pub async fn get_datasource(&self, id: u64) -> Result { + self.transport + .json( + Method::GET, + &format!("datasources/{id}"), + Option::<&()>::None, + "grafana get datasource", + ) + .await + } + + /// Get a datasource by UID. + pub async fn get_datasource_by_uid(&self, uid: &str) -> Result { + self.transport + .json( + Method::GET, + &format!("datasources/uid/{uid}"), + Option::<&()>::None, + "grafana get datasource by uid", + ) + .await + } + + /// Create a new datasource. + pub async fn create_datasource( + &self, + body: &serde_json::Value, + ) -> Result { + // Grafana wraps create response in {"datasource": {...}} + let resp: serde_json::Value = self + .transport + .json(Method::POST, "datasources", Some(body), "grafana create datasource") + .await?; + if let Some(inner) = resp.get("datasource") { + Ok(serde_json::from_value(inner.clone())?) + } else { + Ok(serde_json::from_value(resp)?) + } + } + + /// Update an existing datasource by numeric ID. + pub async fn update_datasource( + &self, + id: u64, + body: &serde_json::Value, + ) -> Result { + // Grafana wraps update response in {"datasource": {...}} + let resp: serde_json::Value = self + .transport + .json( + Method::PUT, + &format!("datasources/{id}"), + Some(body), + "grafana update datasource", + ) + .await?; + if let Some(inner) = resp.get("datasource") { + Ok(serde_json::from_value(inner.clone())?) + } else { + Ok(serde_json::from_value(resp)?) + } + } + + /// Delete a datasource by numeric ID. + pub async fn delete_datasource(&self, id: u64) -> Result<()> { + self.transport + .send( + Method::DELETE, + &format!("datasources/{id}"), + Option::<&()>::None, + "grafana delete datasource", + ) + .await + } + + /// Proxy a request to a datasource. + pub async fn proxy_datasource( + &self, + id: u64, + path: &str, + ) -> Result { + self.transport + .json( + Method::GET, + &format!("datasources/proxy/{id}/{}", path.trim_start_matches('/')), + Option::<&()>::None, + "grafana proxy datasource", + ) + .await + } + + // -- Folders ------------------------------------------------------------ + + /// List all folders. + pub async fn list_folders(&self) -> Result> { + self.transport + .json(Method::GET, "folders", Option::<&()>::None, "grafana list folders") + .await + } + + /// Create a folder. + pub async fn create_folder(&self, body: &serde_json::Value) -> Result { + self.transport + .json(Method::POST, "folders", Some(body), "grafana create folder") + .await + } + + /// Get a folder by UID. + pub async fn get_folder(&self, uid: &str) -> Result { + self.transport + .json( + Method::GET, + &format!("folders/{uid}"), + Option::<&()>::None, + "grafana get folder", + ) + .await + } + + /// Update a folder by UID. + pub async fn update_folder( + &self, + uid: &str, + body: &serde_json::Value, + ) -> Result { + self.transport + .json( + Method::PUT, + &format!("folders/{uid}"), + Some(body), + "grafana update folder", + ) + .await + } + + /// Delete a folder by UID. + pub async fn delete_folder(&self, uid: &str) -> Result<()> { + self.transport + .send( + Method::DELETE, + &format!("folders/{uid}"), + Option::<&()>::None, + "grafana delete folder", + ) + .await + } + + // -- Annotations -------------------------------------------------------- + + /// List annotations with optional filter params. + pub async fn list_annotations( + &self, + params: Option<&str>, + ) -> Result> { + let path = match params { + Some(p) => format!("annotations?{p}"), + None => "annotations".to_string(), + }; + self.transport + .json(Method::GET, &path, Option::<&()>::None, "grafana list annotations") + .await + } + + /// Create an annotation. + pub async fn create_annotation( + &self, + body: &serde_json::Value, + ) -> Result { + self.transport + .json(Method::POST, "annotations", Some(body), "grafana create annotation") + .await + } + + /// Get an annotation by ID. + pub async fn get_annotation(&self, id: u64) -> Result { + self.transport + .json( + Method::GET, + &format!("annotations/{id}"), + Option::<&()>::None, + "grafana get annotation", + ) + .await + } + + /// Update an annotation by ID. + pub async fn update_annotation( + &self, + id: u64, + body: &serde_json::Value, + ) -> Result { + self.transport + .json( + Method::PUT, + &format!("annotations/{id}"), + Some(body), + "grafana update annotation", + ) + .await + } + + /// Delete an annotation by ID. + pub async fn delete_annotation(&self, id: u64) -> Result<()> { + self.transport + .send( + Method::DELETE, + &format!("annotations/{id}"), + Option::<&()>::None, + "grafana delete annotation", + ) + .await + } + + // -- Alerts ------------------------------------------------------------- + + /// Get all provisioned alert rules. + pub async fn get_alert_rules(&self) -> Result> { + self.transport + .json( + Method::GET, + "v1/provisioning/alert-rules", + Option::<&()>::None, + "grafana get alert rules", + ) + .await + } + + /// Create a provisioned alert rule. + pub async fn create_alert_rule( + &self, + body: &serde_json::Value, + ) -> Result { + self.transport + .json( + Method::POST, + "v1/provisioning/alert-rules", + Some(body), + "grafana create alert rule", + ) + .await + } + + /// Update a provisioned alert rule by UID. + pub async fn update_alert_rule( + &self, + uid: &str, + body: &serde_json::Value, + ) -> Result { + self.transport + .json( + Method::PUT, + &format!("v1/provisioning/alert-rules/{uid}"), + Some(body), + "grafana update alert rule", + ) + .await + } + + /// Delete a provisioned alert rule by UID. + pub async fn delete_alert_rule(&self, uid: &str) -> Result<()> { + self.transport + .send( + Method::DELETE, + &format!("v1/provisioning/alert-rules/{uid}"), + Option::<&()>::None, + "grafana delete alert rule", + ) + .await + } + + // -- Org ---------------------------------------------------------------- + + /// Get the current organization. + pub async fn get_current_org(&self) -> Result { + self.transport + .json(Method::GET, "org", Option::<&()>::None, "grafana get current org") + .await + } + + /// Update the current organization. + pub async fn update_org(&self, body: &serde_json::Value) -> Result<()> { + self.transport + .send(Method::PUT, "org", Some(body), "grafana update org") + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_connect_url() { + let c = GrafanaClient::connect("sunbeam.pt"); + assert_eq!(c.base_url(), "https://grafana.sunbeam.pt/api"); + assert_eq!(c.service_name(), "grafana"); + } + + #[test] + fn test_from_parts() { + let c = GrafanaClient::from_parts( + "http://localhost:3000/api".into(), + AuthMethod::None, + ); + assert_eq!(c.base_url(), "http://localhost:3000/api"); + } +} diff --git a/sunbeam-sdk/src/monitoring/loki.rs b/sunbeam-sdk/src/monitoring/loki.rs new file mode 100644 index 0000000..07c5e9e --- /dev/null +++ b/sunbeam-sdk/src/monitoring/loki.rs @@ -0,0 +1,269 @@ +//! Loki API client. + +use crate::client::{AuthMethod, HttpTransport, ServiceClient}; +use crate::error::Result; +use reqwest::Method; +use super::types::{self, *}; + +/// Client for the Loki HTTP API (`/loki/api/v1`). +pub struct LokiClient { + pub(crate) transport: HttpTransport, +} + +impl ServiceClient for LokiClient { + fn service_name(&self) -> &'static str { + "loki" + } + + fn base_url(&self) -> &str { + &self.transport.base_url + } + + fn from_parts(base_url: String, auth: AuthMethod) -> Self { + Self { + transport: HttpTransport::new(&base_url, auth), + } + } +} + +impl LokiClient { + /// Build a LokiClient from domain (e.g. `https://loki.{domain}/loki/api/v1`). + pub fn connect(domain: &str) -> Self { + let base_url = format!("https://loki.{domain}/loki/api/v1"); + Self::from_parts(base_url, AuthMethod::None) + } + + // -- Query -------------------------------------------------------------- + + /// Execute an instant query. + pub async fn query( + &self, + query: &str, + limit: Option, + time: Option<&str>, + ) -> Result { + let mut path = format!("query?query={}", types::urlencode(query)); + if let Some(l) = limit { + path.push_str(&format!("&limit={l}")); + } + if let Some(t) = time { + path.push_str(&format!("&time={t}")); + } + self.transport + .json(Method::GET, &path, Option::<&()>::None, "loki query") + .await + } + + /// Execute a range query. + pub async fn query_range( + &self, + query: &str, + start: &str, + end: &str, + limit: Option, + step: Option<&str>, + ) -> Result { + let mut path = format!( + "query_range?query={}&start={start}&end={end}", + types::urlencode(query), + ); + if let Some(l) = limit { + path.push_str(&format!("&limit={l}")); + } + if let Some(s) = step { + path.push_str(&format!("&step={s}")); + } + self.transport + .json(Method::GET, &path, Option::<&()>::None, "loki query_range") + .await + } + + /// Get all label names. + pub async fn labels( + &self, + start: Option<&str>, + end: Option<&str>, + ) -> Result>> { + let mut path = String::from("labels"); + let mut sep = '?'; + if let Some(s) = start { + path.push_str(&format!("{sep}start={s}")); + sep = '&'; + } + if let Some(e) = end { + path.push_str(&format!("{sep}end={e}")); + } + self.transport + .json(Method::GET, &path, Option::<&()>::None, "loki labels") + .await + } + + /// Get values for a specific label. + pub async fn label_values( + &self, + label: &str, + start: Option<&str>, + end: Option<&str>, + ) -> Result>> { + let mut path = format!("label/{label}/values"); + let mut sep = '?'; + if let Some(s) = start { + path.push_str(&format!("{sep}start={s}")); + sep = '&'; + } + if let Some(e) = end { + path.push_str(&format!("{sep}end={e}")); + } + self.transport + .json(Method::GET, &path, Option::<&()>::None, "loki label values") + .await + } + + /// Find series matching label matchers. + pub async fn series( + &self, + match_params: &[&str], + start: Option<&str>, + end: Option<&str>, + ) -> Result>> { + let mut path = String::from("series?"); + for (i, m) in match_params.iter().enumerate() { + if i > 0 { + path.push('&'); + } + path.push_str(&format!("match[]={}", types::urlencode(m))); + } + if let Some(s) = start { + path.push_str(&format!("&start={s}")); + } + if let Some(e) = end { + path.push_str(&format!("&end={e}")); + } + self.transport + .json(Method::GET, &path, Option::<&()>::None, "loki series") + .await + } + + // -- Index -------------------------------------------------------------- + + /// Get index statistics. + pub async fn index_stats(&self) -> Result { + self.transport + .json(Method::GET, "index/stats", Option::<&()>::None, "loki index stats") + .await + } + + /// Get index volume for a query. + pub async fn index_volume( + &self, + query: &str, + start: Option<&str>, + end: Option<&str>, + ) -> Result { + let mut path = format!("index/volume?query={}", types::urlencode(query)); + if let Some(s) = start { + path.push_str(&format!("&start={s}")); + } + if let Some(e) = end { + path.push_str(&format!("&end={e}")); + } + self.transport + .json(Method::GET, &path, Option::<&()>::None, "loki index volume") + .await + } + + /// Get index volume range for a query. + pub async fn index_volume_range( + &self, + query: &str, + start: &str, + end: &str, + step: Option<&str>, + ) -> Result { + let mut path = format!( + "index/volume_range?query={}&start={start}&end={end}", + types::urlencode(query), + ); + if let Some(s) = step { + path.push_str(&format!("&step={s}")); + } + self.transport + .json(Method::GET, &path, Option::<&()>::None, "loki index volume_range") + .await + } + + // -- Patterns ----------------------------------------------------------- + + /// Detect log patterns. + pub async fn detect_patterns( + &self, + query: &str, + start: Option<&str>, + end: Option<&str>, + ) -> Result { + let mut path = format!("patterns?query={}", types::urlencode(query)); + if let Some(s) = start { + path.push_str(&format!("&start={s}")); + } + if let Some(e) = end { + path.push_str(&format!("&end={e}")); + } + self.transport + .json(Method::GET, &path, Option::<&()>::None, "loki detect patterns") + .await + } + + // -- Ingest ------------------------------------------------------------- + + /// Push log entries. + pub async fn push(&self, body: &serde_json::Value) -> Result<()> { + self.transport + .send(Method::POST, "push", Some(body), "loki push") + .await + } + + // -- Status ------------------------------------------------------------- + + /// Check readiness. Note: Loki's `/ready` is at the server root, + /// not under the API prefix. + pub async fn ready(&self) -> Result { + // Build URL from base by stripping the API path suffix + let base = self.transport.base_url.trim_end_matches("/loki/api/v1"); + let url = format!("{base}/ready"); + let resp = self + .transport + .http + .get(&url) + .send() + .await + .map_err(|e| crate::error::SunbeamError::network(format!("loki ready: {e}")))?; + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(crate::error::SunbeamError::network(format!("loki ready: {body}"))); + } + Ok(ReadyStatus { + status: Some("ready".into()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_connect_url() { + let c = LokiClient::connect("sunbeam.pt"); + assert_eq!(c.base_url(), "https://loki.sunbeam.pt/loki/api/v1"); + assert_eq!(c.service_name(), "loki"); + } + + #[test] + fn test_from_parts() { + let c = LokiClient::from_parts( + "http://localhost:3100/loki/api/v1".into(), + AuthMethod::None, + ); + assert_eq!(c.base_url(), "http://localhost:3100/loki/api/v1"); + } +} diff --git a/sunbeam-sdk/src/monitoring/mod.rs b/sunbeam-sdk/src/monitoring/mod.rs new file mode 100644 index 0000000..13cd461 --- /dev/null +++ b/sunbeam-sdk/src/monitoring/mod.rs @@ -0,0 +1,10 @@ +//! Monitoring service clients: Prometheus, Loki, and Grafana. + +pub mod grafana; +pub mod loki; +pub mod prometheus; +pub mod types; + +pub use grafana::GrafanaClient; +pub use loki::LokiClient; +pub use prometheus::PrometheusClient; diff --git a/sunbeam-sdk/src/monitoring/prometheus.rs b/sunbeam-sdk/src/monitoring/prometheus.rs new file mode 100644 index 0000000..32a089f --- /dev/null +++ b/sunbeam-sdk/src/monitoring/prometheus.rs @@ -0,0 +1,268 @@ +//! Prometheus API client. + +use crate::client::{AuthMethod, HttpTransport, ServiceClient}; +use crate::error::Result; +use reqwest::Method; +use super::types::{self, *}; + +/// Client for the Prometheus HTTP API (`/api/v1`). +pub struct PrometheusClient { + pub(crate) transport: HttpTransport, +} + +impl ServiceClient for PrometheusClient { + fn service_name(&self) -> &'static str { + "prometheus" + } + + fn base_url(&self) -> &str { + &self.transport.base_url + } + + fn from_parts(base_url: String, auth: AuthMethod) -> Self { + Self { + transport: HttpTransport::new(&base_url, auth), + } + } +} + +impl PrometheusClient { + /// Build a PrometheusClient from domain (e.g. `https://prometheus.{domain}/api/v1`). + pub fn connect(domain: &str) -> Self { + let base_url = format!("https://prometheus.{domain}/api/v1"); + Self::from_parts(base_url, AuthMethod::None) + } + + // -- Query -------------------------------------------------------------- + + /// Execute an instant query. + pub async fn query( + &self, + query: &str, + time: Option<&str>, + ) -> Result { + let mut path = format!("query?query={}", types::urlencode(query)); + if let Some(t) = time { + path.push_str(&format!("&time={t}")); + } + self.transport + .json(Method::GET, &path, Option::<&()>::None, "prometheus query") + .await + } + + /// Execute a range query. + pub async fn query_range( + &self, + query: &str, + start: &str, + end: &str, + step: &str, + ) -> Result { + let path = format!( + "query_range?query={}&start={start}&end={end}&step={step}", + types::urlencode(query), + ); + self.transport + .json(Method::GET, &path, Option::<&()>::None, "prometheus query_range") + .await + } + + /// Format a PromQL expression. + pub async fn format_query(&self, query: &str) -> Result { + let path = format!("format_query?query={}", types::urlencode(query)); + self.transport + .json(Method::GET, &path, Option::<&()>::None, "prometheus format_query") + .await + } + + // -- Metadata ----------------------------------------------------------- + + /// Find series matching label matchers. + pub async fn series( + &self, + match_params: &[&str], + start: Option<&str>, + end: Option<&str>, + ) -> Result>> { + let mut path = String::from("series?"); + for (i, m) in match_params.iter().enumerate() { + if i > 0 { + path.push('&'); + } + path.push_str(&format!("match[]={}", types::urlencode(m))); + } + if let Some(s) = start { + path.push_str(&format!("&start={s}")); + } + if let Some(e) = end { + path.push_str(&format!("&end={e}")); + } + self.transport + .json(Method::GET, &path, Option::<&()>::None, "prometheus series") + .await + } + + /// Get all label names. + pub async fn labels( + &self, + start: Option<&str>, + end: Option<&str>, + ) -> Result>> { + let mut path = String::from("labels"); + let mut sep = '?'; + if let Some(s) = start { + path.push_str(&format!("{sep}start={s}")); + sep = '&'; + } + if let Some(e) = end { + path.push_str(&format!("{sep}end={e}")); + } + self.transport + .json(Method::GET, &path, Option::<&()>::None, "prometheus labels") + .await + } + + /// Get values for a specific label. + pub async fn label_values( + &self, + label: &str, + start: Option<&str>, + end: Option<&str>, + ) -> Result>> { + let mut path = format!("label/{label}/values"); + let mut sep = '?'; + if let Some(s) = start { + path.push_str(&format!("{sep}start={s}")); + sep = '&'; + } + if let Some(e) = end { + path.push_str(&format!("{sep}end={e}")); + } + self.transport + .json(Method::GET, &path, Option::<&()>::None, "prometheus label values") + .await + } + + /// Get metadata about metrics scraped by targets. + pub async fn targets_metadata( + &self, + metric: Option<&str>, + ) -> Result>> { + let mut path = String::from("targets/metadata"); + if let Some(m) = metric { + path.push_str(&format!("?metric={}", types::urlencode(m))); + } + self.transport + .json(Method::GET, &path, Option::<&()>::None, "prometheus targets metadata") + .await + } + + /// Get per-metric metadata. + pub async fn metadata( + &self, + metric: Option<&str>, + ) -> Result> { + let mut path = String::from("metadata"); + if let Some(m) = metric { + path.push_str(&format!("?metric={}", types::urlencode(m))); + } + self.transport + .json(Method::GET, &path, Option::<&()>::None, "prometheus metadata") + .await + } + + // -- Infrastructure ----------------------------------------------------- + + /// Get current target discovery status. + pub async fn targets(&self) -> Result { + self.transport + .json(Method::GET, "targets", Option::<&()>::None, "prometheus targets") + .await + } + + /// List scrape pools. + pub async fn scrape_pools(&self) -> Result> { + self.transport + .json(Method::GET, "scrape_pools", Option::<&()>::None, "prometheus scrape_pools") + .await + } + + /// Get discovered Alertmanager instances. + pub async fn alertmanagers(&self) -> Result> { + self.transport + .json(Method::GET, "alertmanagers", Option::<&()>::None, "prometheus alertmanagers") + .await + } + + /// Get alerting and recording rules. + pub async fn rules(&self) -> Result { + self.transport + .json(Method::GET, "rules", Option::<&()>::None, "prometheus rules") + .await + } + + /// Get active alerts. + pub async fn alerts(&self) -> Result { + self.transport + .json(Method::GET, "alerts", Option::<&()>::None, "prometheus alerts") + .await + } + + // -- Status ------------------------------------------------------------- + + /// Get Prometheus configuration. + pub async fn config(&self) -> Result { + self.transport + .json(Method::GET, "status/config", Option::<&()>::None, "prometheus config") + .await + } + + /// Get command-line flags. + pub async fn flags(&self) -> Result> { + self.transport + .json(Method::GET, "status/flags", Option::<&()>::None, "prometheus flags") + .await + } + + /// Get runtime information. + pub async fn runtime_info(&self) -> Result> { + self.transport + .json(Method::GET, "status/runtimeinfo", Option::<&()>::None, "prometheus runtimeinfo") + .await + } + + /// Get build information. + pub async fn build_info(&self) -> Result> { + self.transport + .json(Method::GET, "status/buildinfo", Option::<&()>::None, "prometheus buildinfo") + .await + } + + /// Get TSDB statistics. + pub async fn tsdb(&self) -> Result> { + self.transport + .json(Method::GET, "status/tsdb", Option::<&()>::None, "prometheus tsdb") + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_connect_url() { + let c = PrometheusClient::connect("sunbeam.pt"); + assert_eq!(c.base_url(), "https://prometheus.sunbeam.pt/api/v1"); + assert_eq!(c.service_name(), "prometheus"); + } + + #[test] + fn test_from_parts() { + let c = PrometheusClient::from_parts( + "http://localhost:9090/api/v1".into(), + AuthMethod::None, + ); + assert_eq!(c.base_url(), "http://localhost:9090/api/v1"); + } +} diff --git a/sunbeam-sdk/src/monitoring/types.rs b/sunbeam-sdk/src/monitoring/types.rs new file mode 100644 index 0000000..da63505 --- /dev/null +++ b/sunbeam-sdk/src/monitoring/types.rs @@ -0,0 +1,361 @@ +//! Shared types for monitoring clients (Prometheus, Loki, Grafana). + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Prometheus / Loki shared response wrapper +// --------------------------------------------------------------------------- + +/// Standard Prometheus/Loki API response envelope. +/// +/// Both APIs return `{"status":"success","data":{...}}` on success. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiResponse { + pub status: String, + pub data: Option, + #[serde(default, rename = "errorType")] + pub error_type: Option, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub warnings: Option>, +} + +// --------------------------------------------------------------------------- +// Prometheus types +// --------------------------------------------------------------------------- + +/// Result of an instant or range query. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryData { + pub result_type: String, + pub result: serde_json::Value, +} + +/// Alias: Prometheus/Loki query result is an `ApiResponse`. +pub type QueryResult = ApiResponse; + +/// Formatted PromQL query. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FormattedQuery { + pub status: String, + pub data: String, +} + +/// Prometheus targets response. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TargetsData { + pub active_targets: Vec, + #[serde(default)] + pub dropped_targets: Vec, +} + +pub type TargetsResult = ApiResponse; + +/// Prometheus rules response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RulesData { + pub groups: Vec, +} + +pub type RulesResult = ApiResponse; + +/// Prometheus alerts response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlertsData { + pub alerts: Vec, +} + +pub type AlertsResult = ApiResponse; + +/// Prometheus config response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigData { + pub yaml: String, +} + +pub type ConfigResult = ApiResponse; + +// --------------------------------------------------------------------------- +// Loki types +// --------------------------------------------------------------------------- + +/// Loki readiness status. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReadyStatus { + #[serde(default)] + pub status: Option, +} + +// --------------------------------------------------------------------------- +// Grafana types +// --------------------------------------------------------------------------- + +/// Grafana dashboard create/update response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardResponse { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub uid: Option, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub slug: Option, + /// Present on GET responses. + #[serde(default)] + pub dashboard: Option, + #[serde(default)] + pub meta: Option, +} + +/// Grafana dashboard search result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardSearchResult { + pub id: u64, + pub uid: String, + pub title: String, + #[serde(default)] + pub url: Option, + #[serde(default, rename = "type")] + pub kind: Option, + #[serde(default)] + pub uri: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub is_starred: Option, + #[serde(default, rename = "folderUid")] + pub folder_uid: Option, +} + +/// Grafana datasource. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Datasource { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub uid: Option, + pub name: String, + #[serde(rename = "type")] + pub kind: String, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub access: Option, + #[serde(default)] + pub is_default: Option, + #[serde(default)] + pub json_data: Option, + #[serde(default)] + pub database: Option, +} + +/// Grafana folder. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Folder { + pub id: u64, + pub uid: String, + pub title: String, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub created: Option, + #[serde(default)] + pub updated: Option, +} + +/// Grafana annotation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Annotation { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub dashboard_id: Option, + #[serde(default)] + pub panel_id: Option, + #[serde(default)] + pub time: Option, + #[serde(default)] + pub time_end: Option, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub tags: Vec, +} + +/// Grafana annotation create/update response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnnotationResponse { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub message: Option, +} + +/// Grafana alert rule. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlertRule { + #[serde(default)] + pub uid: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub condition: Option, + #[serde(default)] + pub data: Option>, + #[serde(default, rename = "folderUID")] + pub folder_uid: Option, + #[serde(default, rename = "ruleGroup")] + pub rule_group: Option, + #[serde(default, rename = "for")] + pub for_duration: Option, + #[serde(default)] + pub labels: Option, + #[serde(default)] + pub annotations: Option, +} + +/// Grafana organization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Organization { + pub id: u64, + pub name: String, + #[serde(default)] + pub address: Option, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Minimal percent-encoding for URL query parameters. +pub(super) fn urlencode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + _ => { + out.push_str(&format!("%{:02X}", b)); + } + } + } + out +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_api_response_deserialize_success() { + let json = r#"{"status":"success","data":{"resultType":"vector","result":[]}}"#; + let resp: ApiResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.status, "success"); + assert_eq!(resp.data.unwrap().result_type, "vector"); + } + + #[test] + fn test_api_response_deserialize_with_warnings() { + let json = r#"{ + "status": "success", + "data": {"resultType":"matrix","result":[]}, + "warnings": ["some warning"] + }"#; + let resp: QueryResult = serde_json::from_str(json).unwrap(); + assert_eq!(resp.warnings.unwrap().len(), 1); + } + + #[test] + fn test_api_response_deserialize_error() { + let json = r#"{ + "status": "error", + "errorType": "bad_data", + "error": "invalid query", + "data": {"resultType":"","result":[]} + }"#; + let resp: QueryResult = serde_json::from_str(json).unwrap(); + assert_eq!(resp.status, "error"); + assert_eq!(resp.error_type.unwrap(), "bad_data"); + } + + #[test] + fn test_dashboard_search_result_roundtrip() { + let item = DashboardSearchResult { + id: 1, + uid: "abc123".into(), + title: "My Dashboard".into(), + url: Some("/d/abc123".into()), + kind: Some("dash-db".into()), + uri: None, + tags: vec!["prod".into()], + is_starred: Some(false), + folder_uid: None, + }; + let json = serde_json::to_string(&item).unwrap(); + let back: DashboardSearchResult = serde_json::from_str(&json).unwrap(); + assert_eq!(back.uid, "abc123"); + } + + #[test] + fn test_datasource_roundtrip() { + let ds = Datasource { + id: Some(1), + uid: Some("prom".into()), + name: "Prometheus".into(), + kind: "prometheus".into(), + url: Some("http://prometheus:9090".into()), + access: Some("proxy".into()), + is_default: Some(true), + json_data: None, + database: None, + }; + let json = serde_json::to_string(&ds).unwrap(); + let back: Datasource = serde_json::from_str(&json).unwrap(); + assert_eq!(back.name, "Prometheus"); + } + + #[test] + fn test_ready_status_minimal() { + let json = r#"{}"#; + let rs: ReadyStatus = serde_json::from_str(json).unwrap(); + assert!(rs.status.is_none()); + } + + #[test] + fn test_config_result_deserialize() { + let json = r#"{"status":"success","data":{"yaml":"global:\n scrape_interval: 15s"}}"#; + let resp: ConfigResult = serde_json::from_str(json).unwrap(); + assert!(resp.data.unwrap().yaml.contains("scrape_interval")); + } + + #[test] + fn test_alert_rule_roundtrip() { + let rule = AlertRule { + uid: Some("rule-1".into()), + title: Some("High CPU".into()), + condition: Some("A".into()), + data: None, + folder_uid: Some("folder-1".into()), + rule_group: Some("group-1".into()), + for_duration: Some("5m".into()), + labels: None, + annotations: None, + }; + let json = serde_json::to_string(&rule).unwrap(); + let back: AlertRule = serde_json::from_str(&json).unwrap(); + assert_eq!(back.uid.unwrap(), "rule-1"); + } +}