Skip to content

Commit 2fc6cec

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

7 files changed

Lines changed: 412 additions & 1 deletion

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
#[cfg(feature = "test-support")]
12+
use std::time::Duration;
13+
use tokio::net::TcpListener;
14+
15+
async fn start_health_server(
16+
store: Arc<Store>,
17+
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
18+
let listener = TcpListener::bind("127.0.0.1:0")
19+
.await
20+
.expect("bind ephemeral health test listener");
21+
let addr = listener
22+
.local_addr()
23+
.expect("resolve local address for health test listener");
24+
25+
let router = health_router(store).await;
26+
let server = tokio::spawn(async move {
27+
let _ = axum::serve(listener, router.into_make_service()).await;
28+
});
29+
30+
(addr, server)
31+
}
32+
33+
async fn http_get_json(addr: std::net::SocketAddr, path: &str) -> (StatusCode, Value) {
34+
let stream = tokio::net::TcpStream::connect(addr)
35+
.await
36+
.expect("connect test HTTP client");
37+
let (mut sender, conn) = hyper::client::conn::http1::Builder::new()
38+
.handshake(TokioIo::new(stream))
39+
.await
40+
.expect("handshake HTTP/1 test client");
41+
tokio::spawn(async move {
42+
let _ = conn.await;
43+
});
44+
45+
let req = Request::builder()
46+
.method("GET")
47+
.uri(format!("http://{addr}{path}"))
48+
.body(Empty::<Bytes>::new())
49+
.expect("build HTTP request");
50+
let resp = sender.send_request(req).await.expect("send HTTP request");
51+
let status = resp.status();
52+
let bytes = resp
53+
.into_body()
54+
.collect()
55+
.await
56+
.expect("collect response body")
57+
.to_bytes();
58+
let body = if bytes.is_empty() {
59+
Value::Null
60+
} else {
61+
serde_json::from_slice(&bytes).expect("response body must be valid JSON")
62+
};
63+
(status, body)
64+
}
65+
66+
#[tokio::test]
67+
async fn readyz_reports_healthy_when_database_is_reachable() {
68+
let store = Arc::new(
69+
Store::connect("sqlite::memory:")
70+
.await
71+
.expect("connect in-memory sqlite store for health integration test"),
72+
);
73+
let (addr, server) = start_health_server(store.clone()).await;
74+
75+
let (status, body) = http_get_json(addr, "/readyz").await;
76+
assert_eq!(status, StatusCode::OK);
77+
assert_eq!(body["status"], "healthy");
78+
assert_eq!(body["checks"]["database"]["status"], "healthy");
79+
80+
server.abort();
81+
}
82+
83+
#[cfg(feature = "test-support")]
84+
#[tokio::test]
85+
async fn readyz_reports_database_health_transition_after_close() {
86+
let store = Arc::new(
87+
Store::connect("sqlite::memory:")
88+
.await
89+
.expect("connect in-memory sqlite store for health integration test"),
90+
);
91+
let (addr, server) = start_health_server(store.clone()).await;
92+
93+
let (status, body) = http_get_json(addr, "/readyz").await;
94+
assert_eq!(status, StatusCode::OK);
95+
assert_eq!(body["status"], "healthy");
96+
assert_eq!(body["checks"]["database"]["status"], "healthy");
97+
98+
store.close().await;
99+
100+
// The handler reads the cached state published by the background
101+
// readiness monitor, so the transition to Unhealthy can only show up
102+
// after the monitor's next tick. With the default 5s interval the
103+
// outage surfaces within ~5s; poll with a generous deadline so the
104+
// assertion never races the polling cycle.
105+
let (status, body) = wait_for_unhealthy(addr, Duration::from_secs(10))
106+
.await
107+
.expect("/readyz did not transition to 503 after store.close() within 10s");
108+
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
109+
assert_eq!(body["status"], "unhealthy");
110+
assert_eq!(body["checks"]["database"]["status"], "unhealthy");
111+
assert_eq!(body["checks"]["database"]["error"], "database unavailable");
112+
113+
server.abort();
114+
}
115+
116+
#[cfg(feature = "test-support")]
117+
async fn wait_for_unhealthy(
118+
addr: std::net::SocketAddr,
119+
timeout: Duration,
120+
) -> Option<(StatusCode, Value)> {
121+
let deadline = tokio::time::Instant::now() + timeout;
122+
loop {
123+
let observation = http_get_json(addr, "/readyz").await;
124+
if observation.0 == StatusCode::SERVICE_UNAVAILABLE {
125+
return Some(observation);
126+
}
127+
if tokio::time::Instant::now() >= deadline {
128+
return None;
129+
}
130+
tokio::time::sleep(Duration::from_millis(250)).await;
131+
}
132+
}

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
@@ -51,6 +51,11 @@ name = "vm_gateway_resume"
5151
path = "tests/vm_gateway_resume.rs"
5252
required-features = ["e2e-vm"]
5353

54+
[[test]]
55+
name = "readyz_health"
56+
path = "tests/readyz_health.rs"
57+
required-features = ["e2e-kubernetes"]
58+
5459
[[test]]
5560
name = "websocket_conformance"
5661
path = "tests/websocket_conformance.rs"
@@ -83,6 +88,10 @@ required-features = ["e2e-gpu"]
8388

8489
[dependencies]
8590
base64 = "0.22"
91+
bytes = "1"
92+
http-body-util = "0.1"
93+
hyper = { version = "1", features = ["client", "http1"] }
94+
hyper-util = { version = "0.1", features = ["tokio"] }
8695
tokio = { version = "1.43", features = ["full"] }
8796
tempfile = "3"
8897
sha1 = "0.10"

e2e/rust/e2e-kubernetes.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ set -euo pipefail
1919

2020
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
2121

22-
E2E_FEATURES="${OPENSHELL_E2E_KUBERNETES_FEATURES:-e2e,e2e-host-gateway}"
22+
E2E_FEATURES="${OPENSHELL_E2E_KUBERNETES_FEATURES:-e2e,e2e-host-gateway,e2e-kubernetes}"
2323

2424
cargo build -p openshell-cli --features openshell-core/dev-settings
2525

0 commit comments

Comments
 (0)