1mod service;
23mod sources;
24
25pub use sources::RuntimeDiagnosticsSources;
26
27pub use service::{
28 RuntimeDiagnosticsDataProvider, RuntimeDiagnosticsMode, RuntimeDiagnosticsService, RuntimeDiagnosticsStarted,
29 RUNTIME_DIAGNOSTICS_MODE_ENV,
30};
31
32use std::net::IpAddr;
33use std::net::SocketAddr;
34use std::path::Component;
35use std::path::Path;
36use std::path::PathBuf;
37use std::time::Duration;
38
39use rocketmq_runtime::ChildServiceContext;
40use rocketmq_runtime::RuntimeComponent;
41use rocketmq_runtime::RuntimeDiagnosticsInputs;
42use rocketmq_runtime::RuntimeDiagnosticsViewV1;
43use rocketmq_runtime::RuntimeDiagnosticsViewV2;
44use serde::Serialize;
45use tokio::io::AsyncReadExt;
46use tokio::io::AsyncWriteExt;
47use tokio::net::TcpListener;
48use tokio::net::TcpStream;
49
50use crate::ObservabilityError;
51
52pub const RUNTIME_DIAGNOSTICS_BIND_ADDR_ENV: &str = "ROCKETMQ_RUNTIME_DIAGNOSTICS_BIND_ADDR";
53pub const RUNTIME_DIAGNOSTICS_TOKEN_FILE_ENV: &str = "ROCKETMQ_RUNTIME_DIAGNOSTICS_TOKEN_FILE";
54pub const RUNTIME_DIAGNOSTICS_ALLOW_INSECURE_HTTP_ENV: &str = "ROCKETMQ_RUNTIME_DIAGNOSTICS_ALLOW_INSECURE_HTTP";
55pub const RUNTIME_DIAGNOSTICS_SAMPLE_INTERVAL_SECONDS_ENV: &str =
56 "ROCKETMQ_RUNTIME_DIAGNOSTICS_SAMPLE_INTERVAL_SECONDS";
57pub const RUNTIME_DIAGNOSTICS_SCOPE: &str = "rocketmq:diagnose";
58pub const RUNTIME_DIAGNOSTICS_PATH: &str = "/internal/v1/runtime/diagnostics";
59pub const RUNTIME_DIAGNOSTICS_ENDPOINT_SCHEMA: &str = "rocketmq.runtime-diagnostics-endpoint.v1";
60pub const RUNTIME_DIAGNOSTICS_V2_PATH: &str = "/internal/v2/runtime/diagnostics";
67pub const RUNTIME_DIAGNOSTICS_ENDPOINT_SCHEMA_V2: &str = "rocketmq.runtime-diagnostics-endpoint.v2";
68pub const RUNTIME_METADATA_TARGETS_PATH: &str = "/internal/v1/runtime/metadata-targets";
70
71const DEFAULT_SAMPLE_INTERVAL: Duration = Duration::from_secs(10);
72const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
73const MAX_REQUEST_BYTES: usize = 8 * 1024;
74const MAX_TOKEN_BYTES: u64 = 64 * 1024;
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct RuntimeDiagnosticsEndpointConfig {
79 bind_addr: SocketAddr,
80 token_file: PathBuf,
81 sample_interval: Duration,
82}
83
84impl RuntimeDiagnosticsEndpointConfig {
85 pub fn try_new(
97 bind_addr: SocketAddr,
98 token_file: PathBuf,
99 sample_interval: Duration,
100 allow_insecure_http: bool,
101 ) -> Result<Self, ObservabilityError> {
102 validate_token_path(&token_file)?;
103 if sample_interval.is_zero() || sample_interval > Duration::from_secs(300) {
104 return Err(ObservabilityError::invalid_config(
105 "runtime diagnostics sampling interval must be between 1 and 300 seconds",
106 ));
107 }
108 if !is_loopback(bind_addr.ip()) && !allow_insecure_http {
109 return Err(ObservabilityError::invalid_config(
110 "non-loopback runtime diagnostics HTTP requires the explicit development-only opt-in",
111 ));
112 }
113 Ok(Self {
114 bind_addr,
115 token_file,
116 sample_interval,
117 })
118 }
119
120 pub fn from_env() -> Result<Option<Self>, ObservabilityError> {
130 let bind_addr = optional_env(RUNTIME_DIAGNOSTICS_BIND_ADDR_ENV)?;
131 let token_file = optional_env(RUNTIME_DIAGNOSTICS_TOKEN_FILE_ENV)?;
132 let (Some(bind_addr), Some(token_file)) = (bind_addr, token_file) else {
133 if std::env::var_os(RUNTIME_DIAGNOSTICS_BIND_ADDR_ENV).is_some()
134 || std::env::var_os(RUNTIME_DIAGNOSTICS_TOKEN_FILE_ENV).is_some()
135 {
136 return Err(ObservabilityError::invalid_config(
137 "runtime diagnostics bind address and token-file reference must be configured together",
138 ));
139 }
140 return Ok(None);
141 };
142 let bind_addr = bind_addr.parse::<SocketAddr>().map_err(|_| {
143 ObservabilityError::invalid_config("runtime diagnostics bind address must be a socket address")
144 })?;
145 let allow_insecure_http = parse_bool_env(RUNTIME_DIAGNOSTICS_ALLOW_INSECURE_HTTP_ENV, false)?;
146 let sample_interval = parse_sample_interval()?;
147 Self::try_new(
148 bind_addr,
149 PathBuf::from(token_file),
150 sample_interval,
151 allow_insecure_http,
152 )
153 .map(Some)
154 }
155
156 pub fn bind_addr(&self) -> SocketAddr {
157 self.bind_addr
158 }
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub struct RuntimeDiagnosticsEndpointHandle {
164 local_addr: SocketAddr,
165}
166
167impl RuntimeDiagnosticsEndpointHandle {
168 pub fn local_addr(self) -> SocketAddr {
169 self.local_addr
170 }
171}
172
173pub async fn start_runtime_diagnostics_endpoint_from_env(
183 service_context: &ChildServiceContext,
184 component: RuntimeComponent,
185) -> Result<Option<RuntimeDiagnosticsEndpointHandle>, ObservabilityError> {
186 start_runtime_diagnostics_endpoint_from_env_with_telemetry(
187 service_context,
188 component,
189 &crate::TelemetryHandle::noop(),
190 )
191 .await
192}
193
194pub async fn start_runtime_diagnostics_endpoint_from_env_with_telemetry(
202 service_context: &ChildServiceContext,
203 component: RuntimeComponent,
204 telemetry: &crate::TelemetryHandle,
205) -> Result<Option<RuntimeDiagnosticsEndpointHandle>, ObservabilityError> {
206 let mode = RuntimeDiagnosticsMode::from_env()?;
207 if mode == RuntimeDiagnosticsMode::Disabled {
208 return Ok(None);
209 }
210 RuntimeDiagnosticsService::new(
211 service_context,
212 component,
213 telemetry.clone(),
214 std::sync::Arc::new(RuntimeDiagnosticsInputs::default),
215 )
216 .start(mode)
217 .await
218 .map(|started| started.endpoint)
219}
220
221pub async fn start_runtime_diagnostics_endpoint(
228 service_context: &ChildServiceContext,
229 component: RuntimeComponent,
230 config: RuntimeDiagnosticsEndpointConfig,
231) -> Result<RuntimeDiagnosticsEndpointHandle, ObservabilityError> {
232 start_runtime_diagnostics_endpoint_with_telemetry(
233 service_context,
234 component,
235 config,
236 &crate::TelemetryHandle::noop(),
237 )
238 .await
239}
240
241async fn start_runtime_diagnostics_endpoint_with_telemetry(
242 service_context: &ChildServiceContext,
243 component: RuntimeComponent,
244 config: RuntimeDiagnosticsEndpointConfig,
245 telemetry: &crate::TelemetryHandle,
246) -> Result<RuntimeDiagnosticsEndpointHandle, ObservabilityError> {
247 RuntimeDiagnosticsService::new(
248 service_context,
249 component,
250 telemetry.clone(),
251 std::sync::Arc::new(RuntimeDiagnosticsInputs::default),
252 )
253 .start(RuntimeDiagnosticsMode::EndpointAndMetrics(config))
254 .await?
255 .endpoint
256 .ok_or_else(|| ObservabilityError::invalid_config("runtime diagnostics endpoint did not start"))
257}
258
259struct EndpointContext {
260 owner: ChildServiceContext,
261 observed: ChildServiceContext,
262 component: RuntimeComponent,
263 token_file: PathBuf,
264 metrics: crate::metrics::runtime::RuntimeMetricsRecorder,
265 provider: std::sync::Arc<dyn RuntimeDiagnosticsDataProvider>,
266}
267
268async fn serve(listener: TcpListener, context: EndpointContext, cancellation: tokio_util::sync::CancellationToken) {
269 loop {
270 tokio::select! {
271 _ = cancellation.cancelled() => break,
272 accepted = listener.accept() => {
273 match accepted {
274 Ok((stream, _peer)) => {
275 handle_connection(stream, &context).await;
276 }
277 Err(error) => {
278 tracing::warn!(
279 component = component_name(context.component),
280 error_kind = ?error.kind(),
281 "runtime diagnostics accept failed"
282 );
283 break;
284 }
285 }
286 }
287 }
288 }
289}
290
291async fn handle_connection(mut stream: TcpStream, context: &EndpointContext) {
292 let response = match tokio::time::timeout(REQUEST_TIMEOUT, read_request(&mut stream)).await {
293 Ok(Ok(request)) => route_request(&request, context).await,
294 Ok(Err(status)) => error_response(status),
295 Err(_) => error_response(HttpStatus::RequestTimeout),
296 };
297 let _ = stream.write_all(&response).await;
298 let _ = stream.shutdown().await;
299}
300
301async fn route_request(request: &[u8], context: &EndpointContext) -> Vec<u8> {
302 let Ok(request) = ParsedRequest::parse(request) else {
303 return error_response(HttpStatus::BadRequest);
304 };
305 if request.method != "GET" {
306 return error_response(HttpStatus::MethodNotAllowed);
307 }
308 enum View {
309 Legacy,
310 Scoped,
311 MetadataTargets,
312 }
313 let view = match request.path {
314 RUNTIME_DIAGNOSTICS_PATH => View::Legacy,
315 RUNTIME_DIAGNOSTICS_V2_PATH => View::Scoped,
316 RUNTIME_METADATA_TARGETS_PATH => View::MetadataTargets,
317 _ => return error_response(HttpStatus::NotFound),
318 };
319 let Some(candidate) = request.bearer_token else {
320 return error_response(HttpStatus::Unauthorized);
321 };
322 if request.scope != Some(RUNTIME_DIAGNOSTICS_SCOPE) {
323 return error_response(HttpStatus::Forbidden);
324 }
325 let expected = match read_token(&context.owner, context.token_file.clone()).await {
326 Ok(token) => token,
327 Err(_) => return error_response(HttpStatus::ServiceUnavailable),
328 };
329 if !constant_time_equal(candidate.as_bytes(), expected.as_bytes()) {
330 return error_response(HttpStatus::Unauthorized);
331 }
332
333 if matches!(view, View::MetadataTargets) {
334 let envelope = serde_json::json!({
335 "schema_version": "rocketmq.runtime-metadata-targets.v1",
336 "source": "rocketmq_process",
337 "scope": "process_shared",
338 "data": context.observed.resources().metadata_target_stats(),
339 });
340 return match serde_json::to_vec(&envelope) {
341 Ok(body) => response(HttpStatus::Ok, &body),
342 Err(_) => error_response(HttpStatus::InternalServerError),
343 };
344 }
345
346 if matches!(view, View::Scoped) {
347 let view = context
348 .observed
349 .diagnostics_view_v2(context.component, context.provider.snapshot());
350 context.metrics.record_snapshot_v2(&view);
351 let envelope = RuntimeDiagnosticsEndpointEnvelopeV2 {
352 schema_version: RUNTIME_DIAGNOSTICS_ENDPOINT_SCHEMA_V2,
353 source: "rocketmq_process",
354 data: view,
355 };
356 return match serde_json::to_vec(&envelope) {
357 Ok(body) => response(HttpStatus::Ok, &body),
358 Err(_) => error_response(HttpStatus::InternalServerError),
359 };
360 }
361
362 let view = context.observed.diagnostics_view_v1(context.component);
363 context.metrics.record_snapshot(&view);
364 let envelope = RuntimeDiagnosticsEndpointEnvelopeV1 {
365 schema_version: RUNTIME_DIAGNOSTICS_ENDPOINT_SCHEMA,
366 source: "rocketmq_process",
367 data: view,
368 };
369 match serde_json::to_vec(&envelope) {
370 Ok(body) => response(HttpStatus::Ok, &body),
371 Err(_) => error_response(HttpStatus::InternalServerError),
372 }
373}
374
375async fn read_request(stream: &mut TcpStream) -> Result<Vec<u8>, HttpStatus> {
376 let mut request = Vec::with_capacity(1024);
377 loop {
378 if request.len() == MAX_REQUEST_BYTES {
379 return Err(HttpStatus::PayloadTooLarge);
380 }
381 let remaining = MAX_REQUEST_BYTES - request.len();
382 let mut buffer = [0_u8; 1024];
383 let read_capacity = remaining.min(buffer.len());
384 let read = stream
385 .read(&mut buffer[..read_capacity])
386 .await
387 .map_err(|_| HttpStatus::BadRequest)?;
388 if read == 0 {
389 return Err(HttpStatus::BadRequest);
390 }
391 request.extend_from_slice(&buffer[..read]);
392 if request.windows(4).any(|window| window == b"\r\n\r\n") {
393 return Ok(request);
394 }
395 }
396}
397
398async fn read_token(service_context: &ChildServiceContext, token_file: PathBuf) -> Result<String, ObservabilityError> {
399 service_context
400 .metadata_io()
401 .spawn_io("runtime-diagnostics.read-token", move || read_token_file(&token_file))
402 .await
403 .map_err(|_| ObservabilityError::invalid_config("runtime diagnostics token read was not admitted"))?
404}
405
406fn read_token_file(path: &Path) -> Result<String, ObservabilityError> {
407 let parent = path
408 .parent()
409 .ok_or_else(|| ObservabilityError::invalid_config("runtime diagnostics token path has no parent"))?;
410 let mount_root = std::fs::canonicalize(parent)
411 .map_err(|_| ObservabilityError::invalid_config("runtime diagnostics token mount cannot be resolved"))?;
412 let resolved = std::fs::canonicalize(path)
413 .map_err(|_| ObservabilityError::invalid_config("runtime diagnostics token cannot be resolved"))?;
414 if !resolved.starts_with(&mount_root) || resolved == mount_root {
415 return Err(ObservabilityError::invalid_config(
416 "runtime diagnostics token escaped its configured mount",
417 ));
418 }
419 let metadata = std::fs::metadata(&resolved)
420 .map_err(|_| ObservabilityError::invalid_config("runtime diagnostics token cannot be inspected"))?;
421 if !metadata.is_file() || metadata.len() > MAX_TOKEN_BYTES {
422 return Err(ObservabilityError::invalid_config(
423 "runtime diagnostics token must be a bounded regular file",
424 ));
425 }
426 let token = std::fs::read_to_string(resolved)
427 .map_err(|_| ObservabilityError::invalid_config("runtime diagnostics token cannot be read"))?;
428 let token = token.trim().to_owned();
429 if token.is_empty() || u64::try_from(token.len()).map_or(true, |length| length > MAX_TOKEN_BYTES) {
430 return Err(ObservabilityError::invalid_config(
431 "runtime diagnostics token contains an invalid value",
432 ));
433 }
434 Ok(token)
435}
436
437fn validate_token_path(path: &Path) -> Result<(), ObservabilityError> {
438 if !path.is_absolute()
439 || path
440 .components()
441 .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
442 {
443 return Err(ObservabilityError::invalid_config(
444 "runtime diagnostics token path must be absolute and normalized",
445 ));
446 }
447 Ok(())
448}
449
450fn optional_env(name: &'static str) -> Result<Option<String>, ObservabilityError> {
451 let Some(value) = std::env::var_os(name) else {
452 return Ok(None);
453 };
454 value
455 .into_string()
456 .map(Some)
457 .map_err(|_| ObservabilityError::invalid_config(format!("{name} must contain valid UTF-8")))
458}
459
460fn parse_bool_env(name: &'static str, default: bool) -> Result<bool, ObservabilityError> {
461 let Some(raw) = optional_env(name)? else {
462 return Ok(default);
463 };
464 match raw.as_str() {
465 "true" | "1" => Ok(true),
466 "false" | "0" => Ok(false),
467 _ => Err(ObservabilityError::invalid_config(format!(
468 "{name} must be true, false, 1, or 0"
469 ))),
470 }
471}
472
473fn parse_sample_interval() -> Result<Duration, ObservabilityError> {
474 let Some(raw) = optional_env(RUNTIME_DIAGNOSTICS_SAMPLE_INTERVAL_SECONDS_ENV)? else {
475 return Ok(DEFAULT_SAMPLE_INTERVAL);
476 };
477 let seconds = raw.parse::<u64>().map_err(|_| {
478 ObservabilityError::invalid_config("runtime diagnostics sampling interval must be an integer number of seconds")
479 })?;
480 Ok(Duration::from_secs(seconds))
481}
482
483fn is_loopback(ip: IpAddr) -> bool {
484 ip.is_loopback()
485}
486
487fn constant_time_equal(left: &[u8], right: &[u8]) -> bool {
488 let maximum = left.len().max(right.len());
489 let mut difference = left.len() ^ right.len();
490 for index in 0..maximum {
491 let left = left.get(index).copied().unwrap_or_default();
492 let right = right.get(index).copied().unwrap_or_default();
493 difference |= usize::from(left ^ right);
494 }
495 difference == 0
496}
497
498fn component_name(component: RuntimeComponent) -> &'static str {
499 match component {
500 RuntimeComponent::Broker => "broker",
501 RuntimeComponent::NameServer => "name_server",
502 RuntimeComponent::Controller => "controller",
503 RuntimeComponent::Proxy => "proxy",
504 RuntimeComponent::Mcp => "mcp",
505 RuntimeComponent::SreControlPlane => "sre_control_plane",
506 RuntimeComponent::SreConnector => "sre_connector",
507 RuntimeComponent::Other => "other",
508 }
509}
510
511#[derive(Serialize)]
512struct RuntimeDiagnosticsEndpointEnvelopeV1 {
513 schema_version: &'static str,
514 source: &'static str,
515 data: RuntimeDiagnosticsViewV1,
516}
517
518#[derive(Serialize)]
519struct RuntimeDiagnosticsEndpointEnvelopeV2 {
520 schema_version: &'static str,
521 source: &'static str,
522 data: RuntimeDiagnosticsViewV2,
523}
524
525struct ParsedRequest<'a> {
526 method: &'a str,
527 path: &'a str,
528 bearer_token: Option<&'a str>,
529 scope: Option<&'a str>,
530}
531
532impl<'a> ParsedRequest<'a> {
533 fn parse(request: &'a [u8]) -> Result<Self, ()> {
534 let request = std::str::from_utf8(request).map_err(|_| ())?;
535 let mut lines = request.split("\r\n");
536 let first_line = lines.next().ok_or(())?;
537 let mut fields = first_line.split_ascii_whitespace();
538 let method = fields.next().ok_or(())?;
539 let path = fields.next().ok_or(())?;
540 if fields.next().is_none() || fields.next().is_some() {
541 return Err(());
542 }
543
544 let mut bearer_token = None;
545 let mut scope = None;
546 for line in lines {
547 if line.is_empty() {
548 break;
549 }
550 let (name, value) = line.split_once(':').ok_or(())?;
551 let value = value.trim();
552 if name.eq_ignore_ascii_case("authorization") {
553 if bearer_token.is_some() {
554 return Err(());
555 }
556 bearer_token = value.strip_prefix("Bearer ");
557 if bearer_token.is_none() {
558 return Err(());
559 }
560 } else if name.eq_ignore_ascii_case("x-rocketmq-sre-scope") && scope.replace(value).is_some() {
561 return Err(());
562 }
563 }
564 Ok(Self {
565 method,
566 path,
567 bearer_token,
568 scope,
569 })
570 }
571}
572
573#[derive(Clone, Copy)]
574enum HttpStatus {
575 Ok,
576 BadRequest,
577 Unauthorized,
578 Forbidden,
579 NotFound,
580 MethodNotAllowed,
581 RequestTimeout,
582 PayloadTooLarge,
583 InternalServerError,
584 ServiceUnavailable,
585}
586
587impl HttpStatus {
588 const fn code(self) -> u16 {
589 match self {
590 Self::Ok => 200,
591 Self::BadRequest => 400,
592 Self::Unauthorized => 401,
593 Self::Forbidden => 403,
594 Self::NotFound => 404,
595 Self::MethodNotAllowed => 405,
596 Self::RequestTimeout => 408,
597 Self::PayloadTooLarge => 413,
598 Self::InternalServerError => 500,
599 Self::ServiceUnavailable => 503,
600 }
601 }
602
603 const fn reason(self) -> &'static str {
604 match self {
605 Self::Ok => "OK",
606 Self::BadRequest => "Bad Request",
607 Self::Unauthorized => "Unauthorized",
608 Self::Forbidden => "Forbidden",
609 Self::NotFound => "Not Found",
610 Self::MethodNotAllowed => "Method Not Allowed",
611 Self::RequestTimeout => "Request Timeout",
612 Self::PayloadTooLarge => "Payload Too Large",
613 Self::InternalServerError => "Internal Server Error",
614 Self::ServiceUnavailable => "Service Unavailable",
615 }
616 }
617
618 const fn error_code(self) -> &'static str {
619 match self {
620 Self::Ok => "ok",
621 Self::BadRequest => "bad_request",
622 Self::Unauthorized => "unauthorized_scope",
623 Self::Forbidden => "unauthorized_scope",
624 Self::NotFound => "not_found",
625 Self::MethodNotAllowed => "method_not_allowed",
626 Self::RequestTimeout => "request_timeout",
627 Self::PayloadTooLarge => "request_too_large",
628 Self::InternalServerError => "internal_error",
629 Self::ServiceUnavailable => "source_unavailable",
630 }
631 }
632}
633
634fn error_response(status: HttpStatus) -> Vec<u8> {
635 let body = format!(r#"{{"code":"{}"}}"#, status.error_code());
636 response(status, body.as_bytes())
637}
638
639fn response(status: HttpStatus, body: &[u8]) -> Vec<u8> {
640 let headers = format!(
641 "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: \
642 close\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\n\r\n",
643 status.code(),
644 status.reason(),
645 body.len()
646 );
647 let mut response = Vec::with_capacity(headers.len() + body.len());
648 response.extend_from_slice(headers.as_bytes());
649 response.extend_from_slice(body);
650 response
651}
652
653#[cfg(test)]
654mod tests {
655 use std::fs;
656 use std::sync::atomic::AtomicU64;
657 use std::sync::atomic::Ordering;
658
659 use rocketmq_runtime::RuntimeContext;
660 use rocketmq_runtime::ScheduledExecutionPolicy;
661
662 use super::*;
663
664 static NEXT_TEST_FILE: AtomicU64 = AtomicU64::new(1);
665
666 async fn exercise_mode_matrix(telemetry: crate::TelemetryHandle, metrics_enabled: bool) {
667 for index in 0..4 {
668 let token_file = test_token_path();
669 fs::write(&token_file, "mode-token").unwrap();
670 let config = RuntimeDiagnosticsEndpointConfig::try_new(
671 "127.0.0.1:0".parse().unwrap(),
672 token_file.clone(),
673 Duration::from_secs(1),
674 false,
675 )
676 .unwrap();
677 let mode = match index {
678 0 => RuntimeDiagnosticsMode::Disabled,
679 1 => RuntimeDiagnosticsMode::MetricsOnly {
680 sample_interval: Duration::from_secs(1),
681 },
682 2 => RuntimeDiagnosticsMode::EndpointOnly(config),
683 _ => RuntimeDiagnosticsMode::EndpointAndMetrics(config),
684 };
685 let context = RuntimeContext::from_current("mode-matrix");
686 let parent = context.service_context("business-owner");
687 let sampled = std::sync::Arc::new(tokio::sync::Notify::new());
688 let calls = std::sync::Arc::new(AtomicU64::new(0));
689 let provider = {
690 let sampled = sampled.clone();
691 let calls = calls.clone();
692 std::sync::Arc::new(move || {
693 calls.fetch_add(1, Ordering::Relaxed);
694 sampled.notify_one();
695 RuntimeDiagnosticsInputs::default()
696 })
697 };
698 let service =
699 RuntimeDiagnosticsService::new(&parent, RuntimeComponent::Broker, telemetry.clone(), provider);
700 let clone = service.clone();
701 let (first, second) = tokio::join!(service.start(mode.clone()), clone.start(mode.clone()));
702 let first = first.unwrap();
703 assert_eq!(first, second.unwrap());
704 assert_eq!(first.endpoint.is_some(), index >= 2);
705 let expected_sampler = metrics_enabled && matches!(index, 1 | 3);
706 assert_eq!(first.sampler_active, expected_sampler);
707 if expected_sampler {
708 tokio::time::timeout(Duration::from_secs(2), sampled.notified())
709 .await
710 .unwrap();
711 } else {
712 assert_eq!(calls.load(Ordering::Relaxed), 0);
713 }
714 let view = parent.diagnostics_view_v1(RuntimeComponent::Broker);
715 let scheduled = view
716 .task_kinds
717 .iter()
718 .find(|kind| kind.kind == rocketmq_runtime::RuntimeTaskKindV1::ScheduledDriver);
719 assert_eq!(scheduled.map_or(0, |kind| kind.active), usize::from(expected_sampler));
720 let endpoints = view
721 .task_kinds
722 .iter()
723 .find(|kind| kind.kind == rocketmq_runtime::RuntimeTaskKindV1::Service);
724 assert_eq!(endpoints.map_or(0, |kind| kind.active), usize::from(index >= 2));
725 let other_mode = if index == 0 {
726 RuntimeDiagnosticsMode::MetricsOnly {
727 sample_interval: Duration::from_secs(1),
728 }
729 } else {
730 RuntimeDiagnosticsMode::Disabled
731 };
732 assert!(service.start(other_mode).await.is_err());
733 let report = service
734 .shutdown_until(rocketmq_runtime::ShutdownDeadline::after(Duration::from_secs(2)))
735 .await;
736 assert!(report.is_healthy(), "{}", report.to_json());
737 assert!(service.start(mode).await.is_err());
738 if let Some(endpoint) = first.endpoint {
739 assert!(TcpStream::connect(endpoint.local_addr()).await.is_err());
740 }
741 assert!(context.shutdown_tasks(Duration::from_secs(2)).await.is_healthy());
742 fs::remove_file(token_file).unwrap();
743 }
744 }
745
746 #[tokio::test]
747 async fn four_modes_with_noop_metrics_only_create_requested_endpoints() {
748 exercise_mode_matrix(crate::TelemetryHandle::noop(), false).await;
749 }
750
751 #[tokio::test]
752 async fn caller_owned_sources_reach_http_and_outlive_the_listener_without_write_authority() {
753 use rocketmq_runtime::{
754 MetadataDeadline, MetadataIoAdmissionOutcome, MetadataIoConfig, MetadataWriteRequest, ScheduledTaskConfig,
755 ShutdownDeadline, ShutdownReport,
756 };
757
758 let token_file = test_token_path();
759 fs::write(&token_file, "source-token").unwrap();
760 let target = token_file.with_extension("metadata");
761 let context = RuntimeContext::from_current("source-provider");
762 let parent = context.service_context("provider-component");
763 let schedules = parent.scheduled_tasks("maintenance");
764 let sources = RuntimeDiagnosticsSources::default();
765 sources.set_schedules(schedules.observer(&["selected-job", "selected-job"]));
766 let mut config = ScheduledTaskConfig::fixed_delay("selected-job", Duration::from_secs(3600));
767 config.initial_delay = Duration::from_secs(3600);
768 schedules
769 .schedule(config, ScheduledExecutionPolicy::default(), || async {})
770 .unwrap();
771 schedules
772 .schedule(
773 ScheduledTaskConfig::fixed_delay("unselected-job", Duration::from_secs(3600)),
774 ScheduledExecutionPolicy::default(),
775 || async {},
776 )
777 .unwrap();
778 let actor = MetadataIoConfig::default().into_plan().unwrap().start(&parent).unwrap();
779 sources.set_metadata(actor.observer());
780 let deadline = MetadataDeadline::after(Duration::from_secs(2));
781 let receipt = match actor
782 .submit(
783 MetadataWriteRequest::new("private-resource", 1, target.clone(), b"payload".to_vec()),
784 deadline,
785 )
786 .unwrap()
787 {
788 MetadataIoAdmissionOutcome::Accepted(receipt) => receipt,
789 MetadataIoAdmissionOutcome::TargetConflict(_) => panic!("unexpected target conflict"),
790 };
791 receipt.wait_until(deadline).await.unwrap();
792 assert_eq!(fs::read(&target).unwrap(), b"payload");
793 let mut retained = ShutdownReport::new("private-owner", Duration::from_millis(42));
794 retained.cancelled = 3;
795 retained
796 .children
797 .push(ShutdownReport::new("private-child", Duration::ZERO));
798 sources.retain_shutdown(&retained);
799 let config = RuntimeDiagnosticsEndpointConfig::try_new(
800 "127.0.0.1:0".parse().unwrap(),
801 token_file.clone(),
802 Duration::from_secs(1),
803 false,
804 )
805 .unwrap();
806 let service = RuntimeDiagnosticsService::new(
807 &parent,
808 RuntimeComponent::Broker,
809 crate::TelemetryHandle::noop(),
810 std::sync::Arc::new(sources.clone()),
811 );
812 let endpoint = service
813 .start(RuntimeDiagnosticsMode::EndpointOnly(config))
814 .await
815 .unwrap()
816 .endpoint
817 .unwrap();
818 let response = request_path(
819 endpoint.local_addr(),
820 RUNTIME_DIAGNOSTICS_V2_PATH,
821 "source-token",
822 Some(RUNTIME_DIAGNOSTICS_SCOPE),
823 )
824 .await;
825 assert!(response.starts_with("HTTP/1.1 200 OK"), "{response}");
826 let body: serde_json::Value = serde_json::from_str(response.split("\r\n\r\n").nth(1).unwrap()).unwrap();
827 let view = &body["data"];
828 assert_eq!(view["schedule"]["tasks_scanned"], 1, "{body}");
829 assert_eq!(view["metadata"]["resources_scanned"], 1, "{body}");
830 assert_eq!(view["shutdown"]["cancelled"], 3, "{body}");
831 for private in [
832 "source-token",
833 "private-resource",
834 "selected-job",
835 "private-owner",
836 "payload",
837 ] {
838 assert!(!response.contains(private), "{private}: {response}");
839 }
840 let denied = request_path(
841 endpoint.local_addr(),
842 RUNTIME_METADATA_TARGETS_PATH,
843 "wrong-token",
844 Some(RUNTIME_DIAGNOSTICS_SCOPE),
845 )
846 .await;
847 assert!(denied.starts_with("HTTP/1.1 401"));
848 fs::write(&token_file, "rotated-token").unwrap();
849 let denied = request_path(
850 endpoint.local_addr(),
851 RUNTIME_METADATA_TARGETS_PATH,
852 "source-token",
853 Some(RUNTIME_DIAGNOSTICS_SCOPE),
854 )
855 .await;
856 assert!(denied.starts_with("HTTP/1.1 401"));
857 let denied = request_path(
858 endpoint.local_addr(),
859 RUNTIME_METADATA_TARGETS_PATH,
860 "rotated-token",
861 None,
862 )
863 .await;
864 assert!(denied.starts_with("HTTP/1.1 403"));
865 let occupancy = request_path(
866 endpoint.local_addr(),
867 RUNTIME_METADATA_TARGETS_PATH,
868 "rotated-token",
869 Some(RUNTIME_DIAGNOSTICS_SCOPE),
870 )
871 .await;
872 assert!(occupancy.starts_with("HTTP/1.1 200"));
873 let body: serde_json::Value = serde_json::from_str(occupancy.split("\r\n\r\n").nth(1).unwrap()).unwrap();
874 assert_eq!(body["schema_version"], "rocketmq.runtime-metadata-targets.v1");
875 assert_eq!(body["scope"], "process_shared");
876 assert_eq!(body["data"]["retained_targets"], 1);
877 assert_eq!(body["data"]["idle_histories"], 1);
878 assert_eq!(body["data"]["live_owners"], 0);
879 assert_eq!(body["data"]["fenced_targets"], 0);
880 assert!(!occupancy.contains("private-resource"));
881 assert!(!occupancy.contains(target.to_str().unwrap()));
882 assert!(service
883 .shutdown_until(ShutdownDeadline::after(Duration::from_secs(2)))
884 .await
885 .is_healthy());
886 assert!(!actor.shutdown_until(deadline).await.timed_out);
887 drop(actor);
888 drop(schedules);
889 assert!(context.shutdown_tasks(Duration::from_secs(2)).await.is_healthy());
890 let retained = sources.snapshot();
891 assert!(retained.metadata.is_none());
892 assert!(retained.schedule.is_empty());
893 let summary = retained.shutdown.unwrap();
894 assert_eq!(summary.cancelled, 3);
895 assert!(summary.children.is_empty());
896 assert!(summary.name.is_empty());
897 fs::remove_file(token_file).unwrap();
898 fs::remove_file(target).unwrap();
899 }
900
901 #[cfg(feature = "prometheus")]
902 #[tokio::test]
903 async fn four_modes_with_real_metrics_export_without_duplicate_samplers() {
904 let config = crate::ObservabilityConfig {
905 enabled: true,
906 metrics: crate::config::MetricsConfig {
907 enabled: true,
908 ..crate::config::MetricsConfig::default()
909 },
910 ..crate::ObservabilityConfig::default()
911 };
912 let (provider, registry) = crate::exporter::prometheus::init_prometheus_metrics(&config)
913 .unwrap()
914 .into_parts();
915 let handle = crate::TelemetryHandle::active(&config, Some(&provider));
916 exercise_mode_matrix(handle, true).await;
917 assert!(registry
918 .gather()
919 .iter()
920 .any(|metric| metric.name() == crate::semantic::metrics::RUNTIME_TASKS));
921 provider.shutdown().unwrap();
922 }
923
924 #[test]
925 fn endpoint_config_rejects_implicit_non_loopback_plaintext() {
926 let token_file = test_token_path();
927 assert!(RuntimeDiagnosticsEndpointConfig::try_new(
928 "0.0.0.0:8087".parse().expect("address"),
929 token_file,
930 Duration::from_secs(10),
931 false,
932 )
933 .is_err());
934 }
935
936 #[tokio::test]
937 async fn endpoint_requires_scope_and_observes_token_rotation() {
938 let token_file = test_token_path();
939 fs::write(&token_file, "first-token").expect("write token fixture");
940 let context = RuntimeContext::from_current("runtime-diagnostics-endpoint-test");
941 let service_context = context.service_context("runtime-diagnostics-endpoint-test");
942 let config = RuntimeDiagnosticsEndpointConfig::try_new(
943 "127.0.0.1:0".parse().expect("address"),
944 token_file.clone(),
945 Duration::from_secs(1),
946 false,
947 )
948 .expect("config");
949 let endpoint = start_runtime_diagnostics_endpoint(&service_context, RuntimeComponent::Broker, config)
950 .await
951 .expect("endpoint");
952
953 let missing_scope = request(endpoint.local_addr(), "first-token", None).await;
954 assert!(missing_scope.starts_with("HTTP/1.1 403 Forbidden"));
955
956 let accepted = request(endpoint.local_addr(), "first-token", Some(RUNTIME_DIAGNOSTICS_SCOPE)).await;
957 assert!(accepted.starts_with("HTTP/1.1 200 OK"), "{accepted}");
958 assert!(accepted.contains(RUNTIME_DIAGNOSTICS_ENDPOINT_SCHEMA));
959 assert!(accepted.contains(r#""component":"broker""#));
960 assert!(!accepted.contains("first-token"));
961 assert!(!accepted.contains("runtime-diagnostics.endpoint"));
962
963 fs::write(&token_file, "second-token").expect("rotate token fixture");
964 let rejected = request(endpoint.local_addr(), "first-token", Some(RUNTIME_DIAGNOSTICS_SCOPE)).await;
965 assert!(rejected.starts_with("HTTP/1.1 401 Unauthorized"));
966 let rotated = request(endpoint.local_addr(), "second-token", Some(RUNTIME_DIAGNOSTICS_SCOPE)).await;
967 assert!(rotated.starts_with("HTTP/1.1 200 OK"));
968
969 let report = context.shutdown_tasks(Duration::from_secs(2)).await;
970 assert!(report.is_healthy(), "{}", report.to_json());
971 fs::remove_file(token_file).expect("remove token fixture");
972 }
973
974 fn test_token_path() -> PathBuf {
975 let sequence = NEXT_TEST_FILE.fetch_add(1, Ordering::Relaxed);
976 std::env::temp_dir().join(format!(
977 "rocketmq-runtime-diagnostics-{}-{sequence}.token",
978 std::process::id()
979 ))
980 }
981
982 async fn request(addr: SocketAddr, token: &str, scope: Option<&str>) -> String {
983 request_path(addr, RUNTIME_DIAGNOSTICS_PATH, token, scope).await
984 }
985
986 async fn request_path(addr: SocketAddr, path: &str, token: &str, scope: Option<&str>) -> String {
987 let mut stream = TcpStream::connect(addr).await.expect("connect");
988 let scope = scope
989 .map(|scope| format!("X-RocketMQ-SRE-Scope: {scope}\r\n"))
990 .unwrap_or_default();
991 let request = format!(
992 "GET {path} HTTP/1.1\r\nHost: localhost\r\nAuthorization: Bearer \
993 {token}\r\n{scope}Connection: close\r\n\r\n"
994 );
995 stream.write_all(request.as_bytes()).await.expect("write request");
996 let mut response = Vec::new();
997 stream.read_to_end(&mut response).await.expect("read response");
998 String::from_utf8(response).expect("UTF-8 response")
999 }
1000
1001 #[tokio::test]
1002 async fn scoped_view_path_serves_the_v2_schema_and_leaves_v1_unchanged() {
1003 let token_file = test_token_path();
1004 fs::write(&token_file, "scoped-token").expect("write token fixture");
1005 let context = RuntimeContext::from_current("runtime-diagnostics-scoped-view-test");
1006 let service_context = context.service_context("runtime-diagnostics-scoped-view-test");
1007 let config = RuntimeDiagnosticsEndpointConfig::try_new(
1008 "127.0.0.1:0".parse().expect("address"),
1009 token_file,
1010 Duration::from_secs(1),
1011 false,
1012 )
1013 .expect("config");
1014 let endpoint = start_runtime_diagnostics_endpoint(&service_context, RuntimeComponent::Broker, config)
1015 .await
1016 .expect("endpoint");
1017
1018 let scoped = request_path(
1019 endpoint.local_addr(),
1020 RUNTIME_DIAGNOSTICS_V2_PATH,
1021 "scoped-token",
1022 Some(RUNTIME_DIAGNOSTICS_SCOPE),
1023 )
1024 .await;
1025 assert!(scoped.starts_with("HTTP/1.1 200 OK"), "{scoped}");
1026 assert!(scoped.contains(RUNTIME_DIAGNOSTICS_ENDPOINT_SCHEMA_V2), "{scoped}");
1027 assert!(scoped.contains(r#""scope":"subtree""#), "{scoped}");
1028 assert!(scoped.contains(r#""scope":"process_shared""#), "{scoped}");
1029 assert!(scoped.contains(r#""component":"broker""#), "{scoped}");
1030 assert!(!scoped.contains("scoped-token"));
1031
1032 let versioned = request_path(
1033 endpoint.local_addr(),
1034 RUNTIME_DIAGNOSTICS_PATH,
1035 "scoped-token",
1036 Some(RUNTIME_DIAGNOSTICS_SCOPE),
1037 )
1038 .await;
1039 assert!(versioned.starts_with("HTTP/1.1 200 OK"), "{versioned}");
1040 assert!(versioned.contains(RUNTIME_DIAGNOSTICS_ENDPOINT_SCHEMA), "{versioned}");
1041 assert!(!versioned.contains("runtime-diagnostics.v2"));
1042
1043 let unknown = request_path(
1044 endpoint.local_addr(),
1045 "/internal/v3/runtime/diagnostics",
1046 "scoped-token",
1047 Some(RUNTIME_DIAGNOSTICS_SCOPE),
1048 )
1049 .await;
1050 assert!(unknown.starts_with("HTTP/1.1 404 Not Found"), "{unknown}");
1051 }
1052}