1use crate::config::HealthStatus;
8use crate::config::environment_names::logging as env_logging;
9use crate::config::environment_names::runtime::canary as env_canary;
10use crate::config::environment_names::runtime::system as env_system;
11use crate::logging::make_system_request_span;
12use crate::metrics::MetricsHierarchy;
13use crate::traits::DistributedRuntimeProvider;
14use axum::{
15 Router,
16 body::Bytes,
17 extract::{Json, Path, State},
18 http::StatusCode,
19 response::IntoResponse,
20 routing::{any, delete, get, post},
21};
22use futures::StreamExt;
23use serde::{Deserialize, Serialize};
24use serde_json::json;
25use std::collections::HashMap;
26use std::sync::{Arc, OnceLock};
27use std::time::Instant;
28use tokio::{net::TcpListener, task::JoinHandle};
29use tokio_util::sync::CancellationToken;
30use tower_http::trace::TraceLayer;
31
32#[derive(Debug)]
34pub struct SystemStatusServerInfo {
35 pub socket_addr: std::net::SocketAddr,
36 pub handle: Option<Arc<JoinHandle<()>>>,
37}
38
39impl SystemStatusServerInfo {
40 pub fn new(socket_addr: std::net::SocketAddr, handle: Option<JoinHandle<()>>) -> Self {
41 Self {
42 socket_addr,
43 handle: handle.map(Arc::new),
44 }
45 }
46
47 pub fn address(&self) -> String {
48 self.socket_addr.to_string()
49 }
50
51 pub fn hostname(&self) -> String {
52 self.socket_addr.ip().to_string()
53 }
54
55 pub fn port(&self) -> u16 {
56 self.socket_addr.port()
57 }
58}
59
60impl Clone for SystemStatusServerInfo {
61 fn clone(&self) -> Self {
62 Self {
63 socket_addr: self.socket_addr,
64 handle: self.handle.clone(),
65 }
66 }
67}
68
69pub struct SystemStatusState {
71 root_drt: Arc<crate::DistributedRuntime>,
73 discovery_metadata: Option<Arc<tokio::sync::RwLock<crate::discovery::DiscoveryMetadata>>>,
75}
76
77impl SystemStatusState {
78 pub fn new(
80 drt: Arc<crate::DistributedRuntime>,
81 discovery_metadata: Option<Arc<tokio::sync::RwLock<crate::discovery::DiscoveryMetadata>>>,
82 ) -> anyhow::Result<Self> {
83 Ok(Self {
84 root_drt: drt,
85 discovery_metadata,
86 })
87 }
88
89 pub fn drt(&self) -> &crate::DistributedRuntime {
91 &self.root_drt
92 }
93
94 pub fn discovery_metadata(
96 &self,
97 ) -> Option<&Arc<tokio::sync::RwLock<crate::discovery::DiscoveryMetadata>>> {
98 self.discovery_metadata.as_ref()
99 }
100}
101
102#[derive(Debug, Clone, Deserialize, Serialize)]
104pub struct LoadLoraRequest {
105 pub lora_name: String,
106 pub source: LoraSource,
107}
108
109#[derive(Debug, Clone, Deserialize, Serialize)]
111pub struct LoraSource {
112 pub uri: String,
113}
114
115#[derive(Debug, Clone, Deserialize, Serialize)]
117pub struct LoraResponse {
118 pub status: String,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub message: Option<String>,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub lora_name: Option<String>,
123 #[serde(skip_serializing_if = "Option::is_none")]
124 pub lora_id: Option<u64>,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub loras: Option<serde_json::Value>,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 pub count: Option<usize>,
129}
130
131pub async fn spawn_system_status_server(
133 host: &str,
134 port: u16,
135 cancel_token: CancellationToken,
136 drt: Arc<crate::DistributedRuntime>,
137 discovery_metadata: Option<Arc<tokio::sync::RwLock<crate::discovery::DiscoveryMetadata>>>,
138) -> anyhow::Result<(std::net::SocketAddr, tokio::task::JoinHandle<()>)> {
139 let server_state = Arc::new(SystemStatusState::new(drt, discovery_metadata)?);
141 let health_path = server_state
142 .drt()
143 .system_health()
144 .lock()
145 .health_path()
146 .to_string();
147 let live_path = server_state
148 .drt()
149 .system_health()
150 .lock()
151 .live_path()
152 .to_string();
153
154 let lora_enabled = std::env::var(crate::config::environment_names::llm::DYN_LORA_ENABLED)
156 .map(|v| v.to_lowercase() == "true")
157 .unwrap_or(false);
158
159 let mut app = Router::new()
160 .route(
161 &health_path,
162 get({
163 let state = Arc::clone(&server_state);
164 move || health_handler(state)
165 }),
166 )
167 .route(
168 &live_path,
169 get({
170 let state = Arc::clone(&server_state);
171 move || health_handler(state)
172 }),
173 )
174 .route(
175 "/metrics",
176 get({
177 let state = Arc::clone(&server_state);
178 move || metrics_handler(state)
179 }),
180 )
181 .route(
182 "/metadata",
183 get({
184 let state = Arc::clone(&server_state);
185 move || metadata_handler(state)
186 }),
187 )
188 .route(
189 "/engine/{*path}",
190 any({
191 let state = Arc::clone(&server_state);
192 move |path, body| engine_route_handler(state, path, body)
193 }),
194 );
195
196 if lora_enabled {
198 app = app
199 .route(
200 "/v1/loras",
201 get({
202 let state = Arc::clone(&server_state);
203 move || list_loras_handler(State(state))
204 })
205 .post({
206 let state = Arc::clone(&server_state);
207 move |body| load_lora_handler(State(state), body)
208 }),
209 )
210 .route(
211 "/v1/loras/{*lora_name}",
212 delete({
213 let state = Arc::clone(&server_state);
214 move |path| unload_lora_handler(State(state), path)
215 }),
216 );
217 }
218
219 app = app.route(
224 "/v1/metadata/{namespace}/{component}/{endpoint}/{model_slug}/{model_suffix}/{*filename}",
225 get({
226 let state = Arc::clone(&server_state);
227 move |path| metadata_file_handler(State(state), path)
228 }),
229 );
230
231 let app = app
232 .fallback(|| async {
233 tracing::info!("[fallback handler] called");
234 (StatusCode::NOT_FOUND, "Route not found").into_response()
235 })
236 .layer(TraceLayer::new_for_http().make_span_with(make_system_request_span));
237
238 let address = format!("{}:{}", host, port);
239 tracing::info!("[spawn_system_status_server] binding to: {address}");
240
241 let listener = match TcpListener::bind(&address).await {
242 Ok(listener) => {
243 let actual_address = listener.local_addr()?;
245 tracing::info!(
246 "[spawn_system_status_server] system status server bound to: {}",
247 actual_address
248 );
249 (listener, actual_address)
250 }
251 Err(e) => {
252 tracing::error!("Failed to bind to address {}: {}", address, e);
253 return Err(anyhow::anyhow!("Failed to bind to address: {}", e));
254 }
255 };
256 let (listener, actual_address) = listener;
257
258 let observer = cancel_token.child_token();
259 let handle = tokio::spawn(async move {
261 if let Err(e) = axum::serve(listener, app)
262 .with_graceful_shutdown(observer.cancelled_owned())
263 .await
264 {
265 tracing::error!("System status server error: {e}");
266 }
267 });
268
269 Ok((actual_address, handle))
270}
271
272#[tracing::instrument(skip_all, level = "trace")]
274async fn health_handler(state: Arc<SystemStatusState>) -> impl IntoResponse {
275 let system_health = state.drt().system_health();
277 let system_health_lock = system_health.lock();
278 let (healthy, endpoints) = system_health_lock.get_health_status();
279 let uptime = Some(system_health_lock.uptime());
280 drop(system_health_lock);
281
282 let healthy_string = if healthy { "ready" } else { "notready" };
283 let status_code = if healthy {
284 StatusCode::OK
285 } else {
286 StatusCode::SERVICE_UNAVAILABLE
287 };
288
289 let response = json!({
290 "status": healthy_string,
291 "uptime": uptime,
292 "endpoints": endpoints,
293 });
294
295 tracing::trace!("Response {}", response.to_string());
296
297 (status_code, response.to_string())
298}
299
300#[tracing::instrument(skip_all, level = "trace")]
302async fn metrics_handler(state: Arc<SystemStatusState>) -> impl IntoResponse {
303 let response = match state.drt().metrics().prometheus_expfmt() {
310 Ok(r) => r,
311 Err(e) => {
312 tracing::error!("Failed to get metrics from registry: {e}");
313 return (
314 StatusCode::INTERNAL_SERVER_ERROR,
315 "Failed to get metrics".to_string(),
316 );
317 }
318 };
319
320 (StatusCode::OK, response)
321}
322
323#[tracing::instrument(skip_all, level = "trace")]
325async fn metadata_handler(state: Arc<SystemStatusState>) -> impl IntoResponse {
326 let metadata = match state.discovery_metadata() {
328 Some(metadata) => metadata,
329 None => {
330 tracing::debug!("Metadata endpoint called but no discovery metadata available");
331 return (
332 StatusCode::NOT_FOUND,
333 "Discovery metadata not available".to_string(),
334 )
335 .into_response();
336 }
337 };
338
339 let metadata_guard = metadata.read().await;
341
342 match serde_json::to_string(&*metadata_guard) {
344 Ok(json) => {
345 tracing::trace!("Returning metadata: {} bytes", json.len());
346 (StatusCode::OK, json).into_response()
347 }
348 Err(e) => {
349 tracing::error!("Failed to serialize metadata: {e}");
350 (
351 StatusCode::INTERNAL_SERVER_ERROR,
352 "Failed to serialize metadata".to_string(),
353 )
354 .into_response()
355 }
356 }
357}
358
359#[tracing::instrument(skip_all, level = "debug")]
361async fn load_lora_handler(
362 State(state): State<Arc<SystemStatusState>>,
363 Json(request): Json<LoadLoraRequest>,
364) -> impl IntoResponse {
365 tracing::info!("Loading LoRA: {}", request.lora_name);
366
367 match call_lora_endpoint(
369 state.drt(),
370 "load_lora",
371 json!({
372 "lora_name": request.lora_name,
373 "source": {
374 "uri": request.source.uri
375 },
376 }),
377 )
378 .await
379 {
380 Ok(response) => {
381 if response.status == "error" {
382 tracing::error!(
383 "Failed to load LoRA {}: {}",
384 request.lora_name,
385 response.message.as_deref().unwrap_or("Unknown error")
386 );
387 (StatusCode::INTERNAL_SERVER_ERROR, Json(response))
388 } else {
389 tracing::info!("LoRA loaded successfully: {}", request.lora_name);
390 (StatusCode::OK, Json(response))
391 }
392 }
393 Err(e) => {
394 tracing::error!("Failed to load LoRA {}: {}", request.lora_name, e);
395 (
396 StatusCode::INTERNAL_SERVER_ERROR,
397 Json(LoraResponse {
398 status: "error".to_string(),
399 message: Some(e.to_string()),
400 lora_name: Some(request.lora_name),
401 lora_id: None,
402 loras: None,
403 count: None,
404 }),
405 )
406 }
407 }
408}
409
410#[tracing::instrument(skip_all, level = "debug")]
412async fn unload_lora_handler(
413 State(state): State<Arc<SystemStatusState>>,
414 Path(lora_name): Path<String>,
415) -> impl IntoResponse {
416 let lora_name = lora_name
418 .strip_prefix('/')
419 .unwrap_or(&lora_name)
420 .to_string();
421 tracing::info!("Unloading LoRA: {lora_name}");
422
423 match call_lora_endpoint(
425 state.drt(),
426 "unload_lora",
427 json!({
428 "lora_name": lora_name.clone(),
429 }),
430 )
431 .await
432 {
433 Ok(response) => {
434 if response.status == "error" {
435 tracing::error!(
436 "Failed to unload LoRA {}: {}",
437 lora_name,
438 response.message.as_deref().unwrap_or("Unknown error")
439 );
440 (StatusCode::INTERNAL_SERVER_ERROR, Json(response))
441 } else {
442 tracing::info!("LoRA unloaded successfully: {lora_name}");
443 (StatusCode::OK, Json(response))
444 }
445 }
446 Err(e) => {
447 tracing::error!("Failed to unload LoRA {}: {}", lora_name, e);
448 (
449 StatusCode::INTERNAL_SERVER_ERROR,
450 Json(LoraResponse {
451 status: "error".to_string(),
452 message: Some(e.to_string()),
453 lora_name: Some(lora_name),
454 lora_id: None,
455 loras: None,
456 count: None,
457 }),
458 )
459 }
460 }
461}
462
463#[tracing::instrument(skip_all, level = "debug")]
465async fn list_loras_handler(State(state): State<Arc<SystemStatusState>>) -> impl IntoResponse {
466 tracing::info!("Listing all LoRAs");
467
468 match call_lora_endpoint(state.drt(), "list_loras", json!({})).await {
470 Ok(response) => {
471 tracing::info!("Successfully retrieved LoRA list");
472 (StatusCode::OK, Json(response))
473 }
474 Err(e) => {
475 tracing::error!("Failed to list LoRAs: {e}");
476 (
477 StatusCode::INTERNAL_SERVER_ERROR,
478 Json(LoraResponse {
479 status: "error".to_string(),
480 message: Some(e.to_string()),
481 lora_name: None,
482 lora_id: None,
483 loras: None,
484 count: None,
485 }),
486 )
487 }
488 }
489}
490
491async fn metadata_file_handler(
494 State(state): State<Arc<SystemStatusState>>,
495 Path((namespace, component, endpoint, model_slug, model_suffix, filename)): Path<(
496 String,
497 String,
498 String,
499 String,
500 String,
501 String,
502 )>,
503) -> impl IntoResponse {
504 let path = match state.drt().metadata_artifacts().get(
505 &namespace,
506 &component,
507 &endpoint,
508 &model_slug,
509 &model_suffix,
510 &filename,
511 ) {
512 Some(p) => p,
513 None => {
514 tracing::debug!(
515 namespace,
516 component,
517 endpoint,
518 model_slug,
519 model_suffix,
520 filename,
521 "metadata artifact not registered for self-host"
522 );
523 return (StatusCode::NOT_FOUND, "Not found").into_response();
524 }
525 };
526
527 match tokio::fs::read(&path).await {
528 Ok(bytes) => (StatusCode::OK, bytes).into_response(),
529 Err(err) => {
530 tracing::error!(
531 namespace,
532 component,
533 endpoint,
534 model_slug,
535 model_suffix,
536 filename,
537 path = %path.display(),
538 %err,
539 "failed to read self-hosted metadata file"
540 );
541 (StatusCode::INTERNAL_SERVER_ERROR, "Failed to read file").into_response()
542 }
543 }
544}
545
546async fn call_lora_endpoint(
562 drt: &crate::DistributedRuntime,
563 endpoint_name: &str,
564 request_body: serde_json::Value,
565) -> anyhow::Result<LoraResponse> {
566 use crate::engine::AsyncEngine;
567
568 tracing::debug!("Calling LoRA endpoint: '{endpoint_name}'");
569
570 if let Some(engine) = drt.local_endpoint_registry().get(endpoint_name) {
572 tracing::debug!(
573 "Found endpoint '{}' in local registry, calling directly",
574 endpoint_name
575 );
576
577 let request = crate::pipeline::SingleIn::new(request_body);
578 let mut stream = engine.generate(request).await?;
579
580 if let Some(response) = stream.next().await {
581 let response_data = response.data.unwrap_or_default();
582 let lora_response = serde_json::from_value::<LoraResponse>(response_data.clone())
583 .unwrap_or_else(|_| parse_lora_response(&response_data));
584 return Ok(lora_response);
585 }
586
587 anyhow::bail!("No response received from endpoint '{}'", endpoint_name)
588 }
589
590 let update_key = format!("update/{endpoint_name}");
594 if let Some(callback) = drt.engine_routes().get(&update_key) {
595 tracing::debug!(
596 "Found '{}' in engine routes registry, invoking update callback",
597 update_key
598 );
599 let response_data = callback(request_body).await?;
600 let lora_response = serde_json::from_value::<LoraResponse>(response_data.clone())
601 .unwrap_or_else(|_| parse_lora_response(&response_data));
602 return Ok(lora_response);
603 }
604
605 anyhow::bail!(
606 "LoRA management not available: no '{}' handler is registered \
607 (neither a local LoRA endpoint nor an engine update). This worker \
608 either has LoRA disabled or its backend does not support LoRA \
609 management.",
610 endpoint_name
611 )
612}
613
614fn parse_lora_response(response_data: &serde_json::Value) -> LoraResponse {
616 LoraResponse {
617 status: response_data
618 .get("status")
619 .and_then(|s| s.as_str())
620 .unwrap_or("success")
621 .to_string(),
622 message: response_data
623 .get("message")
624 .and_then(|m| m.as_str())
625 .map(|s| s.to_string()),
626 lora_name: response_data
627 .get("lora_name")
628 .and_then(|n| n.as_str())
629 .map(|s| s.to_string()),
630 lora_id: response_data.get("lora_id").and_then(|id| id.as_u64()),
631 loras: response_data.get("loras").cloned(),
632 count: response_data
633 .get("count")
634 .and_then(|c| c.as_u64())
635 .map(|c| c as usize),
636 }
637}
638
639#[tracing::instrument(skip_all, level = "trace", fields(path = %path))]
644async fn engine_route_handler(
645 state: Arc<SystemStatusState>,
646 Path(path): Path<String>,
647 body: Bytes,
648) -> impl IntoResponse {
649 tracing::trace!("Engine route request to /engine/{path}");
650
651 let body_json: serde_json::Value = if body.is_empty() {
653 serde_json::json!({})
654 } else {
655 match serde_json::from_slice(&body) {
656 Ok(json) => json,
657 Err(e) => {
658 tracing::warn!("Invalid JSON in request body: {e}");
659 return (
660 StatusCode::BAD_REQUEST,
661 json!({
662 "error": "Invalid JSON",
663 "message": format!("{}", e)
664 })
665 .to_string(),
666 )
667 .into_response();
668 }
669 }
670 };
671
672 let callback = match state.drt().engine_routes().get(&path) {
674 Some(cb) => cb,
675 None => {
676 tracing::debug!("Route /engine/{path} not found");
677 return (
678 StatusCode::NOT_FOUND,
679 json!({
680 "error": "Route not found",
681 "message": format!("Route /engine/{} not found", path)
682 })
683 .to_string(),
684 )
685 .into_response();
686 }
687 };
688
689 match callback(body_json).await {
691 Ok(response) => {
692 tracing::trace!("Engine route handler succeeded for /engine/{path}");
693 (StatusCode::OK, response.to_string()).into_response()
694 }
695 Err(e) => {
696 tracing::error!("Engine route handler error for /engine/{}: {}", path, e);
697 (
698 StatusCode::INTERNAL_SERVER_ERROR,
699 json!({
700 "error": "Handler error",
701 "message": format!("{}", e)
702 })
703 .to_string(),
704 )
705 .into_response()
706 }
707 }
708}
709
710#[cfg(test)]
712mod tests {
713 use super::*;
714 use tokio::time::Duration;
715
716 #[tokio::test]
718 async fn test_http_server_lifecycle() {
719 let cancel_token = CancellationToken::new();
720 let cancel_token_for_server = cancel_token.clone();
721
722 let app = Router::new().route("/test", get(|| async { (StatusCode::OK, "test") }));
724
725 let server_handle = tokio::spawn(async move {
727 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
728 let _ = axum::serve(listener, app)
729 .with_graceful_shutdown(cancel_token_for_server.cancelled_owned())
730 .await;
731 });
732
733 cancel_token.cancel();
737
738 let result = tokio::time::timeout(Duration::from_secs(5), server_handle).await;
740 assert!(
741 result.is_ok(),
742 "HTTP server should shut down when cancel token is cancelled"
743 );
744 }
745}
746
747#[cfg(all(test, feature = "integration"))]
749mod integration_tests {
750 use super::*;
751 use crate::config::environment_names::logging as env_logging;
752 use crate::config::environment_names::runtime::canary as env_canary;
753 use crate::distributed::distributed_test_utils::create_test_drt_async;
754 use crate::metrics::MetricsHierarchy;
755 use anyhow::Result;
756 use rstest::rstest;
757 use std::sync::Arc;
758 use tokio::time::Duration;
759
760 #[tokio::test]
761 async fn test_uptime_from_system_health() {
762 temp_env::async_with_vars([(env_system::DYN_SYSTEM_PORT, None::<&str>)], async {
764 let drt = create_test_drt_async().await;
765
766 let uptime = drt.system_health().lock().uptime();
768 assert!(uptime.as_nanos() > 0 || uptime.is_zero());
770
771 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
773 let uptime_after = drt.system_health().lock().uptime();
774 assert!(uptime_after > uptime);
775 })
776 .await;
777 }
778
779 #[tokio::test]
780 async fn test_runtime_metrics_initialization_and_namespace() {
781 temp_env::async_with_vars([(env_system::DYN_SYSTEM_PORT, None::<&str>)], async {
783 let drt = create_test_drt_async().await;
784 let response = drt.metrics().prometheus_expfmt().unwrap();
789 println!("Full metrics response:\n{}", response);
790
791 assert!(
793 response.contains("# HELP dynamo_component_uptime_seconds"),
794 "Should contain uptime_seconds help text"
795 );
796 assert!(
797 response.contains("# TYPE dynamo_component_uptime_seconds gauge"),
798 "Should contain uptime_seconds type"
799 );
800 assert!(
801 response.contains("dynamo_component_uptime_seconds"),
802 "Should contain uptime_seconds metric with correct namespace"
803 );
804 })
805 .await;
806 }
807
808 #[tokio::test]
809 async fn test_uptime_gauge_updates() {
810 temp_env::async_with_vars([(env_system::DYN_SYSTEM_PORT, None::<&str>)], async {
812 let drt = create_test_drt_async().await;
813
814 let initial_uptime = drt.system_health().lock().uptime();
816
817 drt.system_health().lock().update_uptime_gauge();
819
820 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
822
823 let uptime_after_sleep = drt.system_health().lock().uptime();
825
826 drt.system_health().lock().update_uptime_gauge();
828
829 let elapsed = uptime_after_sleep - initial_uptime;
831 assert!(
832 elapsed >= std::time::Duration::from_millis(100),
833 "Uptime should have increased by at least 100ms after sleep, but only increased by {:?}",
834 elapsed
835 );
836 })
837 .await;
838 }
839
840 #[tokio::test]
841 async fn test_http_requests_fail_when_system_disabled() {
842 temp_env::async_with_vars([(env_system::DYN_SYSTEM_PORT, None::<&str>)], async {
844 let drt = create_test_drt_async().await;
845
846 let system_info = drt.system_status_server_info();
848 assert!(
849 system_info.is_none(),
850 "System status server should not be running when disabled"
851 );
852
853 println!("✓ System status server correctly disabled when not enabled");
854 })
855 .await;
856 }
857
858 #[rstest]
865 #[case("ready", 200, "ready", None, None, 3)]
866 #[case("notready", 503, "notready", None, None, 3)]
867 #[case("ready", 200, "ready", Some("/custom/health"), Some("/custom/live"), 5)]
868 #[case(
869 "notready",
870 503,
871 "notready",
872 Some("/custom/health"),
873 Some("/custom/live"),
874 5
875 )]
876 #[tokio::test]
877 #[cfg(feature = "integration")]
878 async fn test_health_endpoints(
879 #[case] starting_health_status: &'static str,
880 #[case] expected_status: u16,
881 #[case] expected_body: &'static str,
882 #[case] custom_health_path: Option<&'static str>,
883 #[case] custom_live_path: Option<&'static str>,
884 #[case] expected_num_tests: usize,
885 ) {
886 use std::sync::Arc;
887 crate::logging::init();
893
894 #[allow(clippy::redundant_closure_call)]
895 temp_env::async_with_vars(
896 [
897 (env_system::DYN_SYSTEM_PORT, Some("0")),
898 (
899 env_system::DYN_SYSTEM_STARTING_HEALTH_STATUS,
900 Some(starting_health_status),
901 ),
902 (env_system::DYN_SYSTEM_HEALTH_PATH, custom_health_path),
903 (env_system::DYN_SYSTEM_LIVE_PATH, custom_live_path),
904 ],
905 (async || {
906 let drt = Arc::new(create_test_drt_async().await);
907
908 let system_info = drt
910 .system_status_server_info()
911 .expect("System status server should be started by DRT");
912 let addr = system_info.socket_addr;
913
914 let client = reqwest::Client::new();
915
916 let mut test_cases = vec![];
918 match custom_health_path {
919 None => {
920 test_cases.push(("/health", expected_status, expected_body));
922 }
923 Some(chp) => {
924 test_cases.push(("/health", 404, "Route not found"));
926 test_cases.push((chp, expected_status, expected_body));
927 }
928 }
929 match custom_live_path {
930 None => {
931 test_cases.push(("/live", expected_status, expected_body));
933 }
934 Some(clp) => {
935 test_cases.push(("/live", 404, "Route not found"));
937 test_cases.push((clp, expected_status, expected_body));
938 }
939 }
940 test_cases.push(("/someRandomPathNotFoundHere", 404, "Route not found"));
941 assert_eq!(test_cases.len(), expected_num_tests);
942
943 for (path, expect_status, expect_body) in test_cases {
944 println!("[test] Sending request to {}", path);
945 let url = format!("http://{}{}", addr, path);
946 let response = client.get(&url).send().await.unwrap();
947 let status = response.status();
948 let body = response.text().await.unwrap();
949 println!(
950 "[test] Response for {}: status={}, body={:?}",
951 path, status, body
952 );
953 assert_eq!(
954 status, expect_status,
955 "Response: status={}, body={:?}",
956 status, body
957 );
958 assert!(
959 body.contains(expect_body),
960 "Response: status={}, body={:?}",
961 status,
962 body
963 );
964 }
965 })(),
966 )
967 .await;
968 }
969
970 #[tokio::test]
971 async fn test_health_endpoint_tracing() -> Result<()> {
972 use std::sync::Arc;
973
974 #[allow(clippy::redundant_closure_call)]
977 let _ = temp_env::async_with_vars(
978 [
979 (env_system::DYN_SYSTEM_PORT, Some("0")),
980 (env_system::DYN_SYSTEM_STARTING_HEALTH_STATUS, Some("ready")),
981 (env_logging::DYN_LOGGING_JSONL, Some("1")),
982 (env_logging::DYN_LOG, Some("trace")),
983 ],
984 (async || {
985 crate::logging::init();
989
990 let drt = Arc::new(create_test_drt_async().await);
991
992 let system_info = drt
994 .system_status_server_info()
995 .expect("System status server should be started by DRT");
996 let addr = system_info.socket_addr;
997 let client = reqwest::Client::new();
998 for path in [("/health"), ("/live"), ("/someRandomPathNotFoundHere")] {
999 let traceparent_value =
1000 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
1001 let tracestate_value = "vendor1=opaqueValue1,vendor2=opaqueValue2";
1002 let mut headers = reqwest::header::HeaderMap::new();
1003 headers.insert(
1004 reqwest::header::HeaderName::from_static("traceparent"),
1005 reqwest::header::HeaderValue::from_str(traceparent_value)?,
1006 );
1007 headers.insert(
1008 reqwest::header::HeaderName::from_static("tracestate"),
1009 reqwest::header::HeaderValue::from_str(tracestate_value)?,
1010 );
1011 let url = format!("http://{}{}", addr, path);
1012 let response = client.get(&url).headers(headers).send().await.unwrap();
1013 let status = response.status();
1014 let body = response.text().await.unwrap();
1015 tracing::info!(body = body, status = status.to_string());
1016 }
1017
1018 Ok::<(), anyhow::Error>(())
1019 })(),
1020 )
1021 .await;
1022 Ok(())
1023 }
1024
1025 #[tokio::test]
1026 async fn test_health_endpoint_with_changing_health_status() {
1027 const ENDPOINT_NAME: &str = "generate";
1030 const ENDPOINT_HEALTH_CONFIG: &str = "[\"generate\"]";
1031 temp_env::async_with_vars(
1032 [
1033 (env_system::DYN_SYSTEM_PORT, Some("0")),
1034 (env_system::DYN_SYSTEM_STARTING_HEALTH_STATUS, Some("notready")),
1035 (env_system::DYN_SYSTEM_USE_ENDPOINT_HEALTH_STATUS, Some(ENDPOINT_HEALTH_CONFIG)),
1036 ],
1037 async {
1038 let drt = Arc::new(create_test_drt_async().await);
1039
1040 let system_info_opt = drt.system_status_server_info();
1042
1043 assert!(
1045 system_info_opt.is_some(),
1046 "System status server was not spawned by DRT. Expected DRT to spawn server when DYN_SYSTEM_PORT is set to a positive value, but system_status_server_info() returned None. Environment: DYN_SYSTEM_PORT={:?}",
1047 std::env::var(env_system::DYN_SYSTEM_PORT)
1048 );
1049
1050 let system_info = system_info_opt.unwrap();
1052 let addr = system_info.socket_addr;
1053
1054 let client = reqwest::Client::new();
1056 let health_url = format!("http://{}/health", addr);
1057
1058 let response = client.get(&health_url).send().await.unwrap();
1059 let status = response.status();
1060 let body = response.text().await.unwrap();
1061
1062 assert_eq!(status, 503, "Health should be 503 (not ready) initially, got: {}", status);
1064 assert!(body.contains("\"status\":\"notready\""), "Health should contain status notready");
1065
1066 let namespace = drt.namespace("ns1234").unwrap();
1068 let component = namespace.component("comp1234").unwrap();
1069
1070 use crate::pipeline::{async_trait, network::Ingress, AsyncEngine, AsyncEngineContextProvider, Error, ManyOut, SingleIn};
1072 use crate::protocols::annotated::Annotated;
1073
1074 struct TestHandler;
1075
1076 #[async_trait]
1077 impl AsyncEngine<SingleIn<String>, ManyOut<Annotated<String>>, anyhow::Error> for TestHandler {
1078 async fn generate(&self, input: SingleIn<String>) -> anyhow::Result<ManyOut<Annotated<String>>> {
1079 let (data, ctx) = input.into_parts();
1080 let response = Annotated::from_data(format!("You responded: {}", data));
1081 Ok(crate::pipeline::ResponseStream::new(
1082 Box::pin(crate::stream::iter(vec![response])),
1083 ctx.context()
1084 ))
1085 }
1086 }
1087
1088 let ingress = Ingress::for_engine(std::sync::Arc::new(TestHandler)).unwrap();
1090
1091 tokio::spawn(async move {
1094 let _ = component.endpoint(ENDPOINT_NAME)
1095 .endpoint_builder()
1096 .handler(ingress)
1097 .health_check_payload(serde_json::json!({
1098 "test": "health_check"
1099 }))
1100 .start()
1101 .await;
1102 });
1103
1104 let mut success_count = 0;
1106 let mut failures = Vec::new();
1107
1108 for i in 1..=200 {
1109 let response = client.get(&health_url).send().await.unwrap();
1110 let status = response.status();
1111 let body = response.text().await.unwrap();
1112
1113 if status == 200 && body.contains("\"status\":\"ready\"") {
1114 success_count += 1;
1115 } else {
1116 failures.push((i, status.as_u16(), body.clone()));
1117 if failures.len() <= 5 { tracing::warn!("Request {}: status={}, body={}", i, status, body);
1119 }
1120 }
1121 }
1122
1123 tracing::info!("Health endpoint test results: {success_count}/200 requests succeeded");
1124 if !failures.is_empty() {
1125 tracing::warn!("Failed requests: {}", failures.len());
1126 }
1127
1128 assert!(success_count >= 150, "Expected at least 150 out of 200 requests to succeed, but only {} succeeded", success_count);
1130 },
1131 )
1132 .await;
1133 }
1134
1135 #[tokio::test]
1136 async fn test_spawn_system_status_server_endpoints() {
1137 temp_env::async_with_vars(
1139 [
1140 (env_system::DYN_SYSTEM_PORT, Some("0")),
1141 (env_system::DYN_SYSTEM_STARTING_HEALTH_STATUS, Some("ready")),
1142 ],
1143 async {
1144 let drt = Arc::new(create_test_drt_async().await);
1145
1146 let system_info = drt
1148 .system_status_server_info()
1149 .expect("System status server should be started by DRT");
1150 let addr = system_info.socket_addr;
1151 let client = reqwest::Client::new();
1152 for (path, expect_200, expect_body) in [
1153 ("/health", true, "ready"),
1154 ("/live", true, "ready"),
1155 ("/someRandomPathNotFoundHere", false, "Route not found"),
1156 ] {
1157 println!("[test] Sending request to {}", path);
1158 let url = format!("http://{}{}", addr, path);
1159 let response = client.get(&url).send().await.unwrap();
1160 let status = response.status();
1161 let body = response.text().await.unwrap();
1162 println!(
1163 "[test] Response for {}: status={}, body={:?}",
1164 path, status, body
1165 );
1166 if expect_200 {
1167 assert_eq!(status, 200, "Response: status={}, body={:?}", status, body);
1168 } else {
1169 assert_eq!(status, 404, "Response: status={}, body={:?}", status, body);
1170 }
1171 assert!(
1172 body.contains(expect_body),
1173 "Response: status={}, body={:?}",
1174 status,
1175 body
1176 );
1177 }
1178 },
1180 )
1181 .await;
1182 }
1183
1184 #[cfg(feature = "integration")]
1185 #[tokio::test]
1186 async fn test_health_check_with_payload_and_timeout() {
1187 crate::logging::init();
1189
1190 temp_env::async_with_vars(
1191 [
1192 (env_system::DYN_SYSTEM_PORT, Some("0")),
1193 (
1194 env_system::DYN_SYSTEM_STARTING_HEALTH_STATUS,
1195 Some("notready"),
1196 ),
1197 (
1198 env_system::DYN_SYSTEM_USE_ENDPOINT_HEALTH_STATUS,
1199 Some("[\"test.endpoint\"]"),
1200 ),
1201 ("DYN_HEALTH_CHECK_ENABLED", Some("true")),
1203 (env_canary::DYN_CANARY_WAIT_TIME, Some("1")), ("DYN_HEALTH_CHECK_REQUEST_TIMEOUT", Some("1")), ("RUST_LOG", Some("info")), ],
1207 async {
1208 let drt = Arc::new(create_test_drt_async().await);
1209
1210 let system_info = drt
1212 .system_status_server_info()
1213 .expect("System status server should be started");
1214 let addr = system_info.socket_addr;
1215
1216 let client = reqwest::Client::new();
1217 let health_url = format!("http://{}/health", addr);
1218
1219 let endpoint = "test.endpoint";
1221 let health_check_payload = serde_json::json!({
1222 "prompt": "health check test",
1223 "_health_check": true
1224 });
1225
1226 {
1228 let system_health = drt.system_health();
1229 let system_health_lock = system_health.lock();
1230 system_health_lock.register_health_check_target(
1231 endpoint,
1232 crate::component::Instance {
1233 component: "test_component".to_string(),
1234 endpoint: "health".to_string(),
1235 namespace: "test_namespace".to_string(),
1236 instance_id: 1,
1237 transport: crate::component::TransportType::Nats(endpoint.to_string()),
1238 device_type: None,
1239 },
1240 health_check_payload.clone(),
1241 );
1242 }
1243
1244 let response = client.get(&health_url).send().await.unwrap();
1246 let status = response.status();
1247 let body = response.text().await.unwrap();
1248 assert_eq!(status, 503, "Should be unhealthy initially (default state)");
1249 assert!(
1250 body.contains("\"status\":\"notready\""),
1251 "Should show notready status initially"
1252 );
1253
1254 drt.system_health()
1256 .lock()
1257 .set_endpoint_health_status(endpoint, HealthStatus::Ready);
1258
1259 let response = client.get(&health_url).send().await.unwrap();
1261 let status = response.status();
1262 let body = response.text().await.unwrap();
1263
1264 assert_eq!(status, 200, "Should be healthy due to recent response");
1265 assert!(
1266 body.contains("\"status\":\"ready\""),
1267 "Should show ready status after response"
1268 );
1269
1270 let endpoint_status = drt
1272 .system_health()
1273 .lock()
1274 .get_endpoint_health_status(endpoint);
1275 assert_eq!(
1276 endpoint_status,
1277 Some(HealthStatus::Ready),
1278 "SystemHealth should show endpoint as Ready after response"
1279 );
1280 },
1281 )
1282 .await;
1283 }
1284
1285 #[tokio::test]
1290 async fn test_call_lora_endpoint_resolves_via_engine_routes() {
1291 temp_env::async_with_vars([(env_system::DYN_SYSTEM_PORT, None::<&str>)], async {
1292 let drt = create_test_drt_async().await;
1293
1294 let callback: crate::engine_routes::EngineRouteCallback = Arc::new(|_body| {
1295 Box::pin(async move {
1296 Ok(serde_json::json!({
1297 "status": "success",
1298 "lora_name": "adapterA",
1299 "lora_id": 42,
1300 }))
1301 })
1302 });
1303 drt.engine_routes().register("update/load_lora", callback);
1305
1306 assert!(drt.local_endpoint_registry().get("load_lora").is_none());
1309
1310 let response = call_lora_endpoint(
1311 &drt,
1312 "load_lora",
1313 serde_json::json!({"lora_name": "adapterA"}),
1314 )
1315 .await
1316 .expect("engine_routes fallback should resolve the control");
1317
1318 assert_eq!(response.status, "success");
1319 assert_eq!(response.lora_name.as_deref(), Some("adapterA"));
1320 assert_eq!(response.lora_id, Some(42));
1321 })
1322 .await;
1323 }
1324
1325 #[tokio::test]
1329 async fn test_call_lora_endpoint_missing_returns_clean_error() {
1330 temp_env::async_with_vars([(env_system::DYN_SYSTEM_PORT, None::<&str>)], async {
1331 let drt = create_test_drt_async().await;
1332
1333 let err = call_lora_endpoint(&drt, "load_lora", serde_json::json!({}))
1334 .await
1335 .expect_err("missing handler must error");
1336
1337 assert!(
1338 err.to_string().contains("LoRA management not available"),
1339 "expected explicit unavailable message, got: {err}"
1340 );
1341 })
1342 .await;
1343 }
1344
1345 #[tokio::test]
1350 async fn test_call_lora_endpoint_propagates_error_status() {
1351 temp_env::async_with_vars([(env_system::DYN_SYSTEM_PORT, None::<&str>)], async {
1352 let drt = create_test_drt_async().await;
1353
1354 let callback: crate::engine_routes::EngineRouteCallback = Arc::new(|_body| {
1355 Box::pin(async move {
1356 Ok(serde_json::json!({
1357 "status": "error",
1358 "message": "adapter not found",
1359 }))
1360 })
1361 });
1362 drt.engine_routes().register("update/unload_lora", callback);
1364
1365 let response = call_lora_endpoint(&drt, "unload_lora", serde_json::json!({}))
1366 .await
1367 .expect("a non-raising callback returns Ok even on logical error");
1368
1369 assert_eq!(response.status, "error");
1370 assert_eq!(response.message.as_deref(), Some("adapter not found"));
1371 })
1372 .await;
1373 }
1374}