Skip to content

Commit b52f7d7

Browse files
committed
test(e2e): add simple e2e test with docker to test /readyz
Signed-off-by: Adrien Langou <alangou@nvidia.com>
1 parent 1c2286b commit b52f7d7

5 files changed

Lines changed: 326 additions & 1 deletion

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
use bytes::Bytes;
5+
use http_body_util::{BodyExt, Empty};
6+
use hyper::{Request, StatusCode};
7+
use hyper_util::rt::TokioIo;
8+
use openshell_server::{Store, health_router};
9+
use serde_json::Value;
10+
use std::sync::Arc;
11+
use tokio::net::TcpListener;
12+
13+
async fn start_health_server(
14+
store: Arc<Store>,
15+
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
16+
let listener = TcpListener::bind("127.0.0.1:0")
17+
.await
18+
.expect("bind ephemeral health test listener");
19+
let addr = listener
20+
.local_addr()
21+
.expect("resolve local address for health test listener");
22+
23+
let server = tokio::spawn(async move {
24+
let _ = axum::serve(listener, health_router(store).into_make_service()).await;
25+
});
26+
27+
(addr, server)
28+
}
29+
30+
async fn http_get_json(addr: std::net::SocketAddr, path: &str) -> (StatusCode, Value) {
31+
let stream = tokio::net::TcpStream::connect(addr)
32+
.await
33+
.expect("connect test HTTP client");
34+
let (mut sender, conn) = hyper::client::conn::http1::Builder::new()
35+
.handshake(TokioIo::new(stream))
36+
.await
37+
.expect("handshake HTTP/1 test client");
38+
tokio::spawn(async move {
39+
let _ = conn.await;
40+
});
41+
42+
let req = Request::builder()
43+
.method("GET")
44+
.uri(format!("http://{addr}{path}"))
45+
.body(Empty::<Bytes>::new())
46+
.expect("build HTTP request");
47+
let resp = sender.send_request(req).await.expect("send HTTP request");
48+
let status = resp.status();
49+
let bytes = resp
50+
.into_body()
51+
.collect()
52+
.await
53+
.expect("collect response body")
54+
.to_bytes();
55+
let body = if bytes.is_empty() {
56+
Value::Null
57+
} else {
58+
serde_json::from_slice(&bytes).expect("response body must be valid JSON")
59+
};
60+
(status, body)
61+
}
62+
63+
#[tokio::test]
64+
async fn readyz_reports_healthy_when_database_is_reachable() {
65+
let store = Arc::new(
66+
Store::connect("sqlite::memory:")
67+
.await
68+
.expect("connect in-memory sqlite store for health integration test"),
69+
);
70+
let (addr, server) = start_health_server(store.clone()).await;
71+
72+
let (status, body) = http_get_json(addr, "/readyz").await;
73+
assert_eq!(status, StatusCode::OK);
74+
assert_eq!(body["status"], "healthy");
75+
assert_eq!(body["checks"]["database"]["status"], "healthy");
76+
77+
server.abort();
78+
}
79+
80+
#[cfg(feature = "test-support")]
81+
#[tokio::test]
82+
async fn readyz_reports_database_health_transition_after_close() {
83+
let store = Arc::new(
84+
Store::connect("sqlite::memory:")
85+
.await
86+
.expect("connect in-memory sqlite store for health integration test"),
87+
);
88+
let (addr, server) = start_health_server(store.clone()).await;
89+
90+
let (status, body) = http_get_json(addr, "/readyz").await;
91+
assert_eq!(status, StatusCode::OK);
92+
assert_eq!(body["status"], "healthy");
93+
assert_eq!(body["checks"]["database"]["status"], "healthy");
94+
95+
store.close().await;
96+
97+
let (status, body) = http_get_json(addr, "/readyz").await;
98+
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
99+
assert_eq!(body["status"], "unhealthy");
100+
assert_eq!(body["checks"]["database"]["status"], "unhealthy");
101+
assert_eq!(body["checks"]["database"]["error"], "database unavailable");
102+
103+
server.abort();
104+
}

e2e/rust/Cargo.lock

Lines changed: 113 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/rust/Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ name = "gateway_resume"
4545
path = "tests/gateway_resume.rs"
4646
required-features = ["e2e-docker"]
4747

48+
[[test]]
49+
name = "readyz_health"
50+
path = "tests/readyz_health.rs"
51+
required-features = ["e2e-docker"]
52+
4853
[[test]]
4954
name = "websocket_conformance"
5055
path = "tests/websocket_conformance.rs"
@@ -77,6 +82,10 @@ required-features = ["e2e-gpu"]
7782

7883
[dependencies]
7984
base64 = "0.22"
85+
bytes = "1"
86+
http-body-util = "0.1"
87+
hyper = { version = "1", features = ["client", "http1"] }
88+
hyper-util = { version = "0.1", features = ["tokio"] }
8089
tokio = { version = "1.43", features = ["full"] }
8190
tempfile = "3"
8291
sha1 = "0.10"

e2e/rust/tests/readyz_health.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
#![cfg(feature = "e2e-docker")]
5+
6+
use bytes::Bytes;
7+
use http_body_util::{BodyExt, Empty};
8+
use hyper::Request;
9+
use hyper_util::rt::TokioIo;
10+
use serde_json::Value;
11+
use std::time::{Duration, Instant};
12+
use tokio::net::TcpStream;
13+
14+
fn health_port_from_env() -> Option<u16> {
15+
std::env::var("OPENSHELL_E2E_HEALTH_PORT")
16+
.ok()
17+
.and_then(|value| value.parse::<u16>().ok())
18+
}
19+
20+
async fn http_get_json(port: u16, path: &str) -> Result<(u16, Value), String> {
21+
let stream = TcpStream::connect(("127.0.0.1", port))
22+
.await
23+
.map_err(|err| format!("connect health endpoint :{port}: {err}"))?;
24+
let (mut sender, conn) = hyper::client::conn::http1::Builder::new()
25+
.handshake(TokioIo::new(stream))
26+
.await
27+
.map_err(|err| format!("handshake health HTTP/1 client :{port}: {err}"))?;
28+
tokio::spawn(async move {
29+
let _ = conn.await;
30+
});
31+
32+
let req = Request::builder()
33+
.method("GET")
34+
.uri(format!("http://127.0.0.1:{port}{path}"))
35+
.body(Empty::<Bytes>::new())
36+
.map_err(|err| format!("build health request {path}: {err}"))?;
37+
let resp = sender
38+
.send_request(req)
39+
.await
40+
.map_err(|err| format!("send health request {path} to :{port}: {err}"))?;
41+
let status_code = resp.status().as_u16();
42+
let bytes = resp
43+
.into_body()
44+
.collect()
45+
.await
46+
.map_err(|err| format!("read health response body {path}: {err}"))?
47+
.to_bytes();
48+
let json = serde_json::from_slice::<Value>(&bytes)
49+
.map_err(|err| format!("health endpoint {path} did not return valid JSON: {err}"))?;
50+
51+
Ok((status_code, json))
52+
}
53+
54+
#[tokio::test]
55+
async fn readyz_reports_healthy_database_check() {
56+
let Some(port) = health_port_from_env() else {
57+
eprintln!(
58+
"Skipping /readyz e2e: OPENSHELL_E2E_HEALTH_PORT is not set (existing-endpoint mode)"
59+
);
60+
return;
61+
};
62+
63+
let deadline = Instant::now() + Duration::from_secs(20);
64+
let timeout_detail = loop {
65+
let observation = match http_get_json(port, "/readyz").await {
66+
Ok((status, payload)) => {
67+
let ready = status == 200
68+
&& payload["status"] == "healthy"
69+
&& payload["checks"]["database"]["status"] == "healthy";
70+
if ready {
71+
assert!(
72+
payload["checks"]["database"]["latency_ms"].is_number(),
73+
"readyz payload should include checks.database.latency_ms: {payload}"
74+
);
75+
assert!(
76+
payload["checks"]["database"]["error"].is_null(),
77+
"readyz payload should not include checks.database.error when healthy: {payload}"
78+
);
79+
return;
80+
}
81+
format!("unexpected /readyz response status={status} payload={payload}")
82+
}
83+
Err(err) => err,
84+
};
85+
86+
if Instant::now() >= deadline {
87+
break observation;
88+
}
89+
90+
tokio::time::sleep(Duration::from_secs(1)).await;
91+
};
92+
panic!("timed out waiting for /readyz healthy response after 20s: {timeout_detail}");
93+
}

0 commit comments

Comments
 (0)