From c0df99ce570ce842b3afac33d9c8857ad5fc6e66 Mon Sep 17 00:00:00 2001 From: Dmitry Demin Date: Thu, 18 Sep 2025 11:43:05 +0200 Subject: [PATCH 1/4] zebra-rpc: add GET /health endpoint and gethealthinfo RPC method --- zebra-rpc/src/methods.rs | 55 +++++++++++++++++++ zebra-rpc/src/methods/tests/snapshot.rs | 15 +++++ .../get_health_info_status@mainnet_10.snap | 8 +++ .../get_health_info_status@testnet_10.snap | 8 +++ zebra-rpc/src/server.rs | 11 +++- zebrad/tests/acceptance.rs | 23 ++++++++ 6 files changed, 118 insertions(+), 2 deletions(-) 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.rs b/zebra-rpc/src/methods.rs index 034e430e569..be31285b937 100644 --- a/zebra-rpc/src/methods.rs +++ b/zebra-rpc/src/methods.rs @@ -395,6 +395,20 @@ pub trait Rpc { address_strings: AddressStrings, ) -> Result; + /// 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 in HTTP middleware, + /// it is also available as `GET /health` (no parameters). + #[method(name = "gethealthinfo")] + fn get_health_info(&self) -> Result; + /// Stop the running zebrad process. /// /// # Notes @@ -2059,6 +2073,10 @@ where Ok(response_utxos) } + fn get_health_info(&self) -> Result { + Ok(GetHealthInfo::new()) + } + fn stop(&self) -> Result { #[cfg(not(target_os = "windows"))] if self.network.is_regtest() { @@ -2910,6 +2928,43 @@ where .ok_or_misc_error("No blocks in state") } +/// Response to a `gethealthinfo` RPC request. +/// +/// See the notes for the [`Rpc::get_health_info` method]. +#[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, +} + +impl Default for GetHealthInfo { + fn default() -> Self { + Self::new() + } +} + +impl GetHealthInfo { + /// Creates a new health info instance with current node status and build metadata. + #[inline] + pub fn new() -> Self { + Self { + status: "healthy".into(), + version: env!("CARGO_PKG_VERSION").into(), + git_tag: option_env!("GIT_TAG").unwrap_or("unknown").into(), + git_commit: option_env!("GIT_COMMIT_FULL").unwrap_or("unknown").into(), + timestamp: 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/snapshot.rs b/zebra-rpc/src/methods/tests/snapshot.rs index 6f6634d4778..20a6474dc09 100644 --- a/zebra-rpc/src/methods/tests/snapshot.rs +++ b/zebra-rpc/src/methods/tests/snapshot.rs @@ -575,6 +575,12 @@ async fn test_rpc_response_data_for_network(network: &Network) { .await .expect("We should have a vector of strings"); snapshot_rpc_getaddressutxos(get_address_utxos, &settings); + + // `gethealthinfo` + let get_health_info = rpc + .get_health_info() + .expect("We should have a GetHealthInfo struct"); + snapshot_rpc_gethealthinfo(get_health_info, &settings); } async fn test_mocked_rpc_response_data_for_network(network: &Network) { @@ -798,6 +804,15 @@ fn snapshot_rpc_getaddressutxos(utxos: Vec, settings: &insta::Settings) { settings.bind(|| insta::assert_json_snapshot!("get_address_utxos", utxos)); } +/// Snapshot `gethealthinfo` response, using `cargo insta` and JSON serialization. +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 `getblockcount` response, using `cargo insta` and JSON serialization. fn snapshot_rpc_getblockcount(block_count: u32, settings: &insta::Settings) { settings.bind(|| insta::assert_json_snapshot!("get_block_count", block_count)); 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..ee5b2def5ce --- /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: 808 +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..ee5b2def5ce --- /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: 808 +expression: status_only +--- +{ + "status": "healthy" +} diff --git a/zebra-rpc/src/server.rs b/zebra-rpc/src/server.rs index f46ebe971aa..d8dfe654367 100644 --- a/zebra-rpc/src/server.rs +++ b/zebra-rpc/src/server.rs @@ -10,7 +10,10 @@ use std::{fmt, panic}; use cookie::Cookie; -use jsonrpsee::server::{middleware::rpc::RpcServiceBuilder, Server, ServerHandle}; +use jsonrpsee::server::{ + middleware::{http::ProxyGetRequestLayer, rpc::RpcServiceBuilder}, + Server, ServerHandle, +}; use tokio::task::JoinHandle; use tower::Service; use tracing::*; @@ -156,7 +159,11 @@ impl RpcServer { HttpRequestMiddlewareLayer::new(None) }; - let http_middleware = tower::ServiceBuilder::new().layer(http_middleware_layer); + let http_middleware = tower::ServiceBuilder::new() + .layer( + ProxyGetRequestLayer::new("/health", "gethealthinfo").expect("valid health proxy"), + ) + .layer(http_middleware_layer); let rpc_middleware = RpcServiceBuilder::new() .rpc_logger(1024) diff --git a/zebrad/tests/acceptance.rs b/zebrad/tests/acceptance.rs index 3be0f3579b4..ddca0d73244 100644 --- a/zebrad/tests/acceptance.rs +++ b/zebrad/tests/acceptance.rs @@ -1634,6 +1634,20 @@ async fn rpc_endpoint_client_content_type() -> Result<()> { // Create an http client let client = RpcRequestClient::new(rpc_address); + // 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()) @@ -1718,6 +1732,15 @@ fn non_blocking_logger() -> Result<()> { // Create an http client let client = RpcRequestClient::new(rpc_address); + // Make the call to the `gethealthinfo` RPC method. + 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"); + // Most of Zebra's lines are 100-200 characters long, so 500 requests should print enough to fill the unix pipe, // fill the channel that tracing logs are queued onto, and drop logs rather than block execution. for _ in 0..500 { From b399098c864fc1fea035fe7e15730f3986c929cb Mon Sep 17 00:00:00 2001 From: Dmitry Demin Date: Thu, 18 Sep 2025 11:55:19 +0200 Subject: [PATCH 2/4] Add ci-basic.yml script to run tests on QED-it fork (do not include it into PR to the upstream) --- .github/workflows/ci-basic.yml | 45 ++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/ci-basic.yml diff --git a/.github/workflows/ci-basic.yml b/.github/workflows/ci-basic.yml new file mode 100644 index 00000000000..0c2f9d15a7a --- /dev/null +++ b/.github/workflows/ci-basic.yml @@ -0,0 +1,45 @@ +name: Basic checks + +#on: [push, pull_request] +on: [push] + +jobs: + test: + name: Test on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-24.04] + + env: + # Use system-installed RocksDB library instead of building from scratch + ROCKSDB_LIB_DIR: /usr/lib + # Use system-installed Snappy library for compression in RocksDB + SNAPPY_LIB_DIR: /usr/lib/x86_64-linux-gnu + + steps: + - uses: actions/checkout@v4 + - name: Install dependencies on Ubuntu + #run: sudo apt-get update && sudo apt-get install -y protobuf-compiler build-essential librocksdb-dev + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler librocksdb-dev + - name: Install formatting & linting tools + run: rustup component add rustfmt clippy + - name: Run tests + env: + RUST_BACKTRACE: 1 + # Skip two tests that intermittently fail on CI (likely a race/ordering issue). + # FIXME: investigate and fix the underlying flake; remove these skips once resolved. + run: cargo test --locked --verbose -- --skip v4_with_sapling_spends --skip v5_with_sapling_spends + # Run the skipped tests separately with constrained parallelism + - name: Run Sapling spend tests + run: | + cargo test -p zebra-consensus v4_with_sapling_spends -- --nocapture + cargo test -p zebra-consensus v5_with_sapling_spends -- --nocapture + - name: Verify working directory is clean + run: git diff --exit-code + - name: Run doc check + run: cargo doc --all-features --document-private-items + - name: Run format check + run: cargo fmt -- --check + - name: Run clippy + run: cargo clippy --workspace --all-features --all-targets -- -D warnings From cc9aa72c5d192ea7895cf2a0f731710c44a42d7c Mon Sep 17 00:00:00 2001 From: Dmitry Demin Date: Thu, 18 Sep 2025 12:25:15 +0200 Subject: [PATCH 3/4] ci: allow clippy mismatched-lifetime-syntaxes lint in workflow --- .github/workflows/ci-basic.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-basic.yml b/.github/workflows/ci-basic.yml index 0c2f9d15a7a..c6f15991abf 100644 --- a/.github/workflows/ci-basic.yml +++ b/.github/workflows/ci-basic.yml @@ -42,4 +42,4 @@ jobs: - name: Run format check run: cargo fmt -- --check - name: Run clippy - run: cargo clippy --workspace --all-features --all-targets -- -D warnings + run: cargo clippy --workspace --all-features --all-targets -- -D warnings -A mismatched-lifetime-syntaxes From 58357f7952b81c3037390f72a436bf4ec36be75a Mon Sep 17 00:00:00 2001 From: Arseni Kalma Date: Tue, 23 Sep 2025 09:55:19 +0200 Subject: [PATCH 4/4] Fix comment --- zebra-rpc/src/server.rs | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/zebra-rpc/src/server.rs b/zebra-rpc/src/server.rs index d8dfe654367..45618d37eed 100644 --- a/zebra-rpc/src/server.rs +++ b/zebra-rpc/src/server.rs @@ -150,19 +150,25 @@ impl RpcServer { .listen_addr .expect("caller should make sure listen_addr is set"); - let http_middleware_layer = if conf.enable_cookie_auth { - let cookie = Cookie::default(); - cookie::write_to_disk(&cookie, &conf.cookie_dir) - .expect("Zebra must be able to write the auth cookie to the disk"); - HttpRequestMiddlewareLayer::new(Some(cookie)) - } else { - HttpRequestMiddlewareLayer::new(None) + let http_middleware_layer = match conf.enable_cookie_auth { + true => { + let cookie = Cookie::default(); + match cookie::write_to_disk(&cookie, &conf.cookie_dir) { + Ok(_) => HttpRequestMiddlewareLayer::new(Some(cookie)), + Err(err) => { + error!(?err, "Failed to write auth cookie to disk"); + return Err(err.into()); + } + } + } + false => HttpRequestMiddlewareLayer::new(None), }; + let health_proxy_layer = ProxyGetRequestLayer::new("/health", "gethealthinfo") + .map_err(Into::into)?; + let http_middleware = tower::ServiceBuilder::new() - .layer( - ProxyGetRequestLayer::new("/health", "gethealthinfo").expect("valid health proxy"), - ) + .layer(health_proxy_layer) .layer(http_middleware_layer); let rpc_middleware = RpcServiceBuilder::new()