Skip to main content

mockforge_bench/
parallel_executor.rs

1//! Parallel execution engine for multi-target bench testing
2//!
3//! Executes load tests against multiple targets in parallel with configurable
4//! concurrency limits. Uses tokio for async execution and semaphores for
5//! backpressure control.
6
7use crate::command::BenchCommand;
8use crate::error::{BenchError, Result};
9use crate::executor::{K6Executor, K6Results};
10use crate::k6_gen::{K6Config, K6ScriptGenerator};
11use crate::reporter::TerminalReporter;
12use crate::request_gen::RequestGenerator;
13use crate::scenarios::LoadScenario;
14use crate::spec_parser::SpecParser;
15use crate::target_parser::TargetConfig;
16use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
17use mockforge_openapi::spec::OpenApiSpec;
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::str::FromStr;
21use std::sync::Arc;
22use tokio::sync::Semaphore;
23use tokio::task::JoinHandle;
24
25/// Result for a single target execution
26#[derive(Debug, Clone)]
27pub struct TargetResult {
28    /// Target URL that was tested
29    pub target_url: String,
30    /// Index of the target (for ordering)
31    pub target_index: usize,
32    /// k6 test results
33    pub results: K6Results,
34    /// Output directory for this target
35    pub output_dir: PathBuf,
36    /// Whether the test succeeded
37    pub success: bool,
38    /// Error message if test failed
39    pub error: Option<String>,
40}
41
42/// Aggregated results from all target executions
43#[derive(Debug, Clone)]
44pub struct AggregatedResults {
45    /// Results for each target
46    pub target_results: Vec<TargetResult>,
47    /// Overall statistics
48    pub total_targets: usize,
49    pub successful_targets: usize,
50    pub failed_targets: usize,
51    /// Aggregated metrics across all targets
52    pub aggregated_metrics: AggregatedMetrics,
53}
54
55/// Aggregated metrics across all targets
56#[derive(Debug, Clone)]
57pub struct AggregatedMetrics {
58    /// Total requests across all targets
59    pub total_requests: u64,
60    /// Total failed requests across all targets
61    pub total_failed_requests: u64,
62    /// Average response time across all targets (ms)
63    pub avg_duration_ms: f64,
64    /// p95 response time across all targets (ms)
65    pub p95_duration_ms: f64,
66    /// p99 response time across all targets (ms)
67    pub p99_duration_ms: f64,
68    /// Overall error rate percentage
69    pub error_rate: f64,
70    /// Total RPS across all targets
71    pub total_rps: f64,
72    /// Average RPS per target
73    pub avg_rps: f64,
74    /// Total max VUs across all targets
75    pub total_vus_max: u32,
76    /// Total connections opened across all targets (Issue #79 round 12 —
77    /// Srikanth's multi-target bench output was missing the CPS / connection
78    /// counts that single-target runs surface).
79    pub total_connections_opened: u64,
80    /// Total iterations completed across all targets.
81    pub total_iterations_completed: u64,
82}
83
84impl AggregatedMetrics {
85    /// Calculate aggregated metrics from target results
86    fn from_results(results: &[TargetResult]) -> Self {
87        let mut total_requests = 0u64;
88        let mut total_failed_requests = 0u64;
89        let mut durations = Vec::new();
90        let mut p95_values = Vec::new();
91        let mut p99_values = Vec::new();
92        let mut total_rps = 0.0f64;
93        let mut total_vus_max = 0u32;
94        let mut total_connections_opened = 0u64;
95        let mut total_iterations_completed = 0u64;
96        let mut successful_count = 0usize;
97
98        for result in results {
99            if result.success {
100                total_requests += result.results.total_requests;
101                total_failed_requests += result.results.failed_requests;
102                durations.push(result.results.avg_duration_ms);
103                p95_values.push(result.results.p95_duration_ms);
104                p99_values.push(result.results.p99_duration_ms);
105                total_rps += result.results.rps;
106                total_vus_max += result.results.vus_max;
107                total_connections_opened += result.results.tcp_connect_samples;
108                total_iterations_completed += result.results.iterations_completed;
109                successful_count += 1;
110            }
111        }
112
113        let avg_duration_ms = if !durations.is_empty() {
114            durations.iter().sum::<f64>() / durations.len() as f64
115        } else {
116            0.0
117        };
118
119        let p95_duration_ms = if !p95_values.is_empty() {
120            p95_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
121            let index = (p95_values.len() as f64 * 0.95).ceil() as usize - 1;
122            p95_values[index.min(p95_values.len() - 1)]
123        } else {
124            0.0
125        };
126
127        let p99_duration_ms = if !p99_values.is_empty() {
128            p99_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
129            let index = (p99_values.len() as f64 * 0.99).ceil() as usize - 1;
130            p99_values[index.min(p99_values.len() - 1)]
131        } else {
132            0.0
133        };
134
135        let error_rate = if total_requests > 0 {
136            (total_failed_requests as f64 / total_requests as f64) * 100.0
137        } else {
138            0.0
139        };
140
141        let avg_rps = if successful_count > 0 {
142            total_rps / successful_count as f64
143        } else {
144            0.0
145        };
146
147        Self {
148            total_requests,
149            total_failed_requests,
150            avg_duration_ms,
151            p95_duration_ms,
152            p99_duration_ms,
153            error_rate,
154            total_rps,
155            avg_rps,
156            total_vus_max,
157            total_connections_opened,
158            total_iterations_completed,
159        }
160    }
161}
162
163/// Parallel executor for multi-target bench testing
164pub struct ParallelExecutor {
165    /// Base command configuration (shared across all targets)
166    base_command: BenchCommand,
167    /// List of targets to test
168    targets: Vec<TargetConfig>,
169    /// Maximum number of concurrent executions
170    max_concurrency: usize,
171    /// Base output directory
172    base_output: PathBuf,
173}
174
175impl ParallelExecutor {
176    /// Create a new parallel executor
177    pub fn new(
178        base_command: BenchCommand,
179        targets: Vec<TargetConfig>,
180        max_concurrency: usize,
181    ) -> Self {
182        let base_output = base_command.output.clone();
183        Self {
184            base_command,
185            targets,
186            max_concurrency,
187            base_output,
188        }
189    }
190
191    /// Execute tests against all targets in parallel
192    pub async fn execute_all(&self) -> Result<AggregatedResults> {
193        let total_targets = self.targets.len();
194        TerminalReporter::print_progress(&format!(
195            "Starting parallel execution for {} targets (max concurrency: {})",
196            total_targets, self.max_concurrency
197        ));
198
199        // Validate k6 installation
200        if !K6Executor::is_k6_installed() {
201            TerminalReporter::print_error("k6 is not installed");
202            TerminalReporter::print_warning(
203                "Install k6 from: https://k6.io/docs/get-started/installation/",
204            );
205            return Err(BenchError::K6NotFound);
206        }
207
208        // #79: --targets-file used to ignore --wafbench-verbatim. It always
209        // required a spec and built templates from spec operations, so the
210        // command either died with "No spec files provided" or fuzzed spec
211        // URLs instead of sending the traffic file as written.
212        let spec_supplied =
213            !self.base_command.spec.is_empty() || self.base_command.spec_dir.is_some();
214        let verbatim = self.base_command.wafbench_verbatim;
215
216        let (templates, parser) = if verbatim {
217            let verbatim_templates = self.base_command.load_verbatim_templates()?;
218            if verbatim_templates.is_empty() {
219                return Err(BenchError::Other(
220                    "--wafbench-verbatim was set but no traffic cases were loaded. Check \
221                     --wafbench-dir points at a file, directory or glob containing cases with \
222                     a `request.uri`."
223                        .to_string(),
224                ));
225            }
226            TerminalReporter::print_success(&format!(
227                "Verbatim mode: {} request(s) will be sent exactly as written (spec endpoints not used)",
228                verbatim_templates.len()
229            ));
230            let parser = if spec_supplied {
231                TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
232                let merged_spec = self.base_command.load_and_merge_specs().await?;
233                TerminalReporter::print_success("Specification(s) loaded (base path only)");
234                SpecParser::from_spec(merged_spec)
235            } else {
236                SpecParser::from_spec(OpenApiSpec {
237                    spec: Default::default(),
238                    file_path: None,
239                    raw_document: None,
240                })
241            };
242            (verbatim_templates, parser)
243        } else {
244            TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
245            let merged_spec = self.base_command.load_and_merge_specs().await?;
246            let parser = SpecParser::from_spec(merged_spec);
247            TerminalReporter::print_success("Specification(s) loaded");
248
249            let operations = if let Some(filter) = &self.base_command.operations {
250                parser.filter_operations(filter)?
251            } else {
252                parser.get_operations()
253            };
254
255            if operations.is_empty() {
256                return Err(BenchError::Other("No operations found in spec".to_string()));
257            }
258
259            TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
260
261            TerminalReporter::print_progress("Generating request templates...");
262            let templates: Vec<_> = operations
263                .iter()
264                .map(RequestGenerator::generate_template)
265                .collect::<Result<Vec<_>>>()?;
266            TerminalReporter::print_success("Request templates generated");
267            (templates, parser)
268        };
269
270        // Pre-load per-target specs. In verbatim mode a per-target spec may
271        // still resolve --base-path, but it must not replace the traffic-file
272        // templates. That swap is how --targets-file kept fuzzing spec URLs
273        // after --wafbench-verbatim was added (#79).
274        let mut per_target_data: HashMap<
275            PathBuf,
276            (Vec<crate::request_gen::RequestTemplate>, Option<String>),
277        > = HashMap::new();
278        if !verbatim {
279            let mut unique_specs: Vec<PathBuf> = Vec::new();
280            for t in &self.targets {
281                if let Some(spec_path) = &t.spec {
282                    if !unique_specs.contains(spec_path) {
283                        unique_specs.push(spec_path.clone());
284                    }
285                }
286            }
287            for spec_path in &unique_specs {
288                TerminalReporter::print_progress(&format!(
289                    "Loading per-target spec: {}",
290                    spec_path.display()
291                ));
292                match SpecParser::from_file(spec_path).await {
293                    Ok(target_parser) => {
294                        let target_ops = if let Some(filter) = &self.base_command.operations {
295                            match target_parser.filter_operations(filter) {
296                                Ok(ops) => ops,
297                                Err(e) => {
298                                    TerminalReporter::print_warning(&format!(
299                                        "Failed to filter operations from {}: {}. Using shared spec.",
300                                        spec_path.display(),
301                                        e
302                                    ));
303                                    continue;
304                                }
305                            }
306                        } else {
307                            target_parser.get_operations()
308                        };
309                        let target_templates: Vec<_> = match target_ops
310                            .iter()
311                            .map(RequestGenerator::generate_template)
312                            .collect::<Result<Vec<_>>>()
313                        {
314                            Ok(t) => t,
315                            Err(e) => {
316                                TerminalReporter::print_warning(&format!(
317                                    "Failed to generate templates from {}: {}. Using shared spec.",
318                                    spec_path.display(),
319                                    e
320                                ));
321                                continue;
322                            }
323                        };
324                        let target_base_path = if let Some(cli_bp) = &self.base_command.base_path {
325                            if cli_bp.is_empty() {
326                                None
327                            } else {
328                                Some(cli_bp.clone())
329                            }
330                        } else {
331                            target_parser.get_base_path()
332                        };
333                        TerminalReporter::print_success(&format!(
334                            "Loaded {} operations from {}",
335                            target_templates.len(),
336                            spec_path.display()
337                        ));
338                        per_target_data
339                            .insert(spec_path.clone(), (target_templates, target_base_path));
340                    }
341                    Err(e) => {
342                        TerminalReporter::print_warning(&format!(
343                            "Failed to load per-target spec {}: {}. Targets using this spec will use the shared spec.",
344                            spec_path.display(),
345                            e
346                        ));
347                    }
348                }
349            }
350        }
351
352        // Parse base headers
353        let base_headers = self.base_command.parse_headers()?;
354
355        // Resolve base path (CLI option takes priority over spec's servers URL)
356        let base_path = self.resolve_base_path(&parser);
357        if let Some(ref bp) = base_path {
358            TerminalReporter::print_progress(&format!("Using base path: {}", bp));
359        }
360
361        // Parse scenario
362        let scenario = LoadScenario::from_str(&self.base_command.scenario)
363            .map_err(BenchError::InvalidScenario)?;
364
365        let duration_secs_val = BenchCommand::parse_duration(&self.base_command.duration)?;
366
367        let security_testing_enabled_val = self.base_command.security_testing_enabled();
368
369        // Pre-compute enhancement code once (same for all targets)
370        let has_advanced_features = self.base_command.data_file.is_some()
371            || self.base_command.error_rate.is_some()
372            || self.base_command.security_testing_enabled()
373            || self.base_command.parallel_create.is_some();
374
375        let enhancement_code = if has_advanced_features {
376            let dummy_script = "export const options = {};";
377            let enhanced = self.base_command.generate_enhanced_script(dummy_script)?;
378            if let Some(pos) = enhanced.find("export const options") {
379                enhanced[..pos].to_string()
380            } else {
381                String::new()
382            }
383        } else {
384            String::new()
385        };
386
387        // Create semaphore for concurrency control
388        let semaphore = Arc::new(Semaphore::new(self.max_concurrency));
389        let multi_progress = MultiProgress::new();
390
391        // Create progress bars for each target
392        let progress_bars: Vec<ProgressBar> = (0..total_targets)
393            .map(|i| {
394                let pb = multi_progress.add(ProgressBar::new(1));
395                pb.set_style(
396                    ProgressStyle::default_bar()
397                        .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} {msg}")
398                        .unwrap(),
399                );
400                pb.set_message(format!("Target {}", i + 1));
401                pb
402            })
403            .collect();
404
405        // Spawn tasks for each target
406        let mut handles: Vec<JoinHandle<Result<TargetResult>>> = Vec::new();
407
408        for (index, target) in self.targets.iter().enumerate() {
409            let target = target.clone();
410            // Clone necessary fields from base_command instead of passing reference
411            let duration = self.base_command.duration.clone();
412            let vus = self.base_command.vus;
413            let scenario_str = self.base_command.scenario.clone();
414            let operations = self.base_command.operations.clone();
415            let auth = self.base_command.auth.clone();
416            let headers = self.base_command.headers.clone();
417            let threshold_percentile = self.base_command.threshold_percentile.clone();
418            let threshold_ms = self.base_command.threshold_ms;
419            let max_error_rate = self.base_command.max_error_rate;
420            // Issue #79 r62 (Srikanth on 0.3.209): the round-60 abort valve
421            // stopped his WAF stress runs at ~2min because a legitimate high
422            // rejection rate crossed 0.95. Thread the opt-out/threshold through
423            // the per-target path so `--no-abort-on-error` reaches the k6 script.
424            let abort_on_error = self.base_command.abort_on_error;
425            let abort_on_error_rate = self.base_command.abort_on_error_rate;
426            let verbose = self.base_command.verbose;
427            let skip_tls_verify = self.base_command.skip_tls_verify;
428            let chunked_request_bodies = self.base_command.chunked_request_bodies;
429            let target_rps = self.base_command.target_rps;
430            let no_keep_alive = self.base_command.no_keep_alive;
431            // Issue #79 r54 (Srikanth on 0.3.200): `--source-ip` was silently
432            // dropped in multi-target (`--targets-file`) mode because this
433            // executor built `K6Executor::new()` without `.with_local_ips` and
434            // the per-target BenchCommand clone zeroed `source_ips`. Thread the
435            // source IPs (as the k6 `--local-ips` list) and geo config through
436            // to each target's run.
437            let local_ips = self.base_command.source_ips.join(",");
438            let dns_policy = self.base_command.dns_policy.clone().unwrap_or_default();
439            let geo_source_ips = self.base_command.geo_source_ips.clone();
440            let geo_source_headers = self.base_command.geo_source_headers.clone();
441
442            // Select per-target templates/base_path if this target has a custom spec.
443            // Verbatim templates stay the traffic file's requests even when a
444            // target lists its own spec.
445            let (templates, base_path) = if verbatim {
446                (templates.clone(), base_path.clone())
447            } else if let Some(spec_path) = &target.spec {
448                if let Some((t, bp)) = per_target_data.get(spec_path) {
449                    (t.clone(), bp.clone())
450                } else {
451                    (templates.clone(), base_path.clone())
452                }
453            } else {
454                (templates.clone(), base_path.clone())
455            };
456
457            let base_headers = base_headers.clone();
458            let scenario = scenario.clone();
459            let duration_secs = duration_secs_val;
460            let base_output = self.base_output.clone();
461            let semaphore = semaphore.clone();
462            let progress_bar = progress_bars[index].clone();
463            let target_index = index;
464            let security_testing_enabled = security_testing_enabled_val;
465            let enhancement_code = enhancement_code.clone();
466
467            let handle = tokio::spawn(async move {
468                // Acquire semaphore permit
469                let _permit = semaphore.acquire().await.map_err(|e| {
470                    BenchError::Other(format!("Failed to acquire semaphore: {}", e))
471                })?;
472
473                progress_bar.set_message(format!("Testing {}", target.url));
474
475                // Execute test for this target
476                let result = Self::execute_single_target_internal(
477                    &duration,
478                    vus,
479                    &scenario_str,
480                    &operations,
481                    &auth,
482                    &headers,
483                    &threshold_percentile,
484                    threshold_ms,
485                    max_error_rate,
486                    abort_on_error,
487                    abort_on_error_rate,
488                    verbose,
489                    skip_tls_verify,
490                    base_path.as_ref(),
491                    &target,
492                    target_index,
493                    &templates,
494                    &base_headers,
495                    &scenario,
496                    duration_secs,
497                    &base_output,
498                    security_testing_enabled,
499                    chunked_request_bodies,
500                    target_rps,
501                    no_keep_alive,
502                    &enhancement_code,
503                    &local_ips,
504                    &dns_policy,
505                    &geo_source_ips,
506                    &geo_source_headers,
507                )
508                .await;
509
510                progress_bar.inc(1);
511                progress_bar.finish_with_message(format!("Completed {}", target.url));
512
513                result
514            });
515
516            handles.push(handle);
517        }
518
519        // Wait for all tasks to complete and collect results
520        let mut target_results = Vec::new();
521        for (index, handle) in handles.into_iter().enumerate() {
522            match handle.await {
523                Ok(Ok(result)) => {
524                    target_results.push(result);
525                }
526                Ok(Err(e)) => {
527                    // Create error result
528                    let target_url = self.targets[index].url.clone();
529                    target_results.push(TargetResult {
530                        target_url: target_url.clone(),
531                        target_index: index,
532                        results: K6Results::default(),
533                        output_dir: self.base_output.join(format!("target_{}", index + 1)),
534                        success: false,
535                        error: Some(e.to_string()),
536                    });
537                }
538                Err(e) => {
539                    // Join error
540                    let target_url = self.targets[index].url.clone();
541                    target_results.push(TargetResult {
542                        target_url: target_url.clone(),
543                        target_index: index,
544                        results: K6Results::default(),
545                        output_dir: self.base_output.join(format!("target_{}", index + 1)),
546                        success: false,
547                        error: Some(format!("Task join error: {}", e)),
548                    });
549                }
550            }
551        }
552
553        // Sort results by target index
554        target_results.sort_by_key(|r| r.target_index);
555
556        // Calculate aggregated metrics
557        let aggregated_metrics = AggregatedMetrics::from_results(&target_results);
558
559        let successful_targets = target_results.iter().filter(|r| r.success).count();
560        let failed_targets = total_targets - successful_targets;
561
562        Ok(AggregatedResults {
563            target_results,
564            total_targets,
565            successful_targets,
566            failed_targets,
567            aggregated_metrics,
568        })
569    }
570
571    /// Resolve the effective base path for API endpoints
572    fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
573        // CLI option takes priority (including empty string to disable)
574        if let Some(cli_base_path) = &self.base_command.base_path {
575            if cli_base_path.is_empty() {
576                return None;
577            }
578            return Some(cli_base_path.clone());
579        }
580        // Fall back to spec's base path
581        parser.get_base_path()
582    }
583
584    /// Execute a single target test (internal method that doesn't require BenchCommand)
585    #[allow(clippy::too_many_arguments)]
586    async fn execute_single_target_internal(
587        _duration: &str,
588        vus: u32,
589        _scenario_str: &str,
590        _operations: &Option<String>,
591        auth: &Option<String>,
592        _headers: &[String],
593        threshold_percentile: &str,
594        threshold_ms: u64,
595        max_error_rate: f64,
596        abort_on_error: bool,
597        abort_on_error_rate: f64,
598        verbose: bool,
599        skip_tls_verify: bool,
600        base_path: Option<&String>,
601        target: &TargetConfig,
602        target_index: usize,
603        templates: &[crate::request_gen::RequestTemplate],
604        base_headers: &HashMap<String, String>,
605        scenario: &LoadScenario,
606        duration_secs: u64,
607        base_output: &Path,
608        security_testing_enabled: bool,
609        chunked_request_bodies: bool,
610        target_rps: Option<u32>,
611        no_keep_alive: bool,
612        enhancement_code: &str,
613        local_ips: &str,
614        dns_policy: &str,
615        geo_source_ips: &[String],
616        geo_source_headers: &[String],
617    ) -> Result<TargetResult> {
618        // Merge target-specific headers with base headers
619        let mut custom_headers = base_headers.clone();
620        if let Some(target_headers) = &target.headers {
621            custom_headers.extend(target_headers.clone());
622        }
623
624        // Use target-specific auth if provided, otherwise use base auth
625        let auth_header = target.auth.as_ref().or(auth.as_ref()).cloned();
626
627        // Create k6 config for this target
628        let k6_config = K6Config {
629            target_url: target.url.clone(),
630            base_path: base_path.cloned(),
631            scenario: scenario.clone(),
632            duration_secs,
633            max_vus: vus,
634            threshold_percentile: threshold_percentile.to_string(),
635            threshold_ms,
636            max_error_rate,
637            auth_header,
638            custom_headers,
639            skip_tls_verify,
640            security_testing_enabled,
641            chunked_request_bodies,
642            target_rps,
643            no_keep_alive,
644            geo_source_ips: geo_source_ips.to_vec(),
645            geo_source_headers: geo_source_headers.to_vec(),
646        };
647
648        // Generate k6 script
649        let generator = K6ScriptGenerator::new(k6_config, templates.to_vec())
650            .with_abort_valve(abort_on_error, abort_on_error_rate);
651        let mut script = generator.generate()?;
652
653        // Apply pre-computed enhancement code (security definitions, etc.)
654        if !enhancement_code.is_empty() {
655            if let Some(pos) = script.find("export const options") {
656                script.insert_str(pos, enhancement_code);
657            }
658        }
659
660        // Validate script
661        let validation_errors = K6ScriptGenerator::validate_script(&script);
662        if !validation_errors.is_empty() {
663            return Err(BenchError::Other(format!(
664                "Script validation failed for target {}: {}",
665                target.url,
666                validation_errors.join(", ")
667            )));
668        }
669
670        // Create output directory for this target
671        let output_dir = base_output.join(format!("target_{}", target_index + 1));
672        std::fs::create_dir_all(&output_dir)?;
673
674        // Write script to file
675        let script_path = output_dir.join("k6-script.js");
676        std::fs::write(&script_path, script)?;
677
678        // Execute k6 with its own REST API server port per target. k6 defaults
679        // to localhost:6565 and parallel instances collide on it.
680        //
681        // Issue #79 r58 — Srikanth on 0.3.205: every one of 10 targets failed
682        // with `exit status: 106` (k6 `CannotStartRESTAPI`) and 0 requests, so
683        // NO load ran at all (which read as "--discard-response-bodies isn't
684        // helping"). The old scheme pinned each k6 to a FIXED port
685        // (6565 + index → 6566, 6567, ...), which collides whenever those
686        // ports are already taken — e.g. orphaned k6 processes left behind by
687        // a previous run that was OOM-killed (his r56/r57 SIGKILL), a second
688        // concurrent bench, or any local service. Bind an OS-assigned
689        // ephemeral port (`:0`) instead: k6 asks the kernel for a free port at
690        // start, so it can never be "already in use". We never query the REST
691        // API (results come from summary.json on disk), so a random port is
692        // fine. Verified: two concurrent k6 with `--address localhost:0` both
693        // start cleanly where the fixed port yields exit 106.
694        let api_port = 0; // ephemeral: kernel picks a free port, no collision
695                          // Issue #79 r54 — forward `--source-ip` (as k6 `--local-ips`) so each
696                          // target's VUs rotate through the configured source IP pool. Without
697                          // this, `--source-ip` was a silent no-op in multi-target mode.
698                          // Issue #79 r56 — plain multi-target load only checks status codes, so
699                          // discard response bodies to keep k6's RSS bounded on long, high-VU runs.
700                          // Without this, 10+ targets * 10 VUs * 70 min buffered every body and the
701                          // kernel OOM-killer sent k6 `signal: 9 (SIGKILL)`.
702        let executor = K6Executor::new()?
703            .with_local_ips(local_ips.to_string())
704            .with_dns_policy(dns_policy.to_string())
705            .with_discard_response_bodies(true);
706        let results = executor
707            .execute_with_port(&script_path, Some(&output_dir), verbose, Some(api_port))
708            .await;
709
710        match results {
711            Ok(k6_results) => Ok(TargetResult {
712                target_url: target.url.clone(),
713                target_index,
714                results: k6_results,
715                output_dir,
716                success: true,
717                error: None,
718            }),
719            Err(e) => Ok(TargetResult {
720                target_url: target.url.clone(),
721                target_index,
722                results: K6Results::default(),
723                output_dir,
724                success: false,
725                error: Some(e.to_string()),
726            }),
727        }
728    }
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734
735    #[test]
736    fn test_aggregated_metrics_from_results() {
737        let results = vec![
738            TargetResult {
739                target_url: "http://api1.com".to_string(),
740                target_index: 0,
741                results: K6Results {
742                    total_requests: 100,
743                    failed_requests: 5,
744                    avg_duration_ms: 100.0,
745                    p95_duration_ms: 200.0,
746                    p99_duration_ms: 300.0,
747                    ..Default::default()
748                },
749                output_dir: PathBuf::from("output1"),
750                success: true,
751                error: None,
752            },
753            TargetResult {
754                target_url: "http://api2.com".to_string(),
755                target_index: 1,
756                results: K6Results {
757                    total_requests: 200,
758                    failed_requests: 10,
759                    avg_duration_ms: 150.0,
760                    p95_duration_ms: 250.0,
761                    p99_duration_ms: 350.0,
762                    ..Default::default()
763                },
764                output_dir: PathBuf::from("output2"),
765                success: true,
766                error: None,
767            },
768        ];
769
770        let metrics = AggregatedMetrics::from_results(&results);
771        assert_eq!(metrics.total_requests, 300);
772        assert_eq!(metrics.total_failed_requests, 15);
773        assert_eq!(metrics.avg_duration_ms, 125.0); // (100 + 150) / 2
774    }
775
776    #[test]
777    fn test_aggregated_metrics_with_failed_targets() {
778        let results = vec![
779            TargetResult {
780                target_url: "http://api1.com".to_string(),
781                target_index: 0,
782                results: K6Results {
783                    total_requests: 100,
784                    failed_requests: 5,
785                    avg_duration_ms: 100.0,
786                    p95_duration_ms: 200.0,
787                    p99_duration_ms: 300.0,
788                    ..Default::default()
789                },
790                output_dir: PathBuf::from("output1"),
791                success: true,
792                error: None,
793            },
794            TargetResult {
795                target_url: "http://api2.com".to_string(),
796                target_index: 1,
797                results: K6Results::default(),
798                output_dir: PathBuf::from("output2"),
799                success: false,
800                error: Some("Network error".to_string()),
801            },
802        ];
803
804        let metrics = AggregatedMetrics::from_results(&results);
805        // Only successful target should be counted
806        assert_eq!(metrics.total_requests, 100);
807        assert_eq!(metrics.total_failed_requests, 5);
808        assert_eq!(metrics.avg_duration_ms, 100.0);
809    }
810
811    #[test]
812    fn test_aggregated_metrics_empty_results() {
813        let results = vec![];
814        let metrics = AggregatedMetrics::from_results(&results);
815        assert_eq!(metrics.total_requests, 0);
816        assert_eq!(metrics.total_failed_requests, 0);
817        assert_eq!(metrics.avg_duration_ms, 0.0);
818        assert_eq!(metrics.error_rate, 0.0);
819    }
820
821    #[test]
822    fn test_aggregated_metrics_error_rate_calculation() {
823        let results = vec![TargetResult {
824            target_url: "http://api1.com".to_string(),
825            target_index: 0,
826            results: K6Results {
827                total_requests: 1000,
828                failed_requests: 50,
829                avg_duration_ms: 100.0,
830                p95_duration_ms: 200.0,
831                p99_duration_ms: 300.0,
832                ..Default::default()
833            },
834            output_dir: PathBuf::from("output1"),
835            success: true,
836            error: None,
837        }];
838
839        let metrics = AggregatedMetrics::from_results(&results);
840        assert_eq!(metrics.error_rate, 5.0); // 50/1000 * 100
841    }
842
843    #[test]
844    fn test_aggregated_metrics_p95_p99_calculation() {
845        let results = vec![
846            TargetResult {
847                target_url: "http://api1.com".to_string(),
848                target_index: 0,
849                results: K6Results {
850                    total_requests: 100,
851                    failed_requests: 0,
852                    avg_duration_ms: 100.0,
853                    p95_duration_ms: 150.0,
854                    p99_duration_ms: 200.0,
855                    ..Default::default()
856                },
857                output_dir: PathBuf::from("output1"),
858                success: true,
859                error: None,
860            },
861            TargetResult {
862                target_url: "http://api2.com".to_string(),
863                target_index: 1,
864                results: K6Results {
865                    total_requests: 100,
866                    failed_requests: 0,
867                    avg_duration_ms: 200.0,
868                    p95_duration_ms: 250.0,
869                    p99_duration_ms: 300.0,
870                    ..Default::default()
871                },
872                output_dir: PathBuf::from("output2"),
873                success: true,
874                error: None,
875            },
876            TargetResult {
877                target_url: "http://api3.com".to_string(),
878                target_index: 2,
879                results: K6Results {
880                    total_requests: 100,
881                    failed_requests: 0,
882                    avg_duration_ms: 300.0,
883                    p95_duration_ms: 350.0,
884                    p99_duration_ms: 400.0,
885                    ..Default::default()
886                },
887                output_dir: PathBuf::from("output3"),
888                success: true,
889                error: None,
890            },
891        ];
892
893        let metrics = AggregatedMetrics::from_results(&results);
894        // p95 should be the 95th percentile of [150, 250, 350] = index 2 = 350
895        // p99 should be the 99th percentile of [200, 300, 400] = index 2 = 400
896        assert_eq!(metrics.p95_duration_ms, 350.0);
897        assert_eq!(metrics.p99_duration_ms, 400.0);
898    }
899}