Skip to content

Commit 05b6998

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

5 files changed

Lines changed: 328 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: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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() -> u16 {
15+
let raw = std::env::var("OPENSHELL_E2E_HEALTH_PORT").unwrap_or_else(|_| {
16+
panic!(
17+
"OPENSHELL_E2E_HEALTH_PORT is not set. The Docker e2e wrapper \
18+
(e2e/with-docker-gateway.sh) must export this variable so the \
19+
/readyz test can reach the gateway health listener."
20+
)
21+
});
22+
raw.parse::<u16>().unwrap_or_else(|err| {
23+
panic!("OPENSHELL_E2E_HEALTH_PORT=\"{raw}\" is not a valid u16 port: {err}")
24+
})
25+
}
26+
27+
async fn http_get_json(port: u16, path: &str) -> Result<(u16, Value), String> {
28+
let stream = TcpStream::connect(("127.0.0.1", port))
29+
.await
30+
.map_err(|err| format!("connect health endpoint :{port}: {err}"))?;
31+
let (mut sender, conn) = hyper::client::conn::http1::Builder::new()
32+
.handshake(TokioIo::new(stream))
33+
.await
34+
.map_err(|err| format!("handshake health HTTP/1 client :{port}: {err}"))?;
35+
tokio::spawn(async move {
36+
let _ = conn.await;
37+
});
38+
39+
let req = Request::builder()
40+
.method("GET")
41+
.uri(format!("http://127.0.0.1:{port}{path}"))
42+
.body(Empty::<Bytes>::new())
43+
.map_err(|err| format!("build health request {path}: {err}"))?;
44+
let resp = sender
45+
.send_request(req)
46+
.await
47+
.map_err(|err| format!("send health request {path} to :{port}: {err}"))?;
48+
let status_code = resp.status().as_u16();
49+
let bytes = resp
50+
.into_body()
51+
.collect()
52+
.await
53+
.map_err(|err| format!("read health response body {path}: {err}"))?
54+
.to_bytes();
55+
let json = serde_json::from_slice::<Value>(&bytes)
56+
.map_err(|err| format!("health endpoint {path} did not return valid JSON: {err}"))?;
57+
58+
Ok((status_code, json))
59+
}
60+
61+
#[tokio::test]
62+
async fn readyz_reports_healthy_database_check() {
63+
let port = health_port_from_env();
64+
65+
let deadline = Instant::now() + Duration::from_secs(20);
66+
let timeout_detail = loop {
67+
let observation = match http_get_json(port, "/readyz").await {
68+
Ok((status, payload)) => {
69+
let ready = status == 200
70+
&& payload["status"] == "healthy"
71+
&& payload["checks"]["database"]["status"] == "healthy";
72+
if ready {
73+
assert!(
74+
payload["checks"]["database"]["latency_ms"].is_number(),
75+
"readyz payload should include checks.database.latency_ms: {payload}"
76+
);
77+
assert!(
78+
payload["checks"]["database"]["error"].is_null(),
79+
"readyz payload should not include checks.database.error when healthy: {payload}"
80+
);
81+
return;
82+
}
83+
format!("unexpected /readyz response status={status} payload={payload}")
84+
}
85+
Err(err) => err,
86+
};
87+
88+
if Instant::now() >= deadline {
89+
break observation;
90+
}
91+
92+
tokio::time::sleep(Duration::from_secs(1)).await;
93+
};
94+
panic!("timed out waiting for /readyz healthy response after 20s: {timeout_detail}");
95+
}

0 commit comments

Comments
 (0)