Skip to main content

rocketmq_observability/
runtime_diagnostics.rs

1// Copyright 2026 The RocketMQ Rust Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Authenticated, bounded transport for the sanitized runtime diagnostics view.
16//!
17//! This endpoint is deliberately separate from the anonymous lifecycle probe.
18//! It is disabled unless both a bind address and a mounted token-file reference
19//! are configured. Plain HTTP on a non-loopback listener additionally requires
20//! an explicit development-only opt-in.
21
22mod 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";
60/// Path of the explicitly scoped diagnostics view.
61///
62/// It shares the listener, token, and scope checks with the V1 path and returns
63/// the same process view. Caller-owned providers may supply scheduled work,
64/// metadata and retained shutdown results; compatibility wrappers leave these
65/// absent. V2 continues to omit empty schedule vectors.
66pub const RUNTIME_DIAGNOSTICS_V2_PATH: &str = "/internal/v2/runtime/diagnostics";
67pub const RUNTIME_DIAGNOSTICS_ENDPOINT_SCHEMA_V2: &str = "rocketmq.runtime-diagnostics-endpoint.v2";
68/// Protected, process-shared metadata registry occupancy without target names.
69pub 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/// Fail-closed configuration for the protected runtime diagnostics listener.
77#[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    /// Creates and validates an explicit endpoint configuration.
86    ///
87    /// Non-loopback plain HTTP is allowed only when
88    /// `allow_insecure_http` is true. This flag exists solely for bounded
89    /// development clusters where a NetworkPolicy protects the listener.
90    ///
91    /// # Errors
92    ///
93    /// Returns an error for a relative or non-normalized token path, a zero or
94    /// excessive sampling interval, or a non-loopback listener without the
95    /// explicit development opt-in.
96    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    /// Loads the optional endpoint configuration from process environment.
121    ///
122    /// Both the bind address and token-file reference must be present. Leaving
123    /// both absent disables the endpoint. A partial configuration fails closed.
124    ///
125    /// # Errors
126    ///
127    /// Returns a sanitized configuration error when the environment is
128    /// incomplete, malformed, or violates a listener security invariant.
129    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/// Bound address of the lifecycle-owned diagnostics listener.
162#[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
173/// Starts the optional protected endpoint and its owned metrics sampler.
174///
175/// The token is read through the runtime metadata-I/O lane for every request,
176/// so mounted-secret rotation takes effect without process restart.
177///
178/// # Errors
179///
180/// Returns a sanitized error when configuration, the initial token read, the
181/// listener bind, or lifecycle task registration fails.
182pub 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
194/// Starts the opt-in runtime diagnostics endpoint and records snapshots into
195/// the caller-owned telemetry pipeline.
196///
197/// # Errors
198///
199/// Returns a sanitized error when configuration, the initial token read, the
200/// listener bind, or lifecycle task registration fails.
201pub 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
221/// Starts a protected diagnostics listener using an explicit configuration.
222///
223/// # Errors
224///
225/// Returns a sanitized error when the initial token cannot be validated, the
226/// listener cannot bind, or lifecycle task registration fails.
227pub 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}