use chrono::prelude::*;
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::str::FromStr;
use std::time;
use crate::config::{timespan, GooseConfiguration};
use crate::util;
use crate::{AttackPhase, GooseAttack, GooseAttackRunState, GooseError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestPlan {
pub steps: Vec<(usize, usize)>,
pub current: usize,
}
impl Default for TestPlan {
fn default() -> Self {
Self::new()
}
}
lazy_static! {
static ref STEP: Regex =
Regex::new(r"^\s*([0-9]+)\s*,\s*([0-9hmsHMS]*)\s*$").unwrap();
}
impl TestPlan {
pub fn new() -> TestPlan {
TestPlan {
steps: Vec::new(),
current: 0,
}
}
pub(crate) fn build(configuration: &GooseConfiguration) -> Result<TestPlan, GooseError> {
if let Some(test_plan) = configuration.test_plan.as_ref() {
Ok(test_plan.clone())
} else {
let mut steps: Vec<(usize, usize)> = Vec::new();
let span = |option, value: &Option<String>| {
value
.as_deref()
.map_or(Ok(0), |value| timespan(option, value))
};
let increase_time = span("increase_time", &configuration.increase_time)?;
let run_time = span("run_time", &configuration.run_time)?;
let decrease_time = span("decrease_time", &configuration.decrease_time)?;
if let Some(users) = configuration.users {
if increase_time > 0 {
steps.push((users, increase_time * 1_000));
} else {
let increase_rate =
if let Some(increase_rate) = configuration.increase_rate.as_ref() {
util::parse_rate(Some(increase_rate.to_string()))
} else {
util::parse_rate(None)
};
let ms_increase_rate = 1.0 / increase_rate * 1_000.0;
let total_time = ms_increase_rate * users as f32;
steps.push((users, total_time as usize));
}
if run_time > 0 {
steps.push((users, run_time * 1_000));
}
if decrease_time > 0 {
steps.push((0, decrease_time * 1_000));
} else if let Some(decrease_rate) = configuration.decrease_rate.as_ref() {
let rate = util::parse_rate(Some(decrease_rate.to_string()));
let ms_decrease_rate = 1.0 / rate * 1_000.0;
let total_time = ms_decrease_rate * users as f32;
steps.push((0, total_time as usize));
} else if run_time > 0 {
steps.push((0, 0));
}
}
Ok(TestPlan { steps, current: 0 })
}
}
pub(crate) fn holds(&self) -> bool {
self.steps.windows(2).any(|pair| pair[0].0 == pair[1].0)
|| self.steps.last().is_some_and(|step| step.0 > 0)
}
pub fn total_users(&self) -> usize {
self.checked_total_users().unwrap_or(usize::MAX)
}
fn checked_total_users(&self) -> Option<usize> {
let mut previous: usize = 0;
self.steps
.iter()
.try_fold(0usize, |total_users, &(users, _)| {
let increase = users.saturating_sub(previous);
previous = users;
total_users.checked_add(increase)
})
}
pub(crate) fn parse(s: &str) -> Result<TestPlan, GooseError> {
let mut steps: Vec<(usize, usize)> = Vec::new();
for line in s.split(';') {
let step = STEP.captures(line).and_then(|cap| {
Some((
cap[1].parse::<usize>().ok()?,
util::parse_timespan(&cap[2])? * 1_000,
))
});
match step {
Some(step) => steps.push(step),
None => {
return Err(GooseError::InvalidOption {
option: "`configuration.test_plan`".to_string(),
value: line.to_string(),
detail: INVALID_STEP.to_string(),
})
}
}
}
let test_plan = TestPlan { steps, current: 0 };
match test_plan.checked_total_users() {
Some(total) if total > 0 => Ok(test_plan),
_ => Err(GooseError::InvalidOption {
option: "`configuration.test_plan`".to_string(),
value: s.to_string(),
detail: "`configuration.test_plan` must start at least one user, and no more than fit in a usize.".to_string(),
}),
}
}
}
impl FromStr for TestPlan {
type Err = GooseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
TestPlan::parse(s).inspect_err(|e| eprint!("{}", refusal(e)))
}
}
impl std::fmt::Display for TestPlan {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (index, (users, ms)) in self.steps.iter().enumerate() {
if index > 0 {
f.write_str(";")?;
}
write!(f, "{},{}", users, ms / 1_000)?;
}
Ok(())
}
}
fn refusal(e: &GooseError) -> String {
match e {
GooseError::InvalidOption { value, detail, .. } if detail == INVALID_STEP => format!(
"ERROR: invalid `configuration.test_plan` value: '{value}'\n \
Expected format: --test-plan \"{{users}},{{timespan}};{{users}},{{timespan}}\"\n \
{{users}} must be an integer, ie \"100\"\n \
{{timespan}} can be integer seconds or \"30s\", \"20m\", \"3h\", \"1h30m\", etc\n"
),
GooseError::InvalidOption { value, detail, .. } => {
format!("ERROR: invalid `configuration.test_plan` value: '{value}': {detail}\n")
}
e => format!("ERROR: {e}\n"),
}
}
const INVALID_STEP: &str = "invalid `configuration.test_plan` value.";
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum TestPlanStepAction {
Increasing,
Maintaining,
Decreasing,
Canceling,
Finished,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct TestPlanHistory {
pub action: TestPlanStepAction,
pub timestamp: DateTime<Utc>,
pub users: usize,
}
impl TestPlanHistory {
pub(crate) fn step(action: TestPlanStepAction, users: usize) -> TestPlanHistory {
TestPlanHistory {
action,
timestamp: Utc::now(),
users,
}
}
}
impl GooseAttack {
pub(crate) fn advance_test_plan(&mut self, goose_attack_run_state: &mut GooseAttackRunState) {
let now = time::Instant::now();
self.step_started = Some(now);
goose_attack_run_state.adjust_user_in_ms = 0;
let action = if self.test_plan.current == self.test_plan.steps.len() - 1 {
if self.test_plan.steps[self.test_plan.current].0 == 0 {
self.set_attack_phase(goose_attack_run_state, AttackPhase::Decrease);
None
}
else {
self.set_attack_phase(goose_attack_run_state, AttackPhase::Maintain);
Some(TestPlanStepAction::Maintaining)
}
} else if self.test_plan.current < self.test_plan.steps.len() {
match self.test_plan.steps[self.test_plan.current]
.0
.cmp(&self.test_plan.steps[self.test_plan.current + 1].0)
{
Ordering::Less => {
self.set_attack_phase(goose_attack_run_state, AttackPhase::Increase);
Some(TestPlanStepAction::Increasing)
}
Ordering::Greater => {
self.set_attack_phase(goose_attack_run_state, AttackPhase::Decrease);
Some(TestPlanStepAction::Decreasing)
}
Ordering::Equal => {
self.set_attack_phase(goose_attack_run_state, AttackPhase::Maintain);
Some(TestPlanStepAction::Maintaining)
}
}
} else {
unreachable!("Advanced 2 steps beyond the end of the TestPlan.")
};
if let Some(action) = action {
self.metrics.history.push(TestPlanHistory::step(
action,
self.test_plan.steps[self.test_plan.current].0,
));
}
self.test_plan.current += 1;
if let Some(shape) = self.run_shape.as_mut() {
shape.entered(self.test_plan.current, &mut self.test_plan.steps, now);
}
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn display_round_trips() {
for (plan, written) in [
("10,5s;10,30s;0,5s", "10,5;10,30;0,5"),
("10,1h30m;0,0", "10,5400;0,0"),
("100000,1s", "100000,1"),
(" 3 , 2m10s ;0,1h2m3s", "3,130;0,3723"),
("1,720h", "1,2592000"),
] {
let parsed = TestPlan::parse(plan).unwrap();
assert_eq!(parsed.to_string(), written, "{}", plan);
let again = TestPlan::parse(&parsed.to_string()).unwrap();
assert_eq!(again.steps, parsed.steps, "{}", plan);
assert_eq!(again.to_string(), written, "{}", plan);
}
}
#[test]
fn step_units_in_either_case() {
for (upper, lower) in [
("2,1M", "2,1m"),
("10,1H30M", "10,1h30m"),
("5,10S", "5,10s"),
("10,1h30M;0,5S", "10,1h30m;0,5s"),
] {
let parsed = TestPlan::from_str(upper).unwrap();
assert_eq!(
parsed.steps,
TestPlan::from_str(lower).unwrap().steps,
"{upper}"
);
let configured =
GooseConfiguration::parse_args_default(&["--test-plan", upper]).unwrap();
assert_eq!(configured.test_plan.unwrap().steps, parsed.steps, "{upper}");
}
}
#[test]
fn step_grammar_unchanged() {
for (plan, steps) in [
("10,", vec![(10, 0)]),
("10,90", vec![(10, 90_000)]),
("10,1h30m", vec![(10, 5_400_000)]),
("0,5s;10,20s", vec![(0, 5_000), (10, 20_000)]),
(" 3 , 2m10s ;0,1h2m3s", vec![(3, 130_000), (0, 3_723_000)]),
] {
assert_eq!(TestPlan::parse(plan).unwrap().steps, steps, "{plan}");
}
for plan in [
"2,15mins",
"2,5 junk",
"2,1m1h",
"2,h",
"2,1d",
"2,ms",
"2,s",
"2,1h1h",
"2,1h 30m",
"2,5\u{17f}",
"2,\u{661}h",
"x",
"2",
] {
assert!(TestPlan::parse(plan).is_err(), "{}", plan);
}
}
#[test]
fn refusal_names_reason() {
let reason = |plan: &str| refusal(&TestPlan::parse(plan).unwrap_err());
let no_users = "`configuration.test_plan` must start at least one user, and no more than fit in a usize.";
assert_eq!(
reason("0,1s"),
format!("ERROR: invalid `configuration.test_plan` value: '0,1s': {no_users}\n")
);
let overflow = "18446744073709551615,1s;0,1s;1,1s";
assert_eq!(
reason(overflow),
format!("ERROR: invalid `configuration.test_plan` value: '{overflow}': {no_users}\n")
);
assert!(reason("10,1s;x").starts_with(
"ERROR: invalid `configuration.test_plan` value: 'x'\n Expected format: --test-plan \"{users},{timespan};{users},{timespan}\"\n"
));
}
}