saluki_components/config/
cluster_agent.rs

1//! Cluster Agent configuration.
2
3use std::net::IpAddr;
4
5/// Cluster Agent forwarding configuration.
6///
7/// Every field arrives already resolved: configuration owns the defaults, trims the string values, and turns a blank
8/// value into either `None` or an empty name. What is left here is the endpoint decision, which needs the process
9/// environment and so cannot be made at the configuration boundary.
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub struct ClusterAgentConfiguration {
12    /// Whether forwarding to the Cluster Agent is turned on.
13    ///
14    /// Defaults to `false` in configuration. When this is `false`, no endpoint is resolved and nothing is forwarded,
15    /// whatever the other fields hold.
16    pub enabled: bool,
17
18    /// Configured Cluster Agent URL.
19    ///
20    /// Defaults to unset in configuration. A value is preferred over Kubernetes service discovery, and it is used only
21    /// if it resolves to an `https` URL with a host: a URL without a scheme is completed to `https://`, and anything
22    /// else yields no endpoint rather than falling back to service discovery.
23    pub url: Option<String>,
24
25    /// Bearer token presented on Cluster Agent requests.
26    ///
27    /// Defaults to unset in configuration, which leaves nothing to forward with: the Cluster Agent has no anonymous
28    /// access, so an unset token yields no endpoint.
29    pub auth_token: Option<String>,
30
31    /// Kubernetes service name whose injected environment variables locate the Cluster Agent.
32    ///
33    /// Configuration defaults this to `datadog-cluster-agent`. The name is upper-cased, its dashes become underscores,
34    /// and the `<NAME>_SERVICE_HOST` and `<NAME>_SERVICE_PORT` variables Kubernetes injects into the pod supply the
35    /// endpoint. An empty name turns the lookup off, which is how a deployment asks that `url` be the only way to
36    /// reach the Cluster Agent.
37    pub kubernetes_service_name: String,
38}
39
40impl ClusterAgentConfiguration {
41    /// Returns the Cluster Agent HTTPS endpoint and bearer token when forwarding can be configured.
42    ///
43    /// Returns `None` when forwarding is turned off, when no `https` endpoint resolves from either `url` or the
44    /// Kubernetes service environment, or when `auth_token` is unset.
45    pub fn endpoint_and_token(&self) -> Option<(String, String)> {
46        self.endpoint_and_token_with_env(|key| std::env::var(key).ok())
47    }
48
49    fn endpoint_and_token_with_env<F>(&self, env_lookup: F) -> Option<(String, String)>
50    where
51        F: Fn(&str) -> Option<String>,
52    {
53        if !self.enabled {
54            return None;
55        }
56
57        let endpoint = self.resolve_endpoint(env_lookup)?;
58
59        Some((endpoint, self.auth_token.clone()?))
60    }
61
62    fn resolve_endpoint<F>(&self, env_lookup: F) -> Option<String>
63    where
64        F: Fn(&str) -> Option<String>,
65    {
66        if let Some(url) = self.url.as_deref() {
67            return normalize_cluster_agent_url(url);
68        }
69
70        if self.kubernetes_service_name.is_empty() {
71            return None;
72        }
73
74        resolve_kubernetes_service_endpoint(&self.kubernetes_service_name, env_lookup)
75    }
76}
77
78fn normalize_cluster_agent_url(url: &str) -> Option<String> {
79    let normalized = if url.contains("://") {
80        url.to_string()
81    } else {
82        format!("https://{url}")
83    };
84
85    let parsed = url::Url::parse(&normalized).ok()?;
86    if parsed.scheme() == "https" && parsed.host_str().is_some() {
87        Some(normalized)
88    } else {
89        None
90    }
91}
92
93fn resolve_kubernetes_service_endpoint<F>(service_name: &str, env_lookup: F) -> Option<String>
94where
95    F: Fn(&str) -> Option<String>,
96{
97    let env_prefix = service_name.to_uppercase().replace('-', "_");
98    let host_env = format!("{env_prefix}_SERVICE_HOST");
99    let port_env = format!("{env_prefix}_SERVICE_PORT");
100
101    let host = env_lookup(&host_env)?.trim().to_string();
102    let port = env_lookup(&port_env)?.trim().to_string();
103    if host.is_empty() || port.is_empty() {
104        return None;
105    }
106
107    normalize_cluster_agent_url(&join_host_port(&host, &port))
108}
109
110fn join_host_port(host: &str, port: &str) -> String {
111    match host.parse::<IpAddr>() {
112        Ok(IpAddr::V6(_)) => format!("[{host}]:{port}"),
113        _ => format!("{host}:{port}"),
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    /// The configuration default, owned by `cluster_agent_settings_default_to_schema_values_when_unset` upstream.
122    const DEFAULT_SERVICE_NAME: &str = "datadog-cluster-agent";
123
124    fn enabled_config() -> ClusterAgentConfiguration {
125        ClusterAgentConfiguration {
126            enabled: true,
127            url: None,
128            auth_token: Some("secret-token".to_string()),
129            kubernetes_service_name: DEFAULT_SERVICE_NAME.to_string(),
130        }
131    }
132
133    #[test]
134    fn endpoint_and_token_requires_enabled_cluster_agent() {
135        let config = ClusterAgentConfiguration {
136            enabled: false,
137            url: Some("https://cluster-agent.example.com".to_string()),
138            ..enabled_config()
139        };
140
141        assert_eq!(config.endpoint_and_token_with_env(env_lookup(&[])), None);
142    }
143
144    #[test]
145    fn endpoint_and_token_requires_resolvable_endpoint() {
146        let config = enabled_config();
147
148        assert_eq!(config.endpoint_and_token_with_env(env_lookup(&[])), None);
149    }
150
151    #[test]
152    fn endpoint_and_token_requires_https_url() {
153        let config = ClusterAgentConfiguration {
154            url: Some("http://cluster-agent.example.com".to_string()),
155            ..enabled_config()
156        };
157
158        assert_eq!(config.endpoint_and_token_with_env(env_lookup(&[])), None);
159    }
160
161    #[test]
162    fn endpoint_and_token_adds_https_scheme_to_url() {
163        let config = ClusterAgentConfiguration {
164            url: Some("cluster-agent.example.com:5005".to_string()),
165            ..enabled_config()
166        };
167
168        assert_eq!(
169            config.endpoint_and_token_with_env(env_lookup(&[])),
170            Some((
171                "https://cluster-agent.example.com:5005".to_string(),
172                "secret-token".to_string()
173            ))
174        );
175    }
176
177    #[test]
178    fn endpoint_and_token_requires_a_token() {
179        let config = ClusterAgentConfiguration {
180            url: Some("https://cluster-agent.example.com".to_string()),
181            auth_token: None,
182            ..enabled_config()
183        };
184
185        assert_eq!(config.endpoint_and_token_with_env(env_lookup(&[])), None);
186    }
187
188    #[test]
189    fn endpoint_and_token_returns_https_url_and_token() {
190        let config = ClusterAgentConfiguration {
191            url: Some("https://cluster-agent.example.com".to_string()),
192            ..enabled_config()
193        };
194
195        assert_eq!(
196            config.endpoint_and_token_with_env(env_lookup(&[])),
197            Some((
198                "https://cluster-agent.example.com".to_string(),
199                "secret-token".to_string()
200            ))
201        );
202    }
203
204    #[test]
205    fn endpoint_and_token_resolves_default_kubernetes_service_env() {
206        let config = enabled_config();
207
208        assert_eq!(
209            config.endpoint_and_token_with_env(env_lookup(&[
210                ("DATADOG_CLUSTER_AGENT_SERVICE_HOST", "127.0.0.1"),
211                ("DATADOG_CLUSTER_AGENT_SERVICE_PORT", "443"),
212            ])),
213            Some(("https://127.0.0.1:443".to_string(), "secret-token".to_string()))
214        );
215    }
216
217    #[test]
218    fn endpoint_and_token_resolves_configured_kubernetes_service_env() {
219        let config = ClusterAgentConfiguration {
220            kubernetes_service_name: "custom-cluster-agent".to_string(),
221            ..enabled_config()
222        };
223
224        assert_eq!(
225            config.endpoint_and_token_with_env(env_lookup(&[
226                ("CUSTOM_CLUSTER_AGENT_SERVICE_HOST", "10.0.0.7"),
227                ("CUSTOM_CLUSTER_AGENT_SERVICE_PORT", "5005"),
228            ])),
229            Some(("https://10.0.0.7:5005".to_string(), "secret-token".to_string()))
230        );
231    }
232
233    #[test]
234    fn an_empty_kubernetes_service_name_turns_discovery_off() {
235        // Configuration resolves an explicitly blank name to an empty string, which means the injected environment
236        // variables are ignored even when they are present.
237        let config = ClusterAgentConfiguration {
238            kubernetes_service_name: String::new(),
239            ..enabled_config()
240        };
241
242        assert_eq!(
243            config.endpoint_and_token_with_env(env_lookup(&[
244                ("DATADOG_CLUSTER_AGENT_SERVICE_HOST", "127.0.0.1"),
245                ("DATADOG_CLUSTER_AGENT_SERVICE_PORT", "443"),
246            ])),
247            None
248        );
249    }
250
251    #[test]
252    fn endpoint_and_token_wraps_kubernetes_service_ipv6_host() {
253        let config = enabled_config();
254
255        assert_eq!(
256            config.endpoint_and_token_with_env(env_lookup(&[
257                ("DATADOG_CLUSTER_AGENT_SERVICE_HOST", "fd38:552b:2959::4f4a"),
258                ("DATADOG_CLUSTER_AGENT_SERVICE_PORT", "5005"),
259            ])),
260            Some((
261                "https://[fd38:552b:2959::4f4a]:5005".to_string(),
262                "secret-token".to_string()
263            ))
264        );
265    }
266
267    #[test]
268    fn endpoint_and_token_prefers_configured_url_over_kubernetes_service_env() {
269        let config = ClusterAgentConfiguration {
270            url: Some("https://configured-cluster-agent.example.com".to_string()),
271            kubernetes_service_name: "custom-cluster-agent".to_string(),
272            ..enabled_config()
273        };
274
275        assert_eq!(
276            config.endpoint_and_token_with_env(env_lookup(&[
277                ("CUSTOM_CLUSTER_AGENT_SERVICE_HOST", "10.0.0.7"),
278                ("CUSTOM_CLUSTER_AGENT_SERVICE_PORT", "5005"),
279            ])),
280            Some((
281                "https://configured-cluster-agent.example.com".to_string(),
282                "secret-token".to_string()
283            ))
284        );
285    }
286
287    fn env_lookup<'a>(entries: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
288        move |key| {
289            entries
290                .iter()
291                .find_map(|(entry_key, entry_value)| (*entry_key == key).then(|| (*entry_value).to_string()))
292        }
293    }
294}