From cbe9078fb07caac65ca26bde37cd857aa1a4ebd4 Mon Sep 17 00:00:00 2001 From: Dmitry Demin Date: Tue, 26 Aug 2025 22:19:40 +0200 Subject: [PATCH 1/5] Add gethealthinfo RPC method and HTTP /health endpoint --- zebra-rpc/Cargo.toml | 3 ++ zebra-rpc/src/methods.rs | 68 +++++++++++++++++++++++++ zebra-rpc/src/methods/tests.rs | 1 + zebra-rpc/src/methods/tests/snapshot.rs | 19 +++++++ zebra-rpc/src/server.rs | 9 +++- zebrad/Cargo.toml | 7 ++- zebrad/tests/acceptance.rs | 29 +++++++++++ 7 files changed, 133 insertions(+), 3 deletions(-) diff --git a/zebra-rpc/Cargo.toml b/zebra-rpc/Cargo.toml index 1562a77677e..4dfae1f97ea 100644 --- a/zebra-rpc/Cargo.toml +++ b/zebra-rpc/Cargo.toml @@ -40,6 +40,9 @@ getblocktemplate-rpcs = [ "zebra-chain/getblocktemplate-rpcs", ] +# Health RPC support +gethealthinfo-rpc = [] + # Experimental internal miner support internal-miner = [] diff --git a/zebra-rpc/src/methods.rs b/zebra-rpc/src/methods.rs index 8becc5bb79c..fc6a34479d5 100644 --- a/zebra-rpc/src/methods.rs +++ b/zebra-rpc/src/methods.rs @@ -59,6 +59,23 @@ mod tests; #[rpc(server)] /// RPC method signatures. pub trait Rpc { + // TODO: Consider mentioning this new method in the zcashd RPC docs and add a reference to it here. + /// Returns node health and build metadata, as a [`GetHealthInfo`] JSON struct. + /// + /// zcashd reference: none + /// method: post + /// tags: control + /// + /// # Notes + /// + /// - This method provides a simple liveness/readiness signal and basic build info. + /// - When the HTTP health endpoint is enabled via `ServerBuilder::health_api`, + /// it is also available as `GET /health` (no parameters). + // NOTE: We can’t put a cfg with a feature flag on this method because #[rpc] ignores it. + // So we have to provide a dummy struct and a dummy impl below to support builds without the feature. + #[rpc(name = "gethealthinfo")] + fn get_health_info(&self) -> Result; + #[rpc(name = "getinfo")] /// Returns software information from the RPC server, as a [`GetInfo`] JSON struct. /// @@ -506,6 +523,17 @@ where State::Future: Send, Tip: ChainTip + Clone + Send + Sync + 'static, { + #[cfg(feature = "gethealthinfo-rpc")] + fn get_health_info(&self) -> Result { + Ok(GetHealthInfo::snapshot()) + } + + // Dummy type so the trait always compiles when the feature is off. + #[cfg(not(feature = "gethealthinfo-rpc"))] + fn get_health_info(&self) -> Result { + Err(jsonrpc_core::Error::method_not_found()) + } + fn get_info(&self) -> Result { let response = GetInfo { build: self.build_version.clone(), @@ -1396,6 +1424,46 @@ where .ok_or_server_error("No blocks in state") } +/// Response to a `gethealthinfo` RPC request. +/// +/// See the notes for the [`Rpc::get_health_info` method]. +#[cfg(feature = "gethealthinfo-rpc")] +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct GetHealthInfo { + /// Static health status string. + status: String, + /// Zebra package version + version: String, + /// Build Git tag (if available). + git_tag: String, + /// Full Git commit hash (if available). + git_commit: String, + /// Server timestamp in RFC 3339 format. + timestamp: String, +} + +// Dummy type so the trait always compiles when the feature is off. +#[cfg(not(feature = "gethealthinfo-rpc"))] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct GetHealthInfo; + +#[cfg(feature = "gethealthinfo-rpc")] +impl GetHealthInfo { + /// Creates a snapshot of the node's health and build metadata. + #[inline] + pub fn snapshot() -> Self { + Self { + 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(), + } + } +} + /// Response to a `getinfo` RPC request. /// /// See the notes for the [`Rpc::get_info` method]. diff --git a/zebra-rpc/src/methods/tests.rs b/zebra-rpc/src/methods/tests.rs index f98d41d6fb3..585c6102d5f 100644 --- a/zebra-rpc/src/methods/tests.rs +++ b/zebra-rpc/src/methods/tests.rs @@ -1,5 +1,6 @@ //! Test code for RPC methods +mod method_names; mod prop; mod snapshot; #[cfg(feature = "getblocktemplate-rpcs")] diff --git a/zebra-rpc/src/methods/tests/snapshot.rs b/zebra-rpc/src/methods/tests/snapshot.rs index f4d7804088e..e9d0a441933 100644 --- a/zebra-rpc/src/methods/tests/snapshot.rs +++ b/zebra-rpc/src/methods/tests/snapshot.rs @@ -217,6 +217,15 @@ async fn test_rpc_response_data_for_network(network: &Network) { return; } + // `gethealthinfo` + #[cfg(feature = "gethealthinfo-rpc")] + { + let get_health_info = rpc + .get_health_info() + .expect("We should have a GetHealthInfo struct"); + snapshot_rpc_gethealthinfo(get_health_info, &settings); + } + // `getinfo` let get_info = rpc.get_info().expect("We should have a GetInfo struct"); snapshot_rpc_getinfo(get_info, &settings); @@ -538,6 +547,16 @@ async fn test_mocked_rpc_response_data_for_network(network: &Network) { }); } +/// Snapshot `gethealthinfo` response, using `cargo insta` and JSON serialization. +#[cfg(feature = "gethealthinfo-rpc")] +fn snapshot_rpc_gethealthinfo(info: GetHealthInfo, settings: &insta::Settings) { + // Snapshot only the `status` field since other fields vary per build/run. + let status_only = serde_json::json!({ "status": info.status }); + settings.bind(|| { + insta::assert_json_snapshot!("get_health_info_status", status_only); + }); +} + /// Snapshot `getinfo` response, using `cargo insta` and JSON serialization. fn snapshot_rpc_getinfo(info: GetInfo, settings: &insta::Settings) { settings.bind(|| { diff --git a/zebra-rpc/src/server.rs b/zebra-rpc/src/server.rs index 73fcde65f6b..30addee0be9 100644 --- a/zebra-rpc/src/server.rs +++ b/zebra-rpc/src/server.rs @@ -213,11 +213,16 @@ impl RpcServer { // Use a different tokio executor from the rest of Zebra, // so that large RPCs and any task handling bugs don't impact Zebra. - let server_instance = ServerBuilder::new(io) + let builder = ServerBuilder::new(io) .threads(parallel_cpu_threads) // TODO: disable this security check if we see errors from lightwalletd //.allowed_hosts(DomainsValidation::Disabled) - .request_middleware(middleware) + .request_middleware(middleware); + + #[cfg(feature = "gethealthinfo-rpc")] + let builder = builder.health_api(("health", "gethealthinfo")); + + let server_instance = builder .start_http(&listen_addr) .expect("Unable to start RPC server"); diff --git a/zebrad/Cargo.toml b/zebrad/Cargo.toml index 72a8afd7dc0..2086810d71f 100644 --- a/zebrad/Cargo.toml +++ b/zebrad/Cargo.toml @@ -52,7 +52,7 @@ features = [ [features] # In release builds, don't compile debug logging code, to improve performance. -default = ["release_max_level_info", "progress-bar", "getblocktemplate-rpcs"] +default = ["release_max_level_info", "progress-bar", "getblocktemplate-rpcs", "gethealthinfo-rpc"] # Default features for official ZF binary release builds default-release-binaries = ["default", "sentry"] @@ -71,6 +71,11 @@ getblocktemplate-rpcs = [ "zebra-chain/getblocktemplate-rpcs", ] +# Health RPC support +gethealthinfo-rpc = [ + "zebra-rpc/gethealthinfo-rpc" +] + # Experimental internal miner support internal-miner = [ "thread-priority", diff --git a/zebrad/tests/acceptance.rs b/zebrad/tests/acceptance.rs index 4c717f62eb0..4be4ae5ab2c 100644 --- a/zebrad/tests/acceptance.rs +++ b/zebrad/tests/acceptance.rs @@ -1594,6 +1594,18 @@ async fn rpc_endpoint(parallel_cpu_threads: bool) -> Result<()> { // Create an http client let client = RpcRequestClient::new(rpc_address); + // Make the call to the `gethealthinfo` RPC method if feature is enabled. + #[cfg(feature = "gethealthinfo-rpc")] + { + let res = client.call("gethealthinfo", "[]".to_string()).await?; + assert!(res.status().is_success()); + + let body = res.bytes().await?; + let parsed: Value = serde_json::from_slice(&body)?; + let status = parsed["result"]["status"].as_str().unwrap(); + assert_eq!(status, "healthy"); + } + // Make the call to the `getinfo` RPC method let res = client.call("getinfo", "[]".to_string()).await?; @@ -1651,6 +1663,23 @@ async fn rpc_endpoint_client_content_type() -> Result<()> { // Create an http client let client = RpcRequestClient::new(rpc_address); + #[cfg(feature = "gethealthinfo-rpc")] + { + // Just test with plain content type, similar to getinfo. + let res = client + .call_with_content_type( + "gethealthinfo", + "[]".to_string(), + "application/json".to_string(), + ) + .await?; + assert!(res.status().is_success()); + + let body = res.bytes().await?; + let parsed: Value = serde_json::from_slice(&body)?; + assert_eq!(parsed["result"]["status"], "healthy"); + } + // Call to `getinfo` RPC method with a no content type. let res = client .call_with_no_content_type("getinfo", "[]".to_string()) From b4e355cf90bfec523bc2899056de15e57d0897f4 Mon Sep 17 00:00:00 2001 From: Dmitry Demin Date: Tue, 26 Aug 2025 22:24:54 +0200 Subject: [PATCH 2/5] Add missed files for the previous commit --- zebra-rpc/src/methods/tests.rs | 1 - .../snapshots/get_health_info_status@mainnet_10.snap | 8 ++++++++ .../snapshots/get_health_info_status@testnet_10.snap | 8 ++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 zebra-rpc/src/methods/tests/snapshots/get_health_info_status@mainnet_10.snap create mode 100644 zebra-rpc/src/methods/tests/snapshots/get_health_info_status@testnet_10.snap diff --git a/zebra-rpc/src/methods/tests.rs b/zebra-rpc/src/methods/tests.rs index 585c6102d5f..f98d41d6fb3 100644 --- a/zebra-rpc/src/methods/tests.rs +++ b/zebra-rpc/src/methods/tests.rs @@ -1,6 +1,5 @@ //! Test code for RPC methods -mod method_names; mod prop; mod snapshot; #[cfg(feature = "getblocktemplate-rpcs")] diff --git a/zebra-rpc/src/methods/tests/snapshots/get_health_info_status@mainnet_10.snap b/zebra-rpc/src/methods/tests/snapshots/get_health_info_status@mainnet_10.snap new file mode 100644 index 00000000000..2cf6310d4ad --- /dev/null +++ b/zebra-rpc/src/methods/tests/snapshots/get_health_info_status@mainnet_10.snap @@ -0,0 +1,8 @@ +--- +source: zebra-rpc/src/methods/tests/snapshot.rs +assertion_line: 551 +expression: status_only +--- +{ + "status": "healthy" +} diff --git a/zebra-rpc/src/methods/tests/snapshots/get_health_info_status@testnet_10.snap b/zebra-rpc/src/methods/tests/snapshots/get_health_info_status@testnet_10.snap new file mode 100644 index 00000000000..2cf6310d4ad --- /dev/null +++ b/zebra-rpc/src/methods/tests/snapshots/get_health_info_status@testnet_10.snap @@ -0,0 +1,8 @@ +--- +source: zebra-rpc/src/methods/tests/snapshot.rs +assertion_line: 551 +expression: status_only +--- +{ + "status": "healthy" +} From a31fbbd33f8880da782f5c0742510e27e3324c85 Mon Sep 17 00:00:00 2001 From: Dmitry Demin Date: Wed, 27 Aug 2025 13:17:34 +0200 Subject: [PATCH 3/5] Minor fix of comment --- zebra-rpc/src/methods.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zebra-rpc/src/methods.rs b/zebra-rpc/src/methods.rs index fc6a34479d5..4701899b2e2 100644 --- a/zebra-rpc/src/methods.rs +++ b/zebra-rpc/src/methods.rs @@ -528,7 +528,7 @@ where Ok(GetHealthInfo::snapshot()) } - // Dummy type so the trait always compiles when the feature is off. + // Dummy impl: return MethodNotFound if the feature is disabled. #[cfg(not(feature = "gethealthinfo-rpc"))] fn get_health_info(&self) -> Result { Err(jsonrpc_core::Error::method_not_found()) From 51cc4a4fdb60e3d1fdf27669646bb2e990409360 Mon Sep 17 00:00:00 2001 From: Dmitry Demin Date: Tue, 2 Sep 2025 12:12:18 +0200 Subject: [PATCH 4/5] =?UTF-8?q?Fix=20health=5Fapi=20route=20in=20zebra-rpc?= =?UTF-8?q?=20=E2=80=94=20add=20leading=20'/'=20so=20GET=20/health=20works?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- zebra-rpc/src/server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zebra-rpc/src/server.rs b/zebra-rpc/src/server.rs index 30addee0be9..d30d9e73ccc 100644 --- a/zebra-rpc/src/server.rs +++ b/zebra-rpc/src/server.rs @@ -220,7 +220,7 @@ impl RpcServer { .request_middleware(middleware); #[cfg(feature = "gethealthinfo-rpc")] - let builder = builder.health_api(("health", "gethealthinfo")); + let builder = builder.health_api(("/health", "gethealthinfo")); let server_instance = builder .start_http(&listen_addr) From 11dab631282dd404a45864036961524e14546f90 Mon Sep 17 00:00:00 2001 From: Dmitry Demin Date: Wed, 3 Sep 2025 11:11:14 +0200 Subject: [PATCH 5/5] Update zebrad/tests/acceptance.rs Co-authored-by: Arseni Kalma --- zebrad/tests/acceptance.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/zebrad/tests/acceptance.rs b/zebrad/tests/acceptance.rs index 4be4ae5ab2c..1c9c3b9d5d7 100644 --- a/zebrad/tests/acceptance.rs +++ b/zebrad/tests/acceptance.rs @@ -1665,7 +1665,6 @@ async fn rpc_endpoint_client_content_type() -> Result<()> { #[cfg(feature = "gethealthinfo-rpc")] { - // Just test with plain content type, similar to getinfo. let res = client .call_with_content_type( "gethealthinfo",