1use std::ffi::OsStr;
18use std::net::SocketAddr;
19#[cfg(feature = "otel-metrics")]
20use std::sync::atomic::AtomicBool;
21#[cfg(feature = "otel-metrics")]
22use std::sync::atomic::Ordering;
23#[cfg(feature = "otel-metrics")]
24use std::sync::Arc;
25
26use thiserror::Error;
27
28const MAX_STABLE_LABEL_LEN: usize = 63;
29const GIT_COMMIT_LEN: usize = 40;
30const DEFAULT_LOCAL_COMMIT: &str = "0000000000000000000000000000000000000000";
31const DEFAULT_LOCAL_NONCE: &str = "local";
32
33pub const RELEASE_COMMIT_ENV: &str = "ROCKETMQ_RELEASE_COMMIT";
35pub const RELEASE_NONCE_ENV: &str = "ROCKETMQ_RELEASE_NONCE";
37pub const METRICS_ENABLED_ENV: &str = "ROCKETMQ_METRICS_ENABLED";
39pub const METRICS_EXPORTER_ENV: &str = "ROCKETMQ_METRICS_EXPORTER";
41pub const METRICS_BIND_ADDR_ENV: &str = "ROCKETMQ_METRICS_BIND_ADDR";
43pub const METRICS_PATH_ENV: &str = "ROCKETMQ_METRICS_PATH";
45
46#[derive(Clone, Debug, PartialEq, Eq, Hash)]
53pub struct ValidatedReleaseIdentity {
54 service: String,
55 commit: String,
56 nonce: String,
57}
58
59impl ValidatedReleaseIdentity {
60 pub fn try_new(
71 service: impl Into<String>,
72 commit: impl Into<String>,
73 nonce: impl Into<String>,
74 ) -> Result<Self, ReleaseIdentityError> {
75 let service = service.into();
76 let commit = commit.into();
77 let nonce = nonce.into();
78
79 if !is_stable_label(&service) {
80 return Err(ReleaseIdentityError::InvalidService);
81 }
82 if !is_full_lowercase_commit(&commit) {
83 return Err(ReleaseIdentityError::InvalidCommit);
84 }
85 if !is_stable_label(&nonce) {
86 return Err(ReleaseIdentityError::InvalidNonce);
87 }
88
89 Ok(Self { service, commit, nonce })
90 }
91
92 pub fn service(&self) -> &str {
94 &self.service
95 }
96
97 pub fn commit(&self) -> &str {
99 &self.commit
100 }
101
102 pub fn nonce(&self) -> &str {
104 &self.nonce
105 }
106
107 #[cfg(feature = "otel-metrics")]
108 fn attributes(&self) -> [opentelemetry::KeyValue; 3] {
109 [
110 opentelemetry::KeyValue::new(crate::semantic::labels::SERVICE, self.service.clone()),
111 opentelemetry::KeyValue::new(crate::semantic::labels::RELEASE_COMMIT, self.commit.clone()),
112 opentelemetry::KeyValue::new(crate::semantic::labels::RELEASE_NONCE, self.nonce.clone()),
113 ]
114 }
115}
116
117#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
119pub enum ReleaseIdentityError {
120 #[error("service must be 1..=63 lowercase ASCII letters, digits, or interior hyphens")]
122 InvalidService,
123 #[error("commit must be exactly 40 lowercase hexadecimal characters")]
125 InvalidCommit,
126 #[error("nonce must be 1..=63 lowercase ASCII letters, digits, or interior hyphens")]
128 InvalidNonce,
129}
130
131#[derive(Clone, Debug, PartialEq, Eq)]
137pub struct ProcessTelemetryConfig {
138 release_identity: ValidatedReleaseIdentity,
139 metrics_enabled: bool,
140 metrics_exporter: crate::MetricsExporter,
141 prometheus_bind_addr: SocketAddr,
142 prometheus_host: String,
143 prometheus_port: u16,
144 prometheus_path: String,
145}
146
147impl ProcessTelemetryConfig {
148 #[allow(
165 clippy::too_many_arguments,
166 reason = "mirrors the seven explicit process-root inputs"
167 )]
168 pub fn try_from_values(
169 service: impl Into<String>,
170 commit: Option<&str>,
171 nonce: Option<&str>,
172 metrics_enabled: Option<&str>,
173 exporter: Option<&str>,
174 bind_addr: Option<&str>,
175 path: Option<&str>,
176 ) -> Result<Self, ProcessTelemetryConfigError> {
177 Self::try_from_observability_and_values(
178 service,
179 &crate::ObservabilityConfig::default(),
180 commit.map(OsStr::new),
181 nonce.map(OsStr::new),
182 metrics_enabled.map(OsStr::new),
183 exporter.map(OsStr::new),
184 bind_addr.map(OsStr::new),
185 path.map(OsStr::new),
186 )
187 }
188
189 #[allow(
198 clippy::too_many_arguments,
199 reason = "mirrors the seven explicit process-root inputs"
200 )]
201 pub fn try_from_observability_and_values(
202 service: impl Into<String>,
203 base: &crate::ObservabilityConfig,
204 commit: Option<&OsStr>,
205 nonce: Option<&OsStr>,
206 metrics_enabled: Option<&OsStr>,
207 exporter: Option<&OsStr>,
208 bind_addr: Option<&OsStr>,
209 path: Option<&OsStr>,
210 ) -> Result<Self, ProcessTelemetryConfigError> {
211 let commit = os_value(RELEASE_COMMIT_ENV, commit)?;
212 let nonce = os_value(RELEASE_NONCE_ENV, nonce)?;
213 let metrics_enabled_value = os_value(METRICS_ENABLED_ENV, metrics_enabled)?;
214 let exporter = os_value(METRICS_EXPORTER_ENV, exporter)?;
215 let bind_addr = os_value(METRICS_BIND_ADDR_ENV, bind_addr)?;
216 let path = os_value(METRICS_PATH_ENV, path)?;
217
218 let commit = resolve_release_commit(commit, option_env!("ROCKETMQ_BUILD_COMMIT"))?;
219 let release_identity =
220 ValidatedReleaseIdentity::try_new(service, commit, nonce.unwrap_or(DEFAULT_LOCAL_NONCE))?;
221 let metrics_enabled = match metrics_enabled_value {
222 Some(value) => parse_metrics_enabled(Some(value))?,
223 None => base.metrics.enabled,
224 };
225 let mut metrics_exporter = match exporter {
226 Some(value) => parse_metrics_exporter(Some(value))?,
227 None => base.metrics.exporter,
228 };
229 if metrics_enabled_value == Some("false") {
230 metrics_exporter = crate::MetricsExporter::Disable;
231 }
232 validate_metrics_selection(metrics_enabled, metrics_exporter)?;
233
234 let bind_addr = match bind_addr {
235 Some(value) => value
236 .parse::<SocketAddr>()
237 .map_err(|_| ProcessTelemetryConfigError::InvalidPrometheusBindAddress)?,
238 None => SocketAddr::new(
239 crate::resolver::parse_prometheus_host(&base.prometheus.host)
240 .ok_or(ProcessTelemetryConfigError::InvalidPrometheusBindAddress)?,
241 base.prometheus.port,
242 ),
243 };
244 if bind_addr.port() == 0 {
245 return Err(ProcessTelemetryConfigError::InvalidPrometheusBindAddress);
246 }
247
248 let prometheus_path = path.unwrap_or(&base.prometheus.path);
249 if !crate::resolver::is_canonical_prometheus_path(prometheus_path) {
250 return Err(ProcessTelemetryConfigError::InvalidPrometheusPath);
251 }
252
253 Ok(Self {
254 release_identity,
255 metrics_enabled,
256 metrics_exporter,
257 prometheus_bind_addr: bind_addr,
258 prometheus_host: match bind_addr.ip() {
259 std::net::IpAddr::V4(address) => address.to_string(),
260 std::net::IpAddr::V6(address) => format!("[{address}]"),
261 },
262 prometheus_port: bind_addr.port(),
263 prometheus_path: prometheus_path.to_owned(),
264 })
265 }
266
267 pub fn from_process_env(service: impl Into<String>) -> Result<Self, ProcessTelemetryConfigError> {
274 let commit = read_process_env(RELEASE_COMMIT_ENV)?;
275 let nonce = read_process_env(RELEASE_NONCE_ENV)?;
276 let metrics_enabled = read_process_env(METRICS_ENABLED_ENV)?;
277 let exporter = read_process_env(METRICS_EXPORTER_ENV)?;
278 let bind_addr = read_process_env(METRICS_BIND_ADDR_ENV)?;
279 let path = read_process_env(METRICS_PATH_ENV)?;
280
281 Self::try_from_values(
282 service,
283 commit.as_deref(),
284 nonce.as_deref(),
285 metrics_enabled.as_deref(),
286 exporter.as_deref(),
287 bind_addr.as_deref(),
288 path.as_deref(),
289 )
290 }
291
292 pub fn release_identity(&self) -> &ValidatedReleaseIdentity {
294 &self.release_identity
295 }
296
297 pub const fn metrics_enabled(&self) -> bool {
299 self.metrics_enabled
300 }
301
302 pub const fn metrics_exporter(&self) -> crate::MetricsExporter {
304 self.metrics_exporter
305 }
306
307 pub fn prometheus_listener_addr(&self) -> Option<SocketAddr> {
309 (self.metrics_enabled && self.metrics_exporter == crate::MetricsExporter::Prometheus)
310 .then_some(self.prometheus_bind_addr)
311 }
312
313 pub fn prometheus_host(&self) -> &str {
315 &self.prometheus_host
316 }
317
318 pub const fn prometheus_port(&self) -> u16 {
320 self.prometheus_port
321 }
322
323 pub fn prometheus_path(&self) -> &str {
325 &self.prometheus_path
326 }
327
328 pub fn apply_to(&self, config: &mut crate::ObservabilityConfig) {
333 config.service_name.clone_from(&self.release_identity.service);
334 config.metrics.enabled = self.metrics_enabled;
335 config.metrics.exporter = self.metrics_exporter;
336 config.prometheus.host.clone_from(&self.prometheus_host);
337 config.prometheus.port = self.prometheus_port;
338 config.prometheus.path.clone_from(&self.prometheus_path);
339 config.enabled = config.metrics.enabled || config.traces.enabled || config.logs.enabled;
340 }
341}
342
343fn os_value<'a>(name: &'static str, value: Option<&'a OsStr>) -> Result<Option<&'a str>, ProcessTelemetryConfigError> {
344 value
345 .map(|value| {
346 value
347 .to_str()
348 .ok_or(ProcessTelemetryConfigError::NonUnicodeEnvironment(name))
349 })
350 .transpose()
351}
352
353#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
355pub enum ProcessTelemetryConfigError {
356 #[error(transparent)]
358 ReleaseIdentity(#[from] ReleaseIdentityError),
359 #[error("ROCKETMQ_RELEASE_COMMIT must not be the all-zero null commit")]
361 NullReleaseCommit,
362 #[error("ROCKETMQ_RELEASE_COMMIT must match the embedded ROCKETMQ_BUILD_COMMIT")]
364 ReleaseCommitMismatch,
365 #[error("ROCKETMQ_METRICS_ENABLED must be either true or false")]
367 InvalidMetricsEnabled,
368 #[error("ROCKETMQ_METRICS_EXPORTER must be disable, otlp_grpc, prometheus, or log")]
370 InvalidMetricsExporter,
371 #[error("enabled metrics require a non-disabled exporter and disabled metrics require the disable exporter")]
373 InconsistentMetricsSelection,
374 #[error("ROCKETMQ_METRICS_BIND_ADDR must be an IP address and nonzero port")]
376 InvalidPrometheusBindAddress,
377 #[error("ROCKETMQ_METRICS_PATH must be a canonical absolute path of at most 128 ASCII characters")]
379 InvalidPrometheusPath,
380 #[error("{0} must contain valid Unicode")]
382 NonUnicodeEnvironment(&'static str),
383}
384
385fn parse_metrics_enabled(value: Option<&str>) -> Result<bool, ProcessTelemetryConfigError> {
386 match value {
387 None | Some("false") => Ok(false),
388 Some("true") => Ok(true),
389 Some(_) => Err(ProcessTelemetryConfigError::InvalidMetricsEnabled),
390 }
391}
392
393fn resolve_release_commit<'a>(
394 runtime_commit: Option<&'a str>,
395 build_commit: Option<&'a str>,
396) -> Result<&'a str, ProcessTelemetryConfigError> {
397 if let Some(runtime_commit) = runtime_commit {
398 if runtime_commit == DEFAULT_LOCAL_COMMIT {
399 return Err(ProcessTelemetryConfigError::NullReleaseCommit);
400 }
401 if !is_full_lowercase_commit(runtime_commit) {
402 return Err(ReleaseIdentityError::InvalidCommit.into());
403 }
404 }
405
406 let embedded_commit = build_commit.filter(|commit| is_real_commit(commit));
407 match (runtime_commit, embedded_commit) {
408 (Some(runtime_commit), Some(embedded_commit)) if runtime_commit != embedded_commit => {
409 Err(ProcessTelemetryConfigError::ReleaseCommitMismatch)
410 }
411 (Some(runtime_commit), _) => Ok(runtime_commit),
412 (None, Some(embedded_commit)) => Ok(embedded_commit),
413 (None, None) => Ok(DEFAULT_LOCAL_COMMIT),
414 }
415}
416
417fn parse_metrics_exporter(value: Option<&str>) -> Result<crate::MetricsExporter, ProcessTelemetryConfigError> {
418 match value {
419 None | Some("disable") => Ok(crate::MetricsExporter::Disable),
420 Some("otlp_grpc") => Ok(crate::MetricsExporter::OtlpGrpc),
421 Some("prometheus") => Ok(crate::MetricsExporter::Prometheus),
422 Some("log") => Ok(crate::MetricsExporter::Log),
423 Some(_) => Err(ProcessTelemetryConfigError::InvalidMetricsExporter),
424 }
425}
426
427fn validate_metrics_selection(
428 enabled: bool,
429 exporter: crate::MetricsExporter,
430) -> Result<(), ProcessTelemetryConfigError> {
431 if enabled == exporter.is_enabled() {
432 Ok(())
433 } else {
434 Err(ProcessTelemetryConfigError::InconsistentMetricsSelection)
435 }
436}
437
438fn read_process_env(name: &'static str) -> Result<Option<String>, ProcessTelemetryConfigError> {
439 crate::resolver::read_telemetry_environment_value(name)
440 .map(|value| {
441 value
442 .into_string()
443 .map_err(|_| ProcessTelemetryConfigError::NonUnicodeEnvironment(name))
444 })
445 .transpose()
446}
447
448fn is_full_lowercase_commit(value: &str) -> bool {
449 value.len() == GIT_COMMIT_LEN
450 && value
451 .as_bytes()
452 .iter()
453 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
454}
455
456fn is_real_commit(value: &str) -> bool {
457 value != DEFAULT_LOCAL_COMMIT && is_full_lowercase_commit(value)
458}
459
460fn is_stable_label(value: &str) -> bool {
461 if value.is_empty() || value.len() > MAX_STABLE_LABEL_LEN {
462 return false;
463 }
464
465 let bytes = value.as_bytes();
466 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
467 && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
468 && bytes
469 .iter()
470 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
471}
472
473#[cfg(feature = "otel-metrics")]
475#[derive(Clone, Copy, Debug, PartialEq, Eq)]
476pub enum ReleaseIdentityRegistrationStatus {
477 Registered,
479 AlreadyRegistered,
481}
482
483#[cfg(feature = "otel-metrics")]
488#[derive(Clone, Debug)]
489pub struct ReleaseIdentityRegistration {
490 inner: Arc<ReleaseIdentityRegistrationInner>,
491}
492
493#[cfg(feature = "otel-metrics")]
494#[derive(Debug)]
495struct ReleaseIdentityRegistrationInner {
496 identity: ValidatedReleaseIdentity,
497 registered: AtomicBool,
498 instrument: parking_lot::Mutex<Option<opentelemetry::metrics::Gauge<u64>>>,
499}
500
501#[cfg(feature = "otel-metrics")]
502impl ReleaseIdentityRegistration {
503 pub fn new(identity: ValidatedReleaseIdentity) -> Self {
505 Self {
506 inner: Arc::new(ReleaseIdentityRegistrationInner {
507 identity,
508 registered: AtomicBool::new(false),
509 instrument: parking_lot::Mutex::new(None),
510 }),
511 }
512 }
513
514 pub fn identity(&self) -> &ValidatedReleaseIdentity {
516 &self.inner.identity
517 }
518
519 pub fn is_registered(&self) -> bool {
521 self.inner.registered.load(Ordering::Acquire)
522 }
523
524 pub(crate) fn register(&self, meter: &opentelemetry::metrics::Meter) -> ReleaseIdentityRegistrationStatus {
529 if self
530 .inner
531 .registered
532 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
533 .is_err()
534 {
535 return ReleaseIdentityRegistrationStatus::AlreadyRegistered;
536 }
537
538 let gauge = meter
539 .u64_gauge(crate::semantic::metrics::RELEASE_INFO)
540 .with_description("RocketMQ service build and rollout identity")
541 .with_unit("1")
542 .build();
543 gauge.record(1, &self.inner.identity.attributes());
544 *self.inner.instrument.lock() = Some(gauge);
545
546 ReleaseIdentityRegistrationStatus::Registered
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
555 fn stable_label_accepts_only_canonical_bounded_values() {
556 assert!(is_stable_label("a"));
557 assert!(is_stable_label("rocketmq-broker-01"));
558 assert!(is_stable_label(&"a".repeat(MAX_STABLE_LABEL_LEN)));
559
560 assert!(!is_stable_label(""));
561 assert!(!is_stable_label("-broker"));
562 assert!(!is_stable_label("broker-"));
563 assert!(!is_stable_label("Rocketmq-broker"));
564 assert!(!is_stable_label("rocketmq_broker"));
565 assert!(!is_stable_label(&"a".repeat(MAX_STABLE_LABEL_LEN + 1)));
566 }
567
568 #[test]
569 fn commit_requires_full_lowercase_hex() {
570 assert!(is_full_lowercase_commit("0123456789abcdef0123456789abcdef01234567"));
571 assert!(!is_full_lowercase_commit("0123456789ABCDEF0123456789ABCDEF01234567"));
572 assert!(!is_full_lowercase_commit("g123456789abcdef0123456789abcdef01234567"));
573 assert!(!is_full_lowercase_commit("0123456789abcdef0123456789abcdef0123456"));
574 }
575
576 #[test]
577 fn local_host_build_without_real_embedded_commit_keeps_local_default() {
578 assert_eq!(resolve_release_commit(None, None), Ok(DEFAULT_LOCAL_COMMIT));
579 assert_eq!(
580 resolve_release_commit(None, Some(DEFAULT_LOCAL_COMMIT)),
581 Ok(DEFAULT_LOCAL_COMMIT)
582 );
583 assert_eq!(
584 resolve_release_commit(None, Some("not-a-build-commit")),
585 Ok(DEFAULT_LOCAL_COMMIT)
586 );
587 }
588
589 #[test]
590 fn real_embedded_commit_binds_runtime_identity() {
591 const BUILD_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567";
592 const OTHER_COMMIT: &str = "89abcdef0123456789abcdef0123456789abcdef";
593
594 assert_eq!(resolve_release_commit(None, Some(BUILD_COMMIT)), Ok(BUILD_COMMIT));
595 assert_eq!(
596 resolve_release_commit(Some(BUILD_COMMIT), Some(BUILD_COMMIT)),
597 Ok(BUILD_COMMIT)
598 );
599 assert_eq!(
600 resolve_release_commit(Some(OTHER_COMMIT), Some(BUILD_COMMIT)),
601 Err(ProcessTelemetryConfigError::ReleaseCommitMismatch)
602 );
603 }
604
605 #[test]
606 fn explicit_null_runtime_commit_fails_closed() {
607 const BUILD_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567";
608
609 assert_eq!(
610 resolve_release_commit(Some(DEFAULT_LOCAL_COMMIT), None),
611 Err(ProcessTelemetryConfigError::NullReleaseCommit)
612 );
613 assert_eq!(
614 resolve_release_commit(Some(DEFAULT_LOCAL_COMMIT), Some(BUILD_COMMIT)),
615 Err(ProcessTelemetryConfigError::NullReleaseCommit)
616 );
617 }
618}