diff --git a/.github/workflows/ci-basic.yml b/.github/workflows/ci-basic.yml new file mode 100644 index 00000000000..c6f15991abf --- /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 -A mismatched-lifetime-syntaxes 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..45618d37eed 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::*; @@ -147,16 +150,26 @@ 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 http_middleware = tower::ServiceBuilder::new().layer(http_middleware_layer); + let health_proxy_layer = ProxyGetRequestLayer::new("/health", "gethealthinfo") + .map_err(Into::into)?; + + let http_middleware = tower::ServiceBuilder::new() + .layer(health_proxy_layer) + .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 {