Skip to main content

torrust_tracker_deployer_lib/domain/prometheus/
config.rs

1//! Prometheus configuration domain model
2//!
3//! Defines the configuration for Prometheus metrics scraping.
4
5use std::num::NonZeroU32;
6
7use serde::{Deserialize, Serialize};
8
9use crate::domain::topology::{
10    EnabledServices, Network, NetworkDerivation, PortBinding, PortDerivation, Service,
11};
12use crate::shared::docker_image::DockerImage;
13
14/// Default scrape interval in seconds
15///
16/// This is the recommended interval for most use cases, balancing
17/// monitoring frequency with resource usage.
18const DEFAULT_SCRAPE_INTERVAL_SECS: u32 = 15;
19
20/// Docker image repository for the Prometheus container
21pub const PROMETHEUS_DOCKER_IMAGE_REPOSITORY: &str = "prom/prometheus";
22
23/// Docker image tag for the Prometheus container
24pub const PROMETHEUS_DOCKER_IMAGE_TAG: &str = "v3.11.2";
25
26/// Prometheus metrics collection configuration
27///
28/// Configures how Prometheus scrapes metrics from the tracker.
29/// When present in environment configuration, Prometheus service is enabled.
30/// When absent, Prometheus service is disabled.
31///
32/// # Example
33///
34/// ```rust
35/// use std::num::NonZeroU32;
36/// use torrust_tracker_deployer_lib::domain::prometheus::PrometheusConfig;
37///
38/// let interval = NonZeroU32::new(15).expect("15 is non-zero");
39/// let config = PrometheusConfig::new(interval);
40/// ```
41///
42/// # Default Behavior
43///
44/// - Default scrape interval: 15 seconds
45/// - Minimum: 1 second (to avoid zero or negative values)
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct PrometheusConfig {
48    /// Scrape interval in seconds
49    ///
50    /// Guaranteed to be non-zero at the type level.
51    /// The Prometheus template will append 's' suffix.
52    /// Examples: 15 → "15s", 30 → "30s", 60 → "60s" (1 minute)
53    scrape_interval_in_secs: NonZeroU32,
54}
55
56impl PrometheusConfig {
57    /// Creates a new Prometheus configuration with the specified scrape interval
58    ///
59    /// # Arguments
60    ///
61    /// * `scrape_interval_in_secs` - Non-zero interval in seconds
62    ///
63    /// # Examples
64    ///
65    /// ```rust
66    /// use std::num::NonZeroU32;
67    /// use torrust_tracker_deployer_lib::domain::prometheus::PrometheusConfig;
68    ///
69    /// let interval = NonZeroU32::new(30).expect("30 is non-zero");
70    /// let config = PrometheusConfig::new(interval);
71    /// assert_eq!(config.scrape_interval_in_secs(), 30);
72    /// ```
73    #[must_use]
74    pub const fn new(scrape_interval_in_secs: NonZeroU32) -> Self {
75        Self {
76            scrape_interval_in_secs,
77        }
78    }
79
80    /// Returns the scrape interval in seconds
81    ///
82    /// The value is guaranteed to be non-zero.
83    #[must_use]
84    pub fn scrape_interval_in_secs(&self) -> u32 {
85        self.scrape_interval_in_secs.get()
86    }
87
88    /// Returns the Docker image used for the Prometheus service.
89    ///
90    /// This is a pinned constant — not user-configurable.
91    ///
92    /// # Examples
93    ///
94    /// ```rust
95    /// use torrust_tracker_deployer_lib::domain::prometheus::PrometheusConfig;
96    ///
97    /// let image = PrometheusConfig::docker_image();
98    /// assert_eq!(image.full_reference(), "prom/prometheus:v3.11.2");
99    /// ```
100    #[must_use]
101    pub fn docker_image() -> DockerImage {
102        DockerImage::new(
103            PROMETHEUS_DOCKER_IMAGE_REPOSITORY,
104            PROMETHEUS_DOCKER_IMAGE_TAG,
105        )
106    }
107}
108
109impl Default for PrometheusConfig {
110    fn default() -> Self {
111        Self {
112            // SAFETY: DEFAULT_SCRAPE_INTERVAL_SECS is non-zero
113            scrape_interval_in_secs: NonZeroU32::new(DEFAULT_SCRAPE_INTERVAL_SECS)
114                .expect("DEFAULT_SCRAPE_INTERVAL_SECS is non-zero"),
115        }
116    }
117}
118
119impl PortDerivation for PrometheusConfig {
120    /// Derives port bindings for Prometheus
121    ///
122    /// Implements PORT-10: Prometheus 9090 on localhost only
123    ///
124    /// Prometheus is bound to localhost to prevent external access.
125    /// Grafana accesses it via Docker network (`http://prometheus:9090`).
126    fn derive_ports(&self) -> Vec<PortBinding> {
127        vec![PortBinding::localhost_tcp(
128            9090,
129            "Prometheus metrics (localhost only)",
130        )]
131    }
132}
133
134impl NetworkDerivation for PrometheusConfig {
135    /// Derives network assignments for the Prometheus service
136    ///
137    /// Implements NET-04 and NET-05:
138    /// - NET-04: Metrics network always (to scrape tracker)
139    /// - NET-05: Visualization network if Grafana enabled
140    fn derive_networks(&self, enabled_services: &EnabledServices) -> Vec<Network> {
141        let mut networks = vec![Network::Metrics];
142
143        // NET-05: Visualization network if Grafana enabled
144        if enabled_services.has(Service::Grafana) {
145            networks.push(Network::Visualization);
146        }
147
148        networks
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use std::num::NonZeroU32;
155
156    use super::*;
157
158    #[test]
159    fn it_should_create_prometheus_config_with_default_values() {
160        let config = PrometheusConfig::default();
161        assert_eq!(
162            config.scrape_interval_in_secs(),
163            DEFAULT_SCRAPE_INTERVAL_SECS
164        );
165    }
166
167    #[test]
168    fn it_should_create_prometheus_config_with_custom_interval() {
169        let interval = NonZeroU32::new(30).expect("30 is non-zero");
170        let config = PrometheusConfig::new(interval);
171        assert_eq!(config.scrape_interval_in_secs(), 30);
172    }
173
174    #[test]
175    fn it_should_serialize_to_json() {
176        let interval = NonZeroU32::new(20).expect("20 is non-zero");
177        let config = PrometheusConfig::new(interval);
178
179        let json = serde_json::to_value(&config).unwrap();
180        assert_eq!(json["scrape_interval_in_secs"], 20);
181    }
182
183    #[test]
184    fn it_should_deserialize_from_json() {
185        let json = serde_json::json!({
186            "scrape_interval_in_secs": 25
187        });
188
189        let config: PrometheusConfig = serde_json::from_value(json).unwrap();
190        assert_eq!(config.scrape_interval_in_secs(), 25);
191    }
192
193    #[test]
194    fn it_should_support_different_scrape_intervals() {
195        let fast = PrometheusConfig::new(NonZeroU32::new(5).expect("5 is non-zero"));
196        let medium = PrometheusConfig::new(NonZeroU32::new(15).expect("15 is non-zero"));
197        let slow = PrometheusConfig::new(NonZeroU32::new(300).expect("300 is non-zero"));
198
199        assert_eq!(fast.scrape_interval_in_secs(), 5);
200        assert_eq!(medium.scrape_interval_in_secs(), 15);
201        assert_eq!(slow.scrape_interval_in_secs(), 300);
202    }
203
204    #[test]
205    fn it_should_reject_zero_interval_at_type_level() {
206        // Cannot construct NonZeroU32 with 0
207        let result = NonZeroU32::new(0);
208        assert!(result.is_none());
209    }
210
211    // =========================================================================
212    // Port derivation tests (PORT-10)
213    // =========================================================================
214
215    mod port_derivation {
216        use std::net::{IpAddr, Ipv4Addr};
217
218        use super::*;
219        use crate::domain::tracker::Protocol;
220
221        #[test]
222        fn it_should_derive_prometheus_port_on_localhost_only() {
223            // PORT-10: Prometheus 9090 on localhost only
224            let config = PrometheusConfig::default();
225
226            let ports = config.derive_ports();
227
228            assert_eq!(ports.len(), 1);
229            let port = &ports[0];
230            assert_eq!(port.host_port(), 9090);
231            assert_eq!(port.container_port(), 9090);
232            assert_eq!(port.protocol(), Protocol::Tcp);
233            assert_eq!(port.host_ip(), Some(IpAddr::V4(Ipv4Addr::LOCALHOST)));
234        }
235
236        #[test]
237        fn it_should_include_description_for_prometheus_port() {
238            let config = PrometheusConfig::default();
239
240            let ports = config.derive_ports();
241
242            assert_eq!(
243                ports[0].description(),
244                "Prometheus metrics (localhost only)"
245            );
246        }
247    }
248}