harness/
config.rs

1//! Configuration model and rendering for Datadog Agent configuration.
2//!
3//! Primary focus is currently `DogStatsD` but this is, hopefully, easy to expand
4//! in the future.
5//!
6//! One `datadog.yaml` is sampled per timeline. Only keys ADP and the Datadog
7//! Agent both fully support are emitted, and their values vary per timeline, so a
8//! divergence between the two targets is a finding rather than a config artifact.
9
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use anyhow::Context as _;
14use rand::distr::{Distribution, StandardUniform};
15use rand::{Rng, RngExt};
16use serde::{Deserialize, Serialize};
17
18use crate::payload::dogstatsd::PAYLOAD_BYTE_LIMIT;
19use crate::rand::Probe;
20
21/// Agent log level.
22///
23/// Pinned to `error`: the quietest level that still logs and a value both
24/// targets parse identically. Louder levels blow Antithesis's per-hour
25/// log-output budget. `off` is intentionally absent — `serde_yaml` renders it as
26/// the bare scalar `off`, which a YAML 1.1 reader decodes as the boolean
27/// `false`, and the Datadog Agent then rejects the level and refuses to boot.
28#[derive(Debug, Clone, Copy, Serialize)]
29#[serde(rename_all = "lowercase")]
30pub(crate) enum LogLevel {
31    /// Errors only — the quietest level that still logs.
32    Error,
33}
34
35/// Tag granularity for origin-detected `DogStatsD` tags.
36#[derive(Debug, Clone, Copy, Serialize)]
37#[serde(rename_all = "lowercase")]
38pub(crate) enum TagCardinality {
39    /// Low-cardinality objects: clusters, hosts, deployments, images. Agent
40    /// default.
41    Low,
42    /// Orchestrator-level: pod (Kubernetes) or task (ECS/Mesos) cardinality.
43    Orchestrator,
44    /// High-cardinality objects: individual containers, request user IDs, etc.
45    High,
46}
47
48impl Distribution<TagCardinality> for StandardUniform {
49    fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> TagCardinality {
50        match rng.random_range(0..3u8) {
51            0 => TagCardinality::Low,
52            1 => TagCardinality::Orchestrator,
53            _ => TagCardinality::High,
54        }
55    }
56}
57
58/// The Agent's `DogStatsD` configuration surface. `dogstatsd_socket` is supplied
59/// by the environment, the rest are sampled.
60///
61/// Numeric fields are sampled per field, the wide ones with [`Probe`].
62#[allow(clippy::struct_field_names, clippy::struct_excessive_bools)]
63#[derive(Debug, Serialize)]
64pub(crate) struct DogStatsdConfig {
65    /// Unix socket the server listens on. Supplied by the environment.
66    dogstatsd_socket: PathBuf,
67    /// Buffer used to receive statsd packets, in bytes.
68    dogstatsd_buffer_size: u64,
69    /// Bytes for the socket receive buffer (`POSIX`); `0` keeps the OS default.
70    dogstatsd_so_rcvbuf: u64,
71    /// Maximum entries in the string interner cache.
72    dogstatsd_string_interner_size: u64,
73    /// Tag granularity for origin-detected tags.
74    dogstatsd_tag_cardinality: TagCardinality,
75    /// Listen for non-local UDP traffic (binds `0.0.0.0`).
76    dogstatsd_non_local_traffic: bool,
77    /// Tag metrics with container metadata from the Unix socket peer.
78    dogstatsd_origin_detection: bool,
79    /// Use a client-provided container ID to enrich metrics.
80    dogstatsd_origin_detection_client: bool,
81    /// Let clients opt out of origin detection via cardinality `none`.
82    dogstatsd_origin_optout_enabled: bool,
83    /// When an `Entity-ID` is set, skip origin-detection tag enrichment.
84    dogstatsd_entity_id_precedence: bool,
85    /// Enable the no-aggregation pipeline (forward timestamped metrics with
86    /// tagging only).
87    dogstatsd_no_aggregation_pipeline: bool,
88    /// Flush incomplete metric time buckets on shutdown.
89    dogstatsd_flush_incomplete_buckets: bool,
90}
91
92impl DogStatsdConfig {
93    /// Sample the `DogStatsD` options from `rng`, taking the socket from the
94    /// environment.
95    fn sample<R: Rng + ?Sized>(rng: &mut R, dogstatsd_socket: &Path) -> Self {
96        Self {
97            dogstatsd_socket: dogstatsd_socket.to_path_buf(),
98            dogstatsd_buffer_size: rng.random_range(128..=65_536),
99            dogstatsd_so_rcvbuf: Probe::new(0, 25_165_824).sample(rng),
100            dogstatsd_string_interner_size: Probe::new(1, MAX_STRING_INTERNER_ENTRIES).sample(rng),
101            dogstatsd_tag_cardinality: rng.random(),
102            dogstatsd_non_local_traffic: rng.random(),
103            dogstatsd_origin_detection: rng.random(),
104            dogstatsd_origin_detection_client: rng.random(),
105            dogstatsd_origin_optout_enabled: rng.random(),
106            dogstatsd_entity_id_precedence: rng.random(),
107            dogstatsd_no_aggregation_pipeline: rng.random(),
108            dogstatsd_flush_incomplete_buckets: rng.random(),
109        }
110    }
111}
112
113/// Entry-count ceiling for `dogstatsd_string_interner_size`.
114///
115/// ADP and the Core Agent both preallocate the interner at boot, multiplying
116/// the entry count by 512 bytes when `dogstatsd_string_interner_size_bytes` is
117/// unset. The current value caps the preallocation at 512 MiB.
118const MAX_STRING_INTERNER_ENTRIES: u64 = 1_048_576;
119
120/// Agent-facing config. `hostname`, `api_key`, `dd_url`, and the socket are
121/// supplied by the environment; `log_level` and the `DogStatsD` options are
122/// sampled per branch. The static flags are appended by [`Self::to_yaml`], not
123/// fields here.
124#[derive(Debug, Serialize)]
125pub struct DatadogConfig {
126    /// Agent hostname. Supplied by the environment. ADP requires it
127    /// (`FixedHostProvider`); absent it refuses to boot.
128    hostname: String,
129    /// Agent API key. Supplied by the environment.
130    api_key: String,
131    /// Metrics intake base URL. Supplied by the environment.
132    dd_url: String,
133    /// Agent log verbosity. Pinned to `error` (see [`LogLevel`]).
134    log_level: LogLevel,
135    /// `DogStatsD` options, flattened to top-level `dogstatsd_*` keys.
136    #[serde(flatten)]
137    dogstatsd: DogStatsdConfig,
138}
139
140impl DatadogConfig {
141    /// Generate a config: the environmental fields come from the caller, the rest
142    /// are sampled from `rng`. With an Antithesis-backed rng, each call after the
143    /// snapshot yields an independent draw per replay branch.
144    #[must_use]
145    pub fn sample<R: Rng + ?Sized>(
146        rng: &mut R, hostname: &str, api_key: &str, dd_url: &str, dogstatsd_socket: &Path,
147    ) -> Self {
148        Self {
149            hostname: hostname.to_owned(),
150            api_key: api_key.to_owned(),
151            dd_url: dd_url.to_owned(),
152            log_level: LogLevel::Error,
153            dogstatsd: DogStatsdConfig::sample(rng, dogstatsd_socket),
154        }
155    }
156
157    /// Render `self` as a `datadog.yaml` string, followed by the static-tail
158    /// flags.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if serialization fails.
163    pub fn to_yaml(&self) -> anyhow::Result<String> {
164        let mut yaml = serde_yaml::to_string(self).context("serialize datadog.yaml")?;
165        yaml.push_str(STATIC_YAML_TAIL);
166        Ok(yaml)
167    }
168
169    /// Derive the [`DriverConfig`] a load generator reads to match this timeline's
170    /// SUT, sampling its knobs from `rng` so they land with the SUT config and the
171    /// two cannot disagree.
172    #[must_use]
173    pub fn driver_config<R: Rng + ?Sized>(&self, rng: &mut R) -> DriverConfig {
174        DriverConfig::sample(rng, self.dogstatsd.dogstatsd_buffer_size)
175    }
176}
177
178/// Yaml flags the Agent reads at boot that never vary.
179const STATIC_YAML_TAIL: &str = "use_dogstatsd: true
180use_v2_api_series: true
181inventories_enabled: false
182enable_metadata_collection: false
183cloud_provider_metadata: []
184";
185
186/// Upper bound on datagrams one driver invocation ships in a timeline.
187const MAX_DATAGRAMS: usize = 10_000;
188
189/// Config a load generator reads to shape its output to this timeline's SUT.
190/// `first_sample_config` samples it beside `datadog.yaml` from one draw, so the
191/// generator and the SUT are driven together.
192#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
193pub struct DriverConfig {
194    /// Max bytes a generator packs into one datagram, the smaller of the SUT's
195    /// sampled receive buffer and [`PAYLOAD_BYTE_LIMIT`]. A datagram this size
196    /// fits one read, so the SUT never truncates a line mid-token.
197    pub payload_byte_limit: usize,
198    /// Datagrams a driver invocation ships this timeline.
199    pub datagram_count: usize,
200}
201
202impl DriverConfig {
203    /// Sample the driver knobs for a SUT whose receive buffer is `buffer_size`.
204    fn sample<R: Rng + ?Sized>(rng: &mut R, buffer_size: u64) -> Self {
205        // The min is at most PAYLOAD_BYTE_LIMIT, so a buffer wider than usize
206        // caps to the ceiling like any other oversized buffer.
207        let payload_byte_limit = match usize::try_from(buffer_size.min(PAYLOAD_BYTE_LIMIT as u64)) {
208            Ok(bytes) => bytes,
209            Err(_) => PAYLOAD_BYTE_LIMIT,
210        };
211        Self {
212            payload_byte_limit,
213            datagram_count: rng.random_range(0..=MAX_DATAGRAMS),
214        }
215    }
216
217    /// Render `self` as a `driver.yaml` string.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if serialization fails.
222    pub fn to_yaml(&self) -> anyhow::Result<String> {
223        serde_yaml::to_string(self).context("serialize driver.yaml")
224    }
225
226    /// Read the driver config from the `driver.yaml` that `first_sample_config`
227    /// wrote to `config_dir`.
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if the config is unreadable or is not valid YAML with an
232    /// integer `payload_byte_limit`.
233    pub fn read(config_dir: &Path) -> anyhow::Result<Self> {
234        let path = config_dir.join("driver.yaml");
235        let yaml = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
236        serde_yaml::from_str(&yaml).with_context(|| format!("parse driver config from {}", path.display()))
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use std::convert::Infallible;
243
244    use rand::rand_core::TryRng;
245
246    use super::*;
247
248    /// A trivial deterministic `SplitMix64` generator. Implementing `TryRng` with
249    /// an infallible error gives a blanket [`rand::Rng`].
250    #[derive(Debug)]
251    struct SeqRng(u64);
252
253    impl TryRng for SeqRng {
254        type Error = Infallible;
255
256        fn try_next_u32(&mut self) -> Result<u32, Infallible> {
257            let bytes = self.try_next_u64()?.to_le_bytes();
258            Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
259        }
260
261        fn try_next_u64(&mut self) -> Result<u64, Infallible> {
262            self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
263            let mut z = self.0;
264            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
265            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
266            Ok(z ^ (z >> 31))
267        }
268
269        fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Infallible> {
270            for chunk in dst.chunks_mut(8) {
271                let bytes = self.try_next_u64()?.to_le_bytes();
272                chunk.copy_from_slice(&bytes[..chunk.len()]);
273            }
274            Ok(())
275        }
276    }
277
278    fn render(seed: u64) -> String {
279        let mut rng = SeqRng(seed);
280        DatadogConfig::sample(&mut rng, "h", "k", "http://intake:2049", Path::new("/s.sock"))
281            .to_yaml()
282            .expect("render yaml")
283    }
284
285    /// A rendered top-level key, matched at the start of a line to avoid a
286    /// prefix collision between related keys.
287    fn has_key(yaml: &str, key: &str) -> bool {
288        yaml.lines().any(|line| line.starts_with(&format!("{key}:")))
289    }
290
291    #[test]
292    fn driver_config_caps_payload_to_the_smaller_bound() {
293        assert_eq!(DriverConfig::sample(&mut SeqRng(0), 512).payload_byte_limit, 512);
294        assert_eq!(
295            DriverConfig::sample(&mut SeqRng(0), 1 << 30).payload_byte_limit,
296            PAYLOAD_BYTE_LIMIT
297        );
298        assert_eq!(DriverConfig::sample(&mut SeqRng(0), 0).payload_byte_limit, 0);
299    }
300
301    #[test]
302    fn log_level_is_always_an_unambiguous_scalar() {
303        assert!(has_key(&render(0), "log_level"));
304        assert!(render(0).contains("log_level: error"));
305    }
306}