moosicbox_server_simulator 0.2.0

MoosicBox Server Simulator package
//! Health check client simulator for monitoring server status.
//!
//! This module provides a health checker that periodically verifies the server's
//! health status by making HTTP requests to the health endpoint and validating
//! the responses.

use std::sync::LazyLock;

use plan::{HealthCheckInteractionPlan, Interaction};
use serde_json::Value;
use simvar::{Sim, plan::InteractionPlan as _, switchy::time::simulator::step_multiplier};

pub mod plan;

use crate::{
    http::{headers_contains_in_order, http_request, parse_http_response},
    try_connect,
};

/// Starts the health checker client in the simulation.
///
/// The health checker periodically verifies the server's health status.
pub fn start(sim: &mut impl Sim) {
    let mut plan = HealthCheckInteractionPlan::new().with_gen_interactions(1000);

    sim.client("HealthCheck", async move {
        loop {
            while let Some(interaction) = plan.step() {
                perform_interaction(interaction).await?;
                tokio::time::sleep(std::time::Duration::from_secs(step_multiplier() * 60)).await;
            }

            plan.gen_interactions(1000);
        }
    });
}

async fn perform_interaction(
    interaction: &Interaction,
) -> Result<(), Box<dyn std::error::Error + Send>> {
    log::debug!("perform_interaction: interaction={interaction:?}");

    match interaction {
        Interaction::Sleep(duration) => {
            log::debug!("perform_interaction: sleeping for duration={duration:?}");
            tokio::time::sleep(*duration).await;
        }
        Interaction::HealthCheck(host) => {
            log::debug!("perform_interaction: checking health for host={host}");
            health_check(host).await?;
        }
    }

    Ok(())
}

async fn health_check(host: &str) -> Result<(), Box<dyn std::error::Error + Send>> {
    static TIMEOUT: LazyLock<u64> = LazyLock::new(|| 10 * step_multiplier());

    tokio::select! {
        resp = assert_health(host) => {
            resp?;
        }
        () = tokio::time::sleep(std::time::Duration::from_secs(*TIMEOUT)) => {
            return Err(Box::new(std::io::Error::new(
                std::io::ErrorKind::TimedOut,
                format!("Failed to get healthy response within {} seconds", *TIMEOUT)
            )) as Box<dyn std::error::Error + Send>);
        }
    }

    Ok(())
}

#[allow(clippy::cognitive_complexity)]
async fn assert_health(host: &str) -> Result<(), Box<dyn std::error::Error + Send>> {
    let response = loop {
        log::debug!("[Client] Connecting to server...");
        let mut stream = match try_connect(host, 1).await {
            Ok(stream) => stream,
            Err(e) => {
                log::error!("[Client] Failed to connect to server: {e:?}");
                tokio::time::sleep(std::time::Duration::from_millis(step_multiplier())).await;
                continue;
            }
        };
        log::debug!("[Client] Connected!");

        let resp = match http_request("GET", &mut stream, "/health").await {
            Ok(resp) => resp,
            Err(e) => {
                log::error!("failed to make http_request: {e:?}");
                continue;
            }
        };
        log::debug!("Received response={resp}");

        match parse_http_response(&resp) {
            Ok(resp) => break resp,
            Err(e) => {
                log::debug!("Received error response={e}");
            }
        }
    };

    moosicbox_assert::assert_or_panic!(
        response.status_code == 200,
        "expected successful 200 response, get {}",
        response.status_code
    );
    moosicbox_assert::assert_or_panic!(
        headers_contains_in_order(
            &[
                (
                    "access-control-allow-credentials".to_string(),
                    "true".to_string()
                ),
                ("connection".to_string(), "close".to_string()),
                ("content-length".to_string(), "66".to_string()),
                ("content-type".to_string(), "application/json".to_string()),
                (
                    "vary".to_string(),
                    "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
                        .to_string()
                ),
            ],
            &response.headers
        ),
        "unexpected headers in response: {:?}",
        response.headers
    );
    let json: Value = serde_json::from_str(&response.body).unwrap();
    moosicbox_assert::assert_or_panic!(json.is_object(), "expected json object response");
    moosicbox_assert::assert_or_panic!(
        json.get("healthy").and_then(Value::as_bool) == Some(true),
        "expected healthy response"
    );
    moosicbox_assert::assert_or_panic!(json.get("hash").is_some(), "expected git hash in response");
    moosicbox_assert::assert_or_panic!(
        json.get("hash").unwrap().as_str().unwrap().len() == 40,
        "expected git hash to be 40 chars"
    );

    Ok(())
}