Skip to main content

tropel_engine/
config_file.rs

1//! # Config-file & `K6_*` environment overlays
2//!
3//! Tropel can be configured three ways, in increasing precedence:
4//!
5//! 1. `K6_*` environment variables (k6-compatible names)
6//! 2. a `--config <file.json>` partial job-config overlay
7//! 3. explicit CLI flags (always win)
8//!
9//! The merge lives in [`crate::cli`]; this module only provides the partial
10//! model (`PartialConfig`), the JSON loader, and the env-var parser.
11
12use serde::Deserialize;
13use std::collections::HashMap;
14use std::path::Path;
15use tropel_core::config::*;
16use tropel_sdk::{Result, TropelError};
17
18/// A partial `JobConfig` — every field optional so a config file (or the
19/// env parser) can override only what it sets. `None`/empty means "not
20/// specified; leave the CLI/default value in place".
21#[derive(Debug, Clone, Default, Deserialize)]
22#[serde(default)]
23pub struct PartialConfig {
24    pub input_type: Option<String>,
25    pub execution: Option<ExecutionConfig>,
26    pub scenarios: Option<HashMap<String, ScenarioConfig>>,
27    pub env: HashMap<String, String>,
28    pub globals: HashMap<String, serde_json::Value>,
29    pub collection_vars: HashMap<String, serde_json::Value>,
30    pub data_file: Option<String>,
31    pub iteration_data: Vec<HashMap<String, serde_json::Value>>,
32    pub thresholds: HashMap<String, ThresholdConfig>,
33    pub output: Option<OutputConfig>,
34    pub http: Option<HttpConfig>,
35    pub tls: Option<TlsConfig>,
36    pub extensions: HashMap<String, serde_json::Value>,
37    /// k6 `executionSegment` — "from:to" workload partition for this node.
38    #[serde(default, alias = "executionSegment")]
39    pub execution_segment: Option<String>,
40    /// k6 `executionSegmentSequence` — full sequence of boundaries.
41    #[serde(default, alias = "executionSegmentSequence")]
42    pub execution_segment_sequence: Option<String>,
43    /// Port for the runtime control API (k6 REST parity) for
44    /// `externally-controlled` executors.
45    #[serde(default, alias = "controlPort")]
46    pub control_port: Option<u16>,
47}
48
49impl PartialConfig {
50    /// Load a partial config from a JSON file.
51    pub fn load_from_file(path: &Path) -> Result<Self> {
52        let content = std::fs::read_to_string(path).map_err(|e| {
53            TropelError::Io(std::io::Error::new(
54                e.kind(),
55                format!("Failed to read config file '{}': {e}", path.display()),
56            ))
57        })?;
58        serde_json::from_str(&content).map_err(|e| {
59            TropelError::Parse(format!(
60                "Failed to parse config file '{}': {e}",
61                path.display()
62            ))
63        })
64    }
65
66    /// Build a partial config from `K6_*` environment variables
67    /// (k6-compatible names: `K6_VUS`, `K6_DURATION`, `K6_ITERATIONS`,
68    /// `K6_MODE`, `K6_STAGES`, `K6_THRESHOLDS`, `K6_INSECURE_SKIP_TLS_VERIFY`,
69    /// `K6_REPORTER`, `K6_OUTPUT`, ...). Invalid values are logged and
70    /// skipped — a typo in an env var must not abort the run.
71    ///
72    /// The load-profile vars (`K6_MODE`/`K6_VUS`/`K6_DURATION`/
73    /// `K6_ITERATIONS`/`K6_STAGES`) build an `ExecutionConfig` using the same
74    /// precedence as the CLI (stages → ramping-vus, iterations →
75    /// shared-iterations, mode → that executor, else vus+duration →
76    /// constant-vus). The raw values are also copied into `env` so scripts
77    /// see them via `__ENV` (k6 behavior).
78    pub fn from_env() -> Self {
79        let mut cfg = Self::default();
80
81        let k6_mode = env_str("K6_MODE");
82        let k6_vus = env_num::<u32>("K6_VUS");
83        let k6_duration = env_str("K6_DURATION");
84        let k6_iterations = env_num::<u64>("K6_ITERATIONS");
85        let k6_stages = env_str("K6_STAGES");
86
87        if let Some(v) = k6_mode.clone() {
88            cfg.env.insert("K6_MODE".into(), v);
89        }
90        if let Some(v) = k6_vus {
91            cfg.env.insert("K6_VUS".into(), v.to_string());
92        }
93        if let Some(v) = k6_duration.clone() {
94            cfg.env.insert("K6_DURATION".into(), v);
95        }
96        if let Some(v) = k6_iterations {
97            cfg.env.insert("K6_ITERATIONS".into(), v.to_string());
98        }
99        if let Some(v) = k6_stages.clone() {
100            // JSON array of {duration, target} — parsed below.
101            cfg.env.insert("K6_STAGES".into(), v);
102        }
103
104        cfg.execution = env_execution(
105            k6_mode.as_deref(),
106            k6_vus,
107            k6_duration.as_deref(),
108            k6_iterations,
109            k6_stages.as_deref(),
110        );
111
112        if let Some(v) = env_str("K6_THRESHOLDS") {
113            if let Ok(map) = serde_json::from_str::<HashMap<String, ThresholdConfig>>(&v) {
114                cfg.thresholds.extend(map);
115            } else if let Ok(map) =
116                serde_json::from_str::<HashMap<String, Vec<ThresholdConfig>>>(&v)
117            {
118                for (metric, list) in map {
119                    for (i, t) in list.into_iter().enumerate() {
120                        let key = if i == 0 {
121                            metric.clone()
122                        } else {
123                            format!("{metric}#{i}")
124                        };
125                        cfg.thresholds.insert(key, t);
126                    }
127                }
128            } else {
129                tracing::warn!("K6_THRESHOLDS is not valid JSON — ignored");
130            }
131        }
132        if let Some(v) = env_str("K6_INSECURE_SKIP_TLS_VERIFY") {
133            if let Ok(b) = v.parse::<bool>() {
134                let mut tls = cfg.tls.take().unwrap_or_default();
135                tls.insecure_skip_verify = b;
136                cfg.tls = Some(tls);
137            }
138        }
139        if let Some(v) = env_str("K6_REPORTER") {
140            let mut out = cfg.output.take().unwrap_or_default();
141            out.reporters = v.split(',').map(|s| s.trim().to_string()).collect();
142            cfg.output = Some(out);
143        }
144        if let Some(v) = env_str("K6_OUTPUT") {
145            let mut out = cfg.output.take().unwrap_or_default();
146            out.output_file = Some(v);
147            cfg.output = Some(out);
148        }
149        if let Some(v) = env_str("K6_PROMETHEUS_URL") {
150            let mut out = cfg.output.take().unwrap_or_default();
151            out.prometheus_remote_write_url = Some(v);
152            cfg.output = Some(out);
153        }
154        if let Some(v) = env_str("K6_OTLP_ENDPOINT") {
155            let mut out = cfg.output.take().unwrap_or_default();
156            out.otlp_endpoint = Some(v);
157            cfg.output = Some(out);
158        }
159        cfg.execution_segment = env_str("K6_EXECUTION_SEGMENT");
160        cfg.execution_segment_sequence = env_str("K6_EXECUTION_SEGMENT_SEQUENCE");
161        if let Some(v) = env_str("K6_DISCARD_RESPONSE_BODIES") {
162            if let Ok(b) = v.parse::<bool>() {
163                let mut http = cfg.http.take().unwrap_or_default();
164                http.discard_response_bodies = b;
165                cfg.http = Some(http);
166            }
167        }
168
169        cfg
170    }
171}
172
173fn env_str(key: &str) -> Option<String> {
174    std::env::var(key).ok()
175}
176
177fn env_num<T: std::str::FromStr>(key: &str) -> Option<T> {
178    std::env::var(key).ok().and_then(|v| match v.parse() {
179        Ok(val) => Some(val),
180        Err(_) => {
181            tracing::debug!("K6_* env var '{key}' value '{v}' is not valid — ignored");
182            None
183        }
184    })
185}
186
187/// Build an `ExecutionConfig` from k6-style env load-profile vars,
188/// mirroring the CLI's precedence (stages → ramping-vus, iterations →
189/// shared-iterations, mode → explicit executor, else vus+duration →
190/// constant-vus). Returns `None` when nothing usable is set.
191fn env_execution(
192    mode: Option<&str>,
193    vus: Option<u32>,
194    duration: Option<&str>,
195    iterations: Option<u64>,
196    stages: Option<&str>,
197) -> Option<ExecutionConfig> {
198    let think_time = ThinkTimeConfig::default();
199
200    if let Some(mode) = mode {
201        // Canonical mode→executor mapping lives in tropel-core (shared with
202        // the CLI), so the precedence rules exist in exactly one place.
203        return Some(ExecutionConfig::from_mode(
204            mode,
205            vus,
206            duration.map(|s| s.to_string()),
207            iterations,
208            stages.map(|s| s.to_string()),
209        ));
210    }
211
212    if let Some(stages_str) = stages {
213        match serde_json::from_str::<Vec<Stage>>(stages_str) {
214            Ok(stage_list) if !stage_list.is_empty() => {
215                return Some(ExecutionConfig::RampingVus {
216                    stages: stage_list,
217                    start_vus: vus.unwrap_or(1),
218                    graceful_ramp_down: Some("30s".to_string()),
219                    graceful_stop: Some("30s".to_string()),
220                    think_time,
221                });
222            }
223            Ok(_) => {
224                tracing::warn!("K6_STAGES parsed but is empty — ignoring");
225            }
226            Err(e) => {
227                tracing::warn!("K6_STAGES is malformed ({}): {}", stages_str, e);
228            }
229        }
230    }
231
232    if let Some(iterations) = iterations {
233        return Some(ExecutionConfig::SharedIterations {
234            iterations,
235            max_duration: duration.map(|s| s.to_string()),
236            vus: vus.unwrap_or(1),
237            graceful_stop: Some("30s".to_string()),
238            think_time,
239        });
240    }
241
242    if let (Some(vus), Some(duration)) = (vus, duration) {
243        return Some(ExecutionConfig::ConstantVus {
244            vus,
245            duration: duration.to_string(),
246            graceful_stop: Some("30s".to_string()),
247            think_time,
248        });
249    }
250
251    None
252}
253
254#[cfg(test)]
255mod env_tests {
256    use super::*;
257
258    #[test]
259    fn test_env_vus_duration_constant() {
260        let exec = env_execution(None, Some(5), Some("10s"), None, None);
261        match exec {
262            Some(ExecutionConfig::ConstantVus { vus, duration, .. }) => {
263                assert_eq!(vus, 5);
264                assert_eq!(duration, "10s");
265            }
266            other => panic!("expected ConstantVus, got {other:?}"),
267        }
268    }
269
270    #[test]
271    fn test_env_iterations_shared() {
272        let exec = env_execution(None, Some(3), None, Some(50), None);
273        match exec {
274            Some(ExecutionConfig::SharedIterations {
275                iterations, vus, ..
276            }) => {
277                assert_eq!(iterations, 50);
278                assert_eq!(vus, 3);
279            }
280            other => panic!("expected SharedIterations, got {other:?}"),
281        }
282    }
283
284    #[test]
285    fn test_env_stages_ramping() {
286        let exec = env_execution(
287            None,
288            Some(2),
289            None,
290            None,
291            Some(r#"[{"duration":"10s","target":20}]"#),
292        );
293        match exec {
294            Some(ExecutionConfig::RampingVus {
295                start_vus, stages, ..
296            }) => {
297                assert_eq!(start_vus, 2);
298                assert_eq!(stages[0].target, 20);
299            }
300            other => panic!("expected RampingVus, got {other:?}"),
301        }
302    }
303
304    #[test]
305    fn test_env_mode_wins() {
306        let exec = env_execution(
307            Some("shared-iterations"),
308            Some(4),
309            Some("10s"),
310            Some(99),
311            None,
312        );
313        match exec {
314            Some(ExecutionConfig::SharedIterations {
315                iterations, vus, ..
316            }) => {
317                assert_eq!(iterations, 99);
318                assert_eq!(vus, 4);
319            }
320            other => panic!("expected SharedIterations, got {other:?}"),
321        }
322    }
323
324    #[test]
325    fn test_env_nothing() {
326        assert!(env_execution(None, None, None, None, None).is_none());
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn test_load_config_file_roundtrip() {
336        let dir = std::env::temp_dir();
337        let path = dir.join("tropel_test_config.json");
338        std::fs::write(
339            &path,
340            r#"{
341                "input_type": "postman",
342                "thresholds": {"http_req_duration": {"expression": "http_req_duration.p95 < 500"}},
343                "tls": {"insecure_skip_verify": true}
344            }"#,
345        )
346        .unwrap();
347        let cfg = PartialConfig::load_from_file(&path).unwrap();
348        assert_eq!(cfg.input_type.as_deref(), Some("postman"));
349        assert_eq!(
350            cfg.thresholds.get("http_req_duration").unwrap().expression,
351            "http_req_duration.p95 < 500"
352        );
353        assert!(cfg.tls.unwrap().insecure_skip_verify);
354        std::fs::remove_file(&path).ok();
355    }
356
357    #[test]
358    fn test_config_file_missing_is_error() {
359        let err = PartialConfig::load_from_file(Path::new("/nonexistent/nope.json"));
360        assert!(err.is_err());
361    }
362}