1use 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#[derive(Debug, Clone)]
27pub struct TargetResult {
28 pub target_url: String,
30 pub target_index: usize,
32 pub results: K6Results,
34 pub output_dir: PathBuf,
36 pub success: bool,
38 pub error: Option<String>,
40}
41
42#[derive(Debug, Clone)]
44pub struct AggregatedResults {
45 pub target_results: Vec<TargetResult>,
47 pub total_targets: usize,
49 pub successful_targets: usize,
50 pub failed_targets: usize,
51 pub aggregated_metrics: AggregatedMetrics,
53}
54
55#[derive(Debug, Clone)]
57pub struct AggregatedMetrics {
58 pub total_requests: u64,
60 pub total_failed_requests: u64,
62 pub avg_duration_ms: f64,
64 pub p95_duration_ms: f64,
66 pub p99_duration_ms: f64,
68 pub error_rate: f64,
70 pub total_rps: f64,
72 pub avg_rps: f64,
74 pub total_vus_max: u32,
76 pub total_connections_opened: u64,
80 pub total_iterations_completed: u64,
82}
83
84impl AggregatedMetrics {
85 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
163fn aggregated_metrics_json(m: &AggregatedMetrics) -> serde_json::Value {
167 serde_json::json!({
168 "total_requests": m.total_requests,
169 "total_failed_requests": m.total_failed_requests,
170 "avg_duration_ms": m.avg_duration_ms,
171 "p95_duration_ms": m.p95_duration_ms,
172 "p99_duration_ms": m.p99_duration_ms,
173 "error_rate": m.error_rate,
174 "total_rps": m.total_rps,
175 "avg_rps": m.avg_rps,
176 "total_vus_max": m.total_vus_max,
177 "total_connections_opened": m.total_connections_opened,
178 "total_iterations_completed": m.total_iterations_completed,
179 })
180}
181
182fn target_results_json(results: &AggregatedResults) -> Vec<serde_json::Value> {
184 results
185 .target_results
186 .iter()
187 .map(|r| {
188 serde_json::json!({
189 "target_url": r.target_url,
190 "target_index": r.target_index,
191 "success": r.success,
192 "error": r.error,
193 "total_requests": r.results.total_requests,
194 "failed_requests": r.results.failed_requests,
195 "avg_duration_ms": r.results.avg_duration_ms,
196 "min_duration_ms": r.results.min_duration_ms,
197 "med_duration_ms": r.results.med_duration_ms,
198 "p90_duration_ms": r.results.p90_duration_ms,
199 "p95_duration_ms": r.results.p95_duration_ms,
200 "p99_duration_ms": r.results.p99_duration_ms,
201 "max_duration_ms": r.results.max_duration_ms,
202 "rps": r.results.rps,
203 "vus_max": r.results.vus_max,
204 "output_dir": r.output_dir.to_string_lossy(),
205 })
206 })
207 .collect()
208}
209
210pub(crate) fn targets_summary_json(
213 results: &AggregatedResults,
214 elapsed_secs: u64,
215) -> serde_json::Value {
216 serde_json::json!({
217 "total_elapsed_seconds": elapsed_secs,
218 "total_targets": results.total_targets,
219 "successful_targets": results.successful_targets,
220 "failed_targets": results.failed_targets,
221 "aggregated_metrics": aggregated_metrics_json(&results.aggregated_metrics),
222 "target_results": target_results_json(results),
223 })
224}
225
226pub(crate) fn targets_csv(results: &AggregatedResults) -> String {
229 let mut csv = String::from(
230 "target_url,success,requests,failed,rps,vus,min_ms,avg_ms,med_ms,p90_ms,p95_ms,p99_ms,max_ms,error\n",
231 );
232 for r in &results.target_results {
233 csv.push_str(&format!(
234 "{},{},{},{},{:.1},{},{:.1},{:.1},{:.1},{:.1},{:.1},{:.1},{:.1},{}\n",
235 r.target_url,
236 r.success,
237 r.results.total_requests,
238 r.results.failed_requests,
239 r.results.rps,
240 r.results.vus_max,
241 r.results.min_duration_ms,
242 r.results.avg_duration_ms,
243 r.results.med_duration_ms,
244 r.results.p90_duration_ms,
245 r.results.p95_duration_ms,
246 r.results.p99_duration_ms,
247 r.results.max_duration_ms,
248 r.error.as_deref().unwrap_or(""),
249 ));
250 }
251 csv
252}
253
254pub struct ParallelExecutor {
256 base_command: BenchCommand,
258 targets: Vec<TargetConfig>,
260 max_concurrency_override: Option<usize>,
263 base_output: PathBuf,
265}
266
267impl ParallelExecutor {
268 pub fn new(
270 base_command: BenchCommand,
271 targets: Vec<TargetConfig>,
272 max_concurrency: Option<usize>,
273 ) -> Self {
274 let base_output = base_command.output.clone();
275 Self {
276 base_command,
277 targets,
278 max_concurrency_override: max_concurrency,
279 base_output,
280 }
281 }
282
283 pub(crate) fn estimated_wall_clock(
288 n_targets: usize,
289 max_concurrency: usize,
290 duration_secs: u64,
291 ) -> (usize, u64) {
292 let conc = max_concurrency.max(1);
293 let batches = if n_targets == 0 {
294 0
295 } else {
296 n_targets.div_ceil(conc)
297 };
298 (batches, (batches as u64).saturating_mul(duration_secs))
299 }
300
301 pub(crate) fn format_wall_clock(secs: u64) -> String {
303 let hours = secs / 3600;
304 let mins = (secs % 3600) / 60;
305 let rem = secs % 60;
306 if hours > 0 {
307 format!("{hours}h{mins:02}m{rem:02}s")
308 } else if mins > 0 {
309 format!("{mins}m{rem:02}s")
310 } else {
311 format!("{rem}s")
312 }
313 }
314
315 pub async fn execute_all(&self) -> Result<AggregatedResults> {
317 let total_targets = self.targets.len();
318 TerminalReporter::print_progress(&format!(
319 "Starting parallel execution for {} targets",
320 total_targets
321 ));
322
323 if !K6Executor::is_k6_installed() {
325 TerminalReporter::print_error("k6 is not installed");
326 TerminalReporter::print_warning(
327 "Install k6 from: https://k6.io/docs/get-started/installation/",
328 );
329 return Err(BenchError::K6NotFound);
330 }
331
332 let spec_supplied =
337 !self.base_command.spec.is_empty() || self.base_command.spec_dir.is_some();
338 let verbatim = self.base_command.wafbench_verbatim;
339
340 let (templates, parser) = if verbatim {
341 let verbatim_templates = self.base_command.load_verbatim_templates()?;
342 if verbatim_templates.is_empty() {
343 return Err(BenchError::Other(
344 "--wafbench-verbatim was set but no traffic cases were loaded. Check \
345 --wafbench-dir points at a file, directory or glob containing cases with \
346 a `request.uri`."
347 .to_string(),
348 ));
349 }
350 TerminalReporter::print_success(&format!(
351 "Verbatim mode: {} request(s) will be sent exactly as written (spec endpoints not used)",
352 verbatim_templates.len()
353 ));
354 let parser = if spec_supplied {
355 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
356 let merged_spec = self.base_command.load_and_merge_specs().await?;
357 TerminalReporter::print_success("Specification(s) loaded (base path only)");
358 SpecParser::from_spec(merged_spec)
359 } else {
360 SpecParser::from_spec(OpenApiSpec {
361 spec: Default::default(),
362 file_path: None,
363 raw_document: None,
364 })
365 };
366 (verbatim_templates, parser)
367 } else {
368 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
369 let merged_spec = self.base_command.load_and_merge_specs().await?;
370 let parser = SpecParser::from_spec(merged_spec);
371 TerminalReporter::print_success("Specification(s) loaded");
372
373 let operations = if let Some(filter) = &self.base_command.operations {
374 parser.filter_operations(filter)?
375 } else {
376 parser.get_operations()
377 };
378
379 if operations.is_empty() {
380 return Err(BenchError::Other("No operations found in spec".to_string()));
381 }
382
383 TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
384
385 TerminalReporter::print_progress("Generating request templates...");
386 let templates: Vec<_> = operations
387 .iter()
388 .map(RequestGenerator::generate_template)
389 .collect::<Result<Vec<_>>>()?;
390 TerminalReporter::print_success("Request templates generated");
391 (templates, parser)
392 };
393
394 let mut per_target_data: HashMap<
399 PathBuf,
400 (Vec<crate::request_gen::RequestTemplate>, Option<String>),
401 > = HashMap::new();
402 if !verbatim {
403 let mut unique_specs: Vec<PathBuf> = Vec::new();
404 for t in &self.targets {
405 if let Some(spec_path) = &t.spec {
406 if !unique_specs.contains(spec_path) {
407 unique_specs.push(spec_path.clone());
408 }
409 }
410 }
411 for spec_path in &unique_specs {
412 TerminalReporter::print_progress(&format!(
413 "Loading per-target spec: {}",
414 spec_path.display()
415 ));
416 match SpecParser::from_file(spec_path).await {
417 Ok(target_parser) => {
418 let target_ops = if let Some(filter) = &self.base_command.operations {
419 match target_parser.filter_operations(filter) {
420 Ok(ops) => ops,
421 Err(e) => {
422 TerminalReporter::print_warning(&format!(
423 "Failed to filter operations from {}: {}. Using shared spec.",
424 spec_path.display(),
425 e
426 ));
427 continue;
428 }
429 }
430 } else {
431 target_parser.get_operations()
432 };
433 let target_templates: Vec<_> = match target_ops
434 .iter()
435 .map(RequestGenerator::generate_template)
436 .collect::<Result<Vec<_>>>()
437 {
438 Ok(t) => t,
439 Err(e) => {
440 TerminalReporter::print_warning(&format!(
441 "Failed to generate templates from {}: {}. Using shared spec.",
442 spec_path.display(),
443 e
444 ));
445 continue;
446 }
447 };
448 let target_base_path = if let Some(cli_bp) = &self.base_command.base_path {
449 if cli_bp.is_empty() {
450 None
451 } else {
452 Some(cli_bp.clone())
453 }
454 } else {
455 target_parser.get_base_path()
456 };
457 TerminalReporter::print_success(&format!(
458 "Loaded {} operations from {}",
459 target_templates.len(),
460 spec_path.display()
461 ));
462 per_target_data
463 .insert(spec_path.clone(), (target_templates, target_base_path));
464 }
465 Err(e) => {
466 TerminalReporter::print_warning(&format!(
467 "Failed to load per-target spec {}: {}. Targets using this spec will use the shared spec.",
468 spec_path.display(),
469 e
470 ));
471 }
472 }
473 }
474 }
475
476 let base_headers = self.base_command.parse_headers()?;
478
479 let base_path = self.resolve_base_path(&parser);
481 if let Some(ref bp) = base_path {
482 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
483 }
484
485 let scenario = LoadScenario::from_str(&self.base_command.scenario)
487 .map_err(BenchError::InvalidScenario)?;
488
489 let duration_secs_val = BenchCommand::parse_duration(&self.base_command.duration)?;
490
491 let (max_concurrency, conc_warn) = crate::k6_gen::resolve_max_concurrency(
494 self.max_concurrency_override,
495 templates.len(),
496 total_targets,
497 );
498 if let Some(msg) = conc_warn {
499 TerminalReporter::print_warning(&msg);
500 } else {
501 TerminalReporter::print_progress(&format!(
502 "Max concurrency: {} ({} target{})",
503 max_concurrency,
504 total_targets,
505 if total_targets == 1 { "" } else { "s" },
506 ));
507 }
508
509 let (per_op_metrics, per_op_warn) = crate::k6_gen::resolve_per_op_metrics(
511 self.base_command.per_op_metrics,
512 templates.len(),
513 duration_secs_val,
514 );
515 if let Some(msg) = per_op_warn {
516 TerminalReporter::print_warning(&msg);
517 }
518
519 let (batches, one_pass_wall_secs) =
523 Self::estimated_wall_clock(total_targets, max_concurrency, duration_secs_val);
524 TerminalReporter::print_progress(&format!(
525 "One pass: {batches} batch(es) × {duration_secs_val}s ≈ {} ({one_pass_wall_secs}s). --vus and --rps are per target, not shared.",
526 Self::format_wall_clock(one_pass_wall_secs),
527 ));
528
529 let repeat_until_secs = match &self.base_command.repeat_until {
532 Some(d) => Some(BenchCommand::parse_duration(d)?),
533 None => None,
534 };
535 if let Some(n) = self.base_command.rounds {
536 if n == 0 {
537 return Err(BenchError::Other(
538 "--rounds must be >= 1 (or omit it for a single pass / --repeat-until)".into(),
539 ));
540 }
541 }
542 let max_rounds = self.base_command.rounds.unwrap_or(if repeat_until_secs.is_some() {
543 u32::MAX
544 } else {
545 1
546 });
547 let looping = max_rounds > 1 || repeat_until_secs.is_some();
548 let keep_rounds = self.base_command.keep_rounds;
554 let no_k6_logs = self.base_command.no_k6_logs;
555 let scripts_base = looping.then(|| self.base_output.join("scripts"));
556 if let Some(keep) = keep_rounds {
557 if !looping {
558 TerminalReporter::print_warning(
559 "--keep-rounds only applies to campaign runs (--repeat-until / --rounds > 1); ignoring.",
560 );
561 } else {
562 TerminalReporter::print_progress(&format!(
563 "Round pruning: keeping newest {keep} round_* dir(s); per-round stats persist in campaign.jsonl + round-summaries/"
564 ));
565 }
566 }
567 if let Some(until) = repeat_until_secs {
568 TerminalReporter::print_progress(&format!(
569 "Campaign loop: will re-run all targets until wall clock reaches {} \
570 (or --rounds {}). Each k6 process only lives `--duration` ({duration_secs_val}s), \
571 so RSS resets between batches.",
572 Self::format_wall_clock(until),
573 self.base_command
574 .rounds
575 .map(|n| n.to_string())
576 .unwrap_or_else(|| "unlimited".into()),
577 ));
578 } else if max_rounds > 1 {
579 TerminalReporter::print_progress(&format!(
580 "Campaign loop: will re-run all targets for {max_rounds} round(s)."
581 ));
582 }
583
584 let security_testing_enabled_val = self.base_command.security_testing_enabled();
585
586 if crate::request_gen::should_force_k6_http1(verbatim, &templates, &base_headers) {
587 TerminalReporter::print_progress(
588 "Forcing HTTP/1.1 (GODEBUG=http2client=0): Connection headers are hop-by-hop and HTTP/2 rejects them. The header stays on the wire.",
589 );
590 }
591
592 let has_advanced_features = self.base_command.data_file.is_some()
594 || self.base_command.error_rate.is_some()
595 || self.base_command.security_testing_enabled()
596 || self.base_command.parallel_create.is_some();
597
598 let enhancement_code = if has_advanced_features {
599 let dummy_script = "export const options = {};";
600 let enhanced = self.base_command.generate_enhanced_script(dummy_script)?;
601 if let Some(pos) = enhanced.find("export const options") {
602 enhanced[..pos].to_string()
603 } else {
604 String::new()
605 }
606 } else {
607 String::new()
608 };
609
610 let campaign_start = std::time::Instant::now();
611 let mut last_results: Option<AggregatedResults> = None;
612 let mut round: u32 = 0;
613
614 while round < max_rounds {
615 if let Some(until) = repeat_until_secs {
617 if round > 0 && campaign_start.elapsed().as_secs() >= until {
618 TerminalReporter::print_progress(&format!(
619 "Reached --repeat-until wall clock ({}); stopping after {} round(s).",
620 Self::format_wall_clock(until),
621 round,
622 ));
623 break;
624 }
625 }
626
627 round += 1;
628 let round_output = if looping {
629 let dir = self.base_output.join(format!("round_{round}"));
630 let wall_note = repeat_until_secs
631 .map(|u| {
632 format!(
633 " (wall {} / {})",
634 Self::format_wall_clock(campaign_start.elapsed().as_secs()),
635 Self::format_wall_clock(u)
636 )
637 })
638 .unwrap_or_default();
639 TerminalReporter::print_progress(&format!(
640 "Round {round}{wall_note} — results under {}",
641 dir.display()
642 ));
643 dir
644 } else {
645 self.base_output.clone()
646 };
647
648 let semaphore = Arc::new(Semaphore::new(max_concurrency));
650 let multi_progress = MultiProgress::new();
651
652 let progress_bars: Vec<ProgressBar> = (0..total_targets)
654 .map(|i| {
655 let pb = multi_progress.add(ProgressBar::new(1));
656 pb.set_style(
657 ProgressStyle::default_bar()
658 .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} {msg}")
659 .unwrap(),
660 );
661 pb.set_message(format!("Target {}", i + 1));
662 pb
663 })
664 .collect();
665
666 let mut handles: Vec<JoinHandle<Result<TargetResult>>> = Vec::new();
668
669 for (index, target) in self.targets.iter().enumerate() {
670 let target = target.clone();
671 let duration = self.base_command.duration.clone();
673 let vus = self.base_command.vus;
674 let scenario_str = self.base_command.scenario.clone();
675 let operations = self.base_command.operations.clone();
676 let auth = self.base_command.auth.clone();
677 let headers = self.base_command.headers.clone();
678 let threshold_percentile = self.base_command.threshold_percentile.clone();
679 let threshold_ms = self.base_command.threshold_ms;
680 let max_error_rate = self.base_command.max_error_rate;
681 let abort_on_error = self.base_command.abort_on_error;
686 let abort_on_error_rate = self.base_command.abort_on_error_rate;
687 let verbose = self.base_command.verbose;
688 let skip_tls_verify = self.base_command.skip_tls_verify;
689 let chunked_request_bodies = self.base_command.chunked_request_bodies;
690 let target_rps = self.base_command.target_rps;
691 let no_keep_alive = self.base_command.no_keep_alive;
692 let local_ips = self.base_command.source_ips.join(",");
699 let dns_policy = self.base_command.dns_policy.clone().unwrap_or_default();
700 let geo_source_ips = self.base_command.geo_source_ips.clone();
701 let geo_source_headers = self.base_command.geo_source_headers.clone();
702
703 let (templates, base_path) = if verbatim {
707 (templates.clone(), base_path.clone())
708 } else if let Some(spec_path) = &target.spec {
709 if let Some((t, bp)) = per_target_data.get(spec_path) {
710 (t.clone(), bp.clone())
711 } else {
712 (templates.clone(), base_path.clone())
713 }
714 } else {
715 (templates.clone(), base_path.clone())
716 };
717
718 let base_headers = base_headers.clone();
719 let scenario = scenario.clone();
720 let duration_secs = duration_secs_val;
721 let base_output = round_output.clone();
722 let semaphore = semaphore.clone();
723 let progress_bar = progress_bars[index].clone();
724 let target_index = index;
725 let security_testing_enabled = security_testing_enabled_val;
726 let enhancement_code = enhancement_code.clone();
727 let script_dir =
730 scripts_base.as_ref().map(|d| d.join(format!("target_{}", index + 1)));
731
732 let handle = tokio::spawn(async move {
733 let _permit = semaphore.acquire().await.map_err(|e| {
735 BenchError::Other(format!("Failed to acquire semaphore: {}", e))
736 })?;
737
738 progress_bar.set_message(format!("Testing {}", target.url));
739
740 let result = Self::execute_single_target_internal(
742 &duration,
743 vus,
744 &scenario_str,
745 &operations,
746 &auth,
747 &headers,
748 &threshold_percentile,
749 threshold_ms,
750 max_error_rate,
751 abort_on_error,
752 abort_on_error_rate,
753 per_op_metrics,
754 verbose,
755 skip_tls_verify,
756 base_path.as_ref(),
757 &target,
758 target_index,
759 &templates,
760 &base_headers,
761 &scenario,
762 duration_secs,
763 &base_output,
764 security_testing_enabled,
765 chunked_request_bodies,
766 target_rps,
767 no_keep_alive,
768 &enhancement_code,
769 &local_ips,
770 &dns_policy,
771 &geo_source_ips,
772 &geo_source_headers,
773 verbatim,
774 no_k6_logs,
775 script_dir.as_deref(),
776 )
777 .await;
778
779 progress_bar.inc(1);
780 progress_bar.finish_with_message(format!("Completed {}", target.url));
781
782 result
783 });
784
785 handles.push(handle);
786 }
787
788 let mut target_results = Vec::new();
790 for (index, handle) in handles.into_iter().enumerate() {
791 match handle.await {
792 Ok(Ok(result)) => {
793 target_results.push(result);
794 }
795 Ok(Err(e)) => {
796 let target_url = self.targets[index].url.clone();
798 target_results.push(TargetResult {
799 target_url: target_url.clone(),
800 target_index: index,
801 results: K6Results::default(),
802 output_dir: round_output.join(format!("target_{}", index + 1)),
803 success: false,
804 error: Some(e.to_string()),
805 });
806 }
807 Err(e) => {
808 let target_url = self.targets[index].url.clone();
810 target_results.push(TargetResult {
811 target_url: target_url.clone(),
812 target_index: index,
813 results: K6Results::default(),
814 output_dir: round_output.join(format!("target_{}", index + 1)),
815 success: false,
816 error: Some(format!("Task join error: {}", e)),
817 });
818 }
819 }
820 }
821
822 target_results.sort_by_key(|r| r.target_index);
824
825 let aggregated_metrics = AggregatedMetrics::from_results(&target_results);
827
828 let successful_targets = target_results.iter().filter(|r| r.success).count();
829 let failed_targets = total_targets - successful_targets;
830
831 last_results = Some(AggregatedResults {
832 target_results,
833 total_targets,
834 successful_targets,
835 failed_targets,
836 aggregated_metrics,
837 });
838
839 if looping {
842 let agg = last_results.as_ref().expect("just assigned");
843 Self::write_round_artifacts(
844 round,
845 &round_output,
846 &self.base_output,
847 agg,
848 campaign_start.elapsed(),
849 )?;
850 if let Some(keep) = keep_rounds {
851 Self::prune_old_rounds(&self.base_output, keep);
852 }
853 TerminalReporter::print_success(&format!(
854 "Round {round} done: {}/{} targets ok, {} requests ({:.2}% err) — stats in {}",
855 agg.successful_targets,
856 agg.total_targets,
857 agg.aggregated_metrics.total_requests,
858 agg.aggregated_metrics.error_rate,
859 self.base_output.join("campaign.jsonl").display(),
860 ));
861 }
862 }
863
864 last_results.ok_or_else(|| {
865 BenchError::Other("No rounds executed (check --rounds / --repeat-until)".to_string())
866 })
867 }
868
869 fn write_round_artifacts(
874 round: u32,
875 round_output: &Path,
876 base_output: &Path,
877 results: &AggregatedResults,
878 campaign_elapsed: std::time::Duration,
879 ) -> Result<()> {
880 let summary = serde_json::json!({
881 "round": round,
882 "campaign_elapsed_seconds": campaign_elapsed.as_secs(),
883 "total_targets": results.total_targets,
884 "successful_targets": results.successful_targets,
885 "failed_targets": results.failed_targets,
886 "aggregated_metrics": aggregated_metrics_json(&results.aggregated_metrics),
887 "target_results": target_results_json(results),
888 });
889 let pretty =
890 serde_json::to_string_pretty(&summary).map_err(|e| BenchError::Other(e.to_string()))?;
891
892 std::fs::write(round_output.join("round_summary.json"), &pretty)?;
893 std::fs::write(round_output.join("all_targets.csv"), targets_csv(results))?;
894
895 let summaries_dir = base_output.join("round-summaries");
897 std::fs::create_dir_all(&summaries_dir)?;
898 std::fs::write(summaries_dir.join(format!("round_{round}.json")), &pretty)?;
899
900 let mut line =
903 serde_json::to_string(&summary).map_err(|e| BenchError::Other(e.to_string()))?;
904 line.push('\n');
905 use std::io::Write;
906 let mut f = std::fs::OpenOptions::new()
907 .create(true)
908 .append(true)
909 .open(base_output.join("campaign.jsonl"))?;
910 f.write_all(line.as_bytes())
911 .map_err(|e| BenchError::Other(format!("failed to append campaign.jsonl: {e}")))?;
912 Ok(())
913 }
914
915 pub(crate) fn prune_old_rounds(base_output: &Path, keep: u32) {
919 let mut round_dirs: Vec<(u32, PathBuf)> = match std::fs::read_dir(base_output) {
920 Ok(rd) => rd
921 .filter_map(|e| e.ok())
922 .filter_map(|e| {
923 let name = e.file_name().to_string_lossy().to_string();
924 let n = name.strip_prefix("round_")?.parse::<u32>().ok()?;
925 if e.file_type().ok()?.is_dir() {
926 Some((n, e.path()))
927 } else {
928 None
929 }
930 })
931 .collect(),
932 Err(_) => return,
933 };
934 round_dirs.sort_by_key(|(n, _)| *n);
935 let removable = round_dirs.len().saturating_sub(keep as usize);
936 let mut pruned = 0usize;
937 for (_, path) in round_dirs.into_iter().take(removable) {
938 if std::fs::remove_dir_all(&path).is_ok() {
939 pruned += 1;
940 }
941 }
942 if pruned > 0 {
943 TerminalReporter::print_progress(&format!(
944 "Pruned {pruned} old round dir(s) (keeping newest {keep})"
945 ));
946 }
947 }
948
949 fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
951 if let Some(cli_base_path) = &self.base_command.base_path {
953 if cli_base_path.is_empty() {
954 return None;
955 }
956 return Some(cli_base_path.clone());
957 }
958 parser.get_base_path()
960 }
961
962 #[allow(clippy::too_many_arguments)]
964 async fn execute_single_target_internal(
965 _duration: &str,
966 vus: u32,
967 _scenario_str: &str,
968 _operations: &Option<String>,
969 auth: &Option<String>,
970 _headers: &[String],
971 threshold_percentile: &str,
972 threshold_ms: u64,
973 max_error_rate: f64,
974 abort_on_error: bool,
975 abort_on_error_rate: f64,
976 per_op_metrics: bool,
977 verbose: bool,
978 skip_tls_verify: bool,
979 base_path: Option<&String>,
980 target: &TargetConfig,
981 target_index: usize,
982 templates: &[crate::request_gen::RequestTemplate],
983 base_headers: &HashMap<String, String>,
984 scenario: &LoadScenario,
985 duration_secs: u64,
986 base_output: &Path,
987 security_testing_enabled: bool,
988 chunked_request_bodies: bool,
989 target_rps: Option<u32>,
990 no_keep_alive: bool,
991 enhancement_code: &str,
992 local_ips: &str,
993 dns_policy: &str,
994 geo_source_ips: &[String],
995 geo_source_headers: &[String],
996 wafbench_verbatim: bool,
997 no_k6_logs: bool,
998 script_dir: Option<&Path>,
999 ) -> Result<TargetResult> {
1000 let mut custom_headers = base_headers.clone();
1002 if let Some(target_headers) = &target.headers {
1003 custom_headers.extend(target_headers.clone());
1004 }
1005
1006 let force_http1 = crate::request_gen::should_force_k6_http1(
1008 wafbench_verbatim,
1009 templates,
1010 &custom_headers,
1011 );
1012
1013 let auth_header = target.auth.as_ref().or(auth.as_ref()).cloned();
1015
1016 let k6_config = K6Config {
1018 target_url: target.url.clone(),
1019 base_path: base_path.cloned(),
1020 scenario: scenario.clone(),
1021 duration_secs,
1022 max_vus: vus,
1023 threshold_percentile: threshold_percentile.to_string(),
1024 threshold_ms,
1025 max_error_rate,
1026 auth_header,
1027 custom_headers,
1028 skip_tls_verify,
1029 security_testing_enabled,
1030 chunked_request_bodies,
1031 target_rps,
1032 no_keep_alive,
1033 geo_source_ips: geo_source_ips.to_vec(),
1034 geo_source_headers: geo_source_headers.to_vec(),
1035 };
1036
1037 let generator = K6ScriptGenerator::new(k6_config, templates.to_vec())
1039 .with_abort_valve(abort_on_error, abort_on_error_rate)
1040 .with_force_http1(force_http1)
1041 .with_per_op_metrics(per_op_metrics);
1042 let mut script = generator.generate()?;
1043
1044 if !enhancement_code.is_empty() {
1046 if let Some(pos) = script.find("export const options") {
1047 script.insert_str(pos, enhancement_code);
1048 }
1049 }
1050
1051 let validation_errors = K6ScriptGenerator::validate_script(&script);
1053 if !validation_errors.is_empty() {
1054 return Err(BenchError::Other(format!(
1055 "Script validation failed for target {}: {}",
1056 target.url,
1057 validation_errors.join(", ")
1058 )));
1059 }
1060
1061 let output_dir = base_output.join(format!("target_{}", target_index + 1));
1063 std::fs::create_dir_all(&output_dir)?;
1064
1065 let script_path = match script_dir {
1069 Some(d) => {
1070 std::fs::create_dir_all(d)?;
1071 d.join("k6-script.js")
1072 }
1073 None => output_dir.join("k6-script.js"),
1074 };
1075 std::fs::write(&script_path, script)?;
1076
1077 let api_port = 0; let executor = K6Executor::new()?
1102 .with_local_ips(local_ips.to_string())
1103 .with_dns_policy(dns_policy.to_string())
1104 .with_discard_response_bodies(true)
1105 .with_force_http1(force_http1)
1106 .with_capture_logs(!no_k6_logs);
1107 let results = executor
1108 .execute_with_port(&script_path, Some(&output_dir), verbose, Some(api_port))
1109 .await;
1110
1111 match results {
1112 Ok(k6_results) => Ok(TargetResult {
1113 target_url: target.url.clone(),
1114 target_index,
1115 results: k6_results,
1116 output_dir,
1117 success: true,
1118 error: None,
1119 }),
1120 Err(e) => Ok(TargetResult {
1121 target_url: target.url.clone(),
1122 target_index,
1123 results: K6Results::default(),
1124 output_dir,
1125 success: false,
1126 error: Some(e.to_string()),
1127 }),
1128 }
1129 }
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134 use super::*;
1135
1136 #[test]
1137 fn test_aggregated_metrics_from_results() {
1138 let results = vec![
1139 TargetResult {
1140 target_url: "http://api1.com".to_string(),
1141 target_index: 0,
1142 results: K6Results {
1143 total_requests: 100,
1144 failed_requests: 5,
1145 avg_duration_ms: 100.0,
1146 p95_duration_ms: 200.0,
1147 p99_duration_ms: 300.0,
1148 ..Default::default()
1149 },
1150 output_dir: PathBuf::from("output1"),
1151 success: true,
1152 error: None,
1153 },
1154 TargetResult {
1155 target_url: "http://api2.com".to_string(),
1156 target_index: 1,
1157 results: K6Results {
1158 total_requests: 200,
1159 failed_requests: 10,
1160 avg_duration_ms: 150.0,
1161 p95_duration_ms: 250.0,
1162 p99_duration_ms: 350.0,
1163 ..Default::default()
1164 },
1165 output_dir: PathBuf::from("output2"),
1166 success: true,
1167 error: None,
1168 },
1169 ];
1170
1171 let metrics = AggregatedMetrics::from_results(&results);
1172 assert_eq!(metrics.total_requests, 300);
1173 assert_eq!(metrics.total_failed_requests, 15);
1174 assert_eq!(metrics.avg_duration_ms, 125.0); }
1176
1177 #[test]
1178 fn test_aggregated_metrics_with_failed_targets() {
1179 let results = vec![
1180 TargetResult {
1181 target_url: "http://api1.com".to_string(),
1182 target_index: 0,
1183 results: K6Results {
1184 total_requests: 100,
1185 failed_requests: 5,
1186 avg_duration_ms: 100.0,
1187 p95_duration_ms: 200.0,
1188 p99_duration_ms: 300.0,
1189 ..Default::default()
1190 },
1191 output_dir: PathBuf::from("output1"),
1192 success: true,
1193 error: None,
1194 },
1195 TargetResult {
1196 target_url: "http://api2.com".to_string(),
1197 target_index: 1,
1198 results: K6Results::default(),
1199 output_dir: PathBuf::from("output2"),
1200 success: false,
1201 error: Some("Network error".to_string()),
1202 },
1203 ];
1204
1205 let metrics = AggregatedMetrics::from_results(&results);
1206 assert_eq!(metrics.total_requests, 100);
1208 assert_eq!(metrics.total_failed_requests, 5);
1209 assert_eq!(metrics.avg_duration_ms, 100.0);
1210 }
1211
1212 #[test]
1213 fn test_aggregated_metrics_empty_results() {
1214 let results = vec![];
1215 let metrics = AggregatedMetrics::from_results(&results);
1216 assert_eq!(metrics.total_requests, 0);
1217 assert_eq!(metrics.total_failed_requests, 0);
1218 assert_eq!(metrics.avg_duration_ms, 0.0);
1219 assert_eq!(metrics.error_rate, 0.0);
1220 }
1221
1222 #[test]
1223 fn test_aggregated_metrics_error_rate_calculation() {
1224 let results = vec![TargetResult {
1225 target_url: "http://api1.com".to_string(),
1226 target_index: 0,
1227 results: K6Results {
1228 total_requests: 1000,
1229 failed_requests: 50,
1230 avg_duration_ms: 100.0,
1231 p95_duration_ms: 200.0,
1232 p99_duration_ms: 300.0,
1233 ..Default::default()
1234 },
1235 output_dir: PathBuf::from("output1"),
1236 success: true,
1237 error: None,
1238 }];
1239
1240 let metrics = AggregatedMetrics::from_results(&results);
1241 assert_eq!(metrics.error_rate, 5.0); }
1243
1244 #[test]
1245 fn test_aggregated_metrics_p95_p99_calculation() {
1246 let results = vec![
1247 TargetResult {
1248 target_url: "http://api1.com".to_string(),
1249 target_index: 0,
1250 results: K6Results {
1251 total_requests: 100,
1252 failed_requests: 0,
1253 avg_duration_ms: 100.0,
1254 p95_duration_ms: 150.0,
1255 p99_duration_ms: 200.0,
1256 ..Default::default()
1257 },
1258 output_dir: PathBuf::from("output1"),
1259 success: true,
1260 error: None,
1261 },
1262 TargetResult {
1263 target_url: "http://api2.com".to_string(),
1264 target_index: 1,
1265 results: K6Results {
1266 total_requests: 100,
1267 failed_requests: 0,
1268 avg_duration_ms: 200.0,
1269 p95_duration_ms: 250.0,
1270 p99_duration_ms: 300.0,
1271 ..Default::default()
1272 },
1273 output_dir: PathBuf::from("output2"),
1274 success: true,
1275 error: None,
1276 },
1277 TargetResult {
1278 target_url: "http://api3.com".to_string(),
1279 target_index: 2,
1280 results: K6Results {
1281 total_requests: 100,
1282 failed_requests: 0,
1283 avg_duration_ms: 300.0,
1284 p95_duration_ms: 350.0,
1285 p99_duration_ms: 400.0,
1286 ..Default::default()
1287 },
1288 output_dir: PathBuf::from("output3"),
1289 success: true,
1290 error: None,
1291 },
1292 ];
1293
1294 let metrics = AggregatedMetrics::from_results(&results);
1295 assert_eq!(metrics.p95_duration_ms, 350.0);
1298 assert_eq!(metrics.p99_duration_ms, 400.0);
1299 }
1300
1301 #[test]
1302 fn estimated_wall_clock_is_batches_times_duration() {
1303 let (batches, wall) = ParallelExecutor::estimated_wall_clock(64, 10, 300);
1305 assert_eq!(batches, 7);
1306 assert_eq!(wall, 2100);
1307 assert_eq!(ParallelExecutor::format_wall_clock(2100), "35m00s");
1308 assert_eq!(ParallelExecutor::estimated_wall_clock(10, 10, 300), (1, 300));
1309 assert_eq!(ParallelExecutor::estimated_wall_clock(0, 10, 300), (0, 0));
1310 assert_eq!(ParallelExecutor::format_wall_clock(45), "45s");
1311 assert_eq!(ParallelExecutor::format_wall_clock(3661), "1h01m01s");
1312 }
1313
1314 #[test]
1315 fn parallel_k6_spawn_sets_force_http1() {
1316 let src = include_str!("parallel_executor.rs");
1317 assert!(
1318 src.contains("with_force_http1(force_http1)"),
1319 "multi-target k6 spawn must pass GODEBUG=http2client=0 when Connection headers are present"
1320 );
1321 assert!(
1322 src.contains("One pass:") || src.contains("Estimated wall clock"),
1323 "multi-target start must print ceil(targets/concurrency)*duration"
1324 );
1325 assert!(
1326 src.contains("repeat_until") && src.contains("Campaign loop"),
1327 "multi-target must support --repeat-until campaign looping (#79)"
1328 );
1329 assert!(
1330 src.contains("with_per_op_metrics(per_op_metrics)"),
1331 "multi-target k6 spawn must apply Round-65 per-op metrics collapse (#79)"
1332 );
1333 assert!(
1334 src.contains("resolve_max_concurrency"),
1335 "multi-target must auto-cap concurrency for huge specs (#79)"
1336 );
1337 assert!(
1338 src.contains("with_capture_logs(!no_k6_logs)"),
1339 "multi-target k6 spawn must honour --no-k6-logs (#79)"
1340 );
1341 }
1342
1343 fn sample_aggregated_results() -> AggregatedResults {
1344 let target_results = vec![
1345 TargetResult {
1346 target_url: "http://api1.com".to_string(),
1347 target_index: 0,
1348 results: K6Results {
1349 total_requests: 100,
1350 failed_requests: 5,
1351 avg_duration_ms: 100.0,
1352 p95_duration_ms: 200.0,
1353 p99_duration_ms: 300.0,
1354 ..Default::default()
1355 },
1356 output_dir: PathBuf::from("output1"),
1357 success: true,
1358 error: None,
1359 },
1360 TargetResult {
1361 target_url: "http://api2.com".to_string(),
1362 target_index: 1,
1363 results: K6Results::default(),
1364 output_dir: PathBuf::from("output2"),
1365 success: false,
1366 error: Some("boom".to_string()),
1367 },
1368 ];
1369 AggregatedResults {
1370 aggregated_metrics: AggregatedMetrics::from_results(&target_results),
1371 total_targets: 2,
1372 successful_targets: 1,
1373 failed_targets: 1,
1374 target_results,
1375 }
1376 }
1377
1378 #[test]
1382 fn write_round_artifacts_writes_all_surfaces() {
1383 let base = tempfile::tempdir().unwrap();
1384 let round_dir = base.path().join("round_3");
1385 std::fs::create_dir_all(&round_dir).unwrap();
1386 let results = sample_aggregated_results();
1387
1388 ParallelExecutor::write_round_artifacts(
1389 3,
1390 &round_dir,
1391 base.path(),
1392 &results,
1393 std::time::Duration::from_secs(42),
1394 )
1395 .unwrap();
1396 ParallelExecutor::write_round_artifacts(
1397 4,
1398 &round_dir,
1399 base.path(),
1400 &results,
1401 std::time::Duration::from_secs(84),
1402 )
1403 .unwrap();
1404
1405 let summary_raw = std::fs::read_to_string(round_dir.join("round_summary.json")).unwrap();
1406 let summary: serde_json::Value = serde_json::from_str(&summary_raw).unwrap();
1407 assert_eq!(summary["round"], 4);
1408 assert_eq!(summary["total_targets"], 2);
1409 assert_eq!(summary["successful_targets"], 1);
1410 assert_eq!(summary["aggregated_metrics"]["total_requests"], 100);
1411
1412 assert!(base.path().join("round-summaries/round_3.json").exists());
1413 assert!(base.path().join("round-summaries/round_4.json").exists());
1414
1415 let csv = std::fs::read_to_string(round_dir.join("all_targets.csv")).unwrap();
1416 assert!(csv.contains("http://api1.com"));
1417 assert!(csv.contains("boom"));
1418
1419 let jsonl = std::fs::read_to_string(base.path().join("campaign.jsonl")).unwrap();
1421 let lines: Vec<&str> = jsonl.lines().collect();
1422 assert_eq!(lines.len(), 2);
1423 let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
1424 assert_eq!(first["round"], 3);
1425 assert_eq!(first["campaign_elapsed_seconds"], 42);
1426 let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
1427 assert_eq!(second["round"], 4);
1428 }
1429
1430 #[test]
1433 fn prune_old_rounds_keeps_newest_and_stats() {
1434 let base = tempfile::tempdir().unwrap();
1435 for n in 1..=5 {
1436 std::fs::create_dir_all(base.path().join(format!("round_{n}"))).unwrap();
1437 }
1438 std::fs::create_dir_all(base.path().join("round-summaries")).unwrap();
1440 std::fs::write(base.path().join("campaign.jsonl"), "{}\n").unwrap();
1441 std::fs::create_dir_all(base.path().join("scripts")).unwrap();
1442 std::fs::create_dir_all(base.path().join("target_1")).unwrap();
1443 std::fs::write(base.path().join("round_9.txt"), "x").unwrap();
1445
1446 ParallelExecutor::prune_old_rounds(base.path(), 2);
1447
1448 for n in 1..=3 {
1449 assert!(!base.path().join(format!("round_{n}")).exists(), "round_{n} pruned");
1450 }
1451 assert!(base.path().join("round_4").exists());
1452 assert!(base.path().join("round_5").exists());
1453 assert!(base.path().join("round-summaries").exists());
1454 assert!(base.path().join("campaign.jsonl").exists());
1455 assert!(base.path().join("scripts").exists());
1456 assert!(base.path().join("target_1").exists());
1457 assert!(base.path().join("round_9.txt").exists());
1458 }
1459
1460 #[test]
1462 fn prune_old_rounds_zero_keeps_stats_only() {
1463 let base = tempfile::tempdir().unwrap();
1464 std::fs::create_dir_all(base.path().join("round_1")).unwrap();
1465 std::fs::write(base.path().join("campaign.jsonl"), "{}\n").unwrap();
1466
1467 ParallelExecutor::prune_old_rounds(base.path(), 0);
1468
1469 assert!(!base.path().join("round_1").exists());
1470 assert!(base.path().join("campaign.jsonl").exists());
1471 }
1472}