diff --git a/zebrad/Cargo.toml b/zebrad/Cargo.toml index 72a8afd7dc0..153abf5d618 100644 --- a/zebrad/Cargo.toml +++ b/zebrad/Cargo.toml @@ -89,6 +89,7 @@ elasticsearch = [ sentry = ["dep:sentry"] journald = ["tracing-journald"] filter-reload = ["hyper", "http-body-util", "hyper-util", "bytes"] +health-endpoint = ["hyper", "hyper-util", "hyper-util/tokio", "serde/derive", "chrono/serde", "serde_json"] progress-bar = [ "howudoin", @@ -234,6 +235,9 @@ bytes = { version = "1.8.0", optional = true } # prod feature prometheus metrics-exporter-prometheus = { version = "0.16.0", default-features = false, features = ["http-listener"], optional = true } +# zebra-rpc needs the preserve_order feature, it also makes test results more stable +serde_json = { version = "1.0.132", features = ["preserve_order"], optional = true } + # prod feature release_max_level_info # # zebrad uses tracing for logging, @@ -266,8 +270,6 @@ once_cell = "1.20.2" regex = "1.11.0" insta = { version = "1.40.0", features = ["json"] } -# zebra-rpc needs the preserve_order feature, it also makes test results more stable -serde_json = { version = "1.0.132", features = ["preserve_order"] } tempfile = "3.13.0" hyper = { version = "1.5.0", features = ["http1", "http2", "server"]} @@ -286,6 +288,10 @@ proptest-derive = "0.5.0" # enable span traces and track caller in tests color-eyre = { version = "0.6.3" } +# Make serde_json available for tests regardless of feature flags +# (while keeping it optional in [dependencies] for production builds) +serde_json = { version = "1.0.132", features = ["preserve_order"] } + zebra-chain = { path = "../zebra-chain", version = "1.0.0-beta.41", features = ["proptest-impl"] } zebra-consensus = { path = "../zebra-consensus", version = "1.0.0-beta.41", features = ["proptest-impl"] } zebra-network = { path = "../zebra-network", version = "1.0.0-beta.41", features = ["proptest-impl"] } diff --git a/zebrad/src/application.rs b/zebrad/src/application.rs index 5794e7dc556..ce36f912203 100644 --- a/zebrad/src/application.rs +++ b/zebrad/src/application.rs @@ -229,6 +229,8 @@ impl Application for ZebradApp { #[allow(clippy::print_stderr)] #[allow(clippy::unwrap_in_result)] fn register_components(&mut self, command: &Self::Cmd) -> Result<(), FrameworkError> { + #[cfg(feature = "health-endpoint")] + use crate::components::health::HealthEndpoint; use crate::components::{ metrics::MetricsEndpoint, tokio::TokioComponent, tracing::TracingEndpoint, }; @@ -482,6 +484,8 @@ impl Application for ZebradApp { components.push(Box::new(TokioComponent::new()?)); components.push(Box::new(TracingEndpoint::new(cfg_ref)?)); components.push(Box::new(MetricsEndpoint::new(&metrics_config)?)); + #[cfg(feature = "health-endpoint")] + components.push(Box::new(HealthEndpoint::new(&config.health)?)); } self.state.components_mut().register(components)?; diff --git a/zebrad/src/components.rs b/zebrad/src/components.rs index 43b051f1209..d6c4a10340a 100644 --- a/zebrad/src/components.rs +++ b/zebrad/src/components.rs @@ -5,6 +5,8 @@ //! component and dependency injection models are designed to work together, but //! don't fit the async context well. +#[cfg(feature = "health-endpoint")] +pub mod health; pub mod inbound; #[allow(missing_docs)] pub mod mempool; diff --git a/zebrad/src/components/health.rs b/zebrad/src/components/health.rs new file mode 100644 index 00000000000..e297e35a643 --- /dev/null +++ b/zebrad/src/components/health.rs @@ -0,0 +1,110 @@ +//! A simple HTTP health endpoint for Zebra. +use abscissa_core::{Component, FrameworkError}; +use hyper::body::Incoming; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Method, Request, Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use std::convert::Infallible; +use std::net::SocketAddr; +use tracing::{error, info}; +/// Abscissa component which runs a health endpoint. +#[derive(Debug, Component)] +pub struct HealthEndpoint {} +impl HealthEndpoint { + /// Create the component. + pub fn new(config: &Config) -> Result { + if let Some(addr) = config.endpoint_addr { + info!("Trying to open health endpoint at {}...", addr); + // Start the health endpoint server in a separate thread to avoid Tokio runtime issues + std::thread::spawn(move || match tokio::runtime::Runtime::new() { + Ok(rt) => { + rt.block_on(async { + if let Err(e) = Self::run_server(addr).await { + error!("Health endpoint server failed: {}", e); + } + }); + } + Err(e) => { + error!("Failed to create Tokio runtime for health endpoint: {}", e); + } + }); + info!("Opened health endpoint at {}", addr); + } + Ok(Self {}) + } + async fn run_server(addr: SocketAddr) -> Result<(), Box> { + let listener = tokio::net::TcpListener::bind(addr).await?; + + loop { + let (stream, _) = listener.accept().await?; + let io = hyper_util::rt::TokioIo::new(stream); + + tokio::spawn(async move { + if let Err(err) = http1::Builder::new() + .serve_connection(io, service_fn(Self::handle_request)) + .await + { + error!("Failed to serve connection: {}", err); + } + }); + } + } + async fn handle_request(req: Request) -> Result, Infallible> { + match (req.method(), req.uri().path()) { + (&Method::GET, "/health") => { + let health_info = HealthInfo { + status: "healthy".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + git_tag: option_env!("GIT_TAG").unwrap_or("unknown").to_string(), + git_commit: option_env!("GIT_COMMIT_FULL") + .unwrap_or("unknown") + .to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + }; + let response_body = + serde_json::to_string_pretty(&health_info).unwrap_or_else(|_| { + "{\"error\": \"Failed to serialize health info\"}".to_string() + }); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(response_body) + .expect("response should build successfully")) + } + (_, "/health") => Ok(Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .header("Allow", "GET") + .header("Content-Type", "application/json") + .body("{\"error\": \"Method Not Allowed\"}".to_string()) + .expect("response should build successfully")), + _ => Ok(Response::builder() + .status(StatusCode::NOT_FOUND) + .header("Content-Type", "application/json") + .body("{\"error\": \"Not Found\"}".to_string()) + .expect("response should build successfully")), + } + } +} +/// Health information response. +#[derive(Debug, Serialize)] +struct HealthInfo { + status: String, + version: String, + git_tag: String, + git_commit: String, + timestamp: String, +} +/// Health endpoint configuration section. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct Config { + /// The address to bind the health endpoint to + pub endpoint_addr: Option, +} +impl Default for Config { + fn default() -> Self { + Self { + endpoint_addr: Some(SocketAddr::from(([127, 0, 0, 1], 8080))), + } + } +} diff --git a/zebrad/src/config.rs b/zebrad/src/config.rs index a6174599ef8..21c7e20b2ae 100644 --- a/zebrad/src/config.rs +++ b/zebrad/src/config.rs @@ -51,6 +51,10 @@ pub struct ZebradConfig { /// RPC configuration pub rpc: zebra_rpc::config::Config, + #[cfg(feature = "health-endpoint")] + /// Health endpoint configuration + pub health: crate::components::health::Config, + #[serde(skip_serializing_if = "zebra_rpc::config::mining::Config::skip_getblocktemplate")] /// Mining configuration pub mining: zebra_rpc::config::mining::Config,