fundaia 0.55.0

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use anyhow::Result;
use owo_colors::OwoColorize;
use serde_json::{Map, Value};

use crate::api::client::ApiClient;
use crate::api::models::AlertRule;
use crate::commands::monitors::shell_word;
use crate::exit;
use crate::output::{self, Format};
use crate::render::sanitise;
use crate::render::table::{Cell, Table};
use crate::render::theme;
use crate::render::time;

pub enum AlertKind<'a> {
    Logs(&'a str),
    ErrorRate(f64),
    Latency(u32),
}

pub struct AlertDraft<'a> {
    pub project: &'a str,
    pub service: &'a str,
    pub kind: AlertKind<'a>,
    pub regex: bool,
    pub count: Option<u32>,
    pub minutes: Option<u32>,
    pub min_requests: Option<u32>,
    pub cooldown: Option<u32>,
    pub name: Option<&'a str>,
}

#[derive(Default)]
pub struct AlertChange<'a> {
    pub name: Option<&'a str>,
    pub pattern: Option<&'a str>,
    pub pattern_kind: Option<&'a str>,
    pub threshold: Option<f64>,
    pub minutes: Option<u32>,
    pub min_requests: Option<u32>,
    pub cooldown: Option<u32>,
}

pub async fn list(client: &ApiClient, project_reference: &str, format: Format) -> Result<()> {
    let project = client.find_project(project_reference).await?;
    let answer = client.alerts(&project.id).await?;

    if format.answered_in_json(&answer.raw) {
        return Ok(());
    }

    let mut table = Table::new(&["alerta", "estado", "servicio", "regla", "última vez"]);
    for alert in &answer.body.alerts {
        table.push(vec![
            Cell::styled(sanitise::plain(&alert.name), theme::strong()),
            state_cell(alert),
            Cell::plain(sanitise::plain(
                alert.service.name.as_deref().unwrap_or("—"),
            )),
            Cell::styled(sanitise::plain(&alert.description), theme::muted()),
            Cell::styled(last_fired(alert), theme::muted()),
        ]);
    }

    table.print_or(format_args!(
        "`{}` no tiene alertas. fundaia alerts add {} <servicio> --logs ERROR",
        project.name, project.slug
    ));
    Ok(())
}

pub async fn add(client: &ApiClient, draft: AlertDraft<'_>, format: Format) -> Result<()> {
    let (project, service) = client.locate(draft.project, draft.service).await?;
    let answer = client
        .create_alert(&project.id, &add_body(&draft, &service.id))
        .await?;

    if format.answered_in_json(&answer.raw) {
        return Ok(());
    }

    let alert = &answer.body.alert;
    println!(
        "  {} {} vigila {}: {}",
        theme::CHECK.style(theme::success()),
        sanitise::plain(&alert.name).style(theme::strong()),
        sanitise::plain(&service.name),
        sanitise::plain(&alert.description).style(theme::muted()),
    );
    Ok(())
}

pub async fn edit(
    client: &ApiClient,
    project_reference: &str,
    alert: &str,
    change: AlertChange<'_>,
    format: Format,
) -> Result<()> {
    let body = change_body(&change);
    if body.as_object().is_none_or(Map::is_empty) {
        return Err(exit::Refused::invalid(
            "nada que cambiar: usa --name, --pattern, --regex, --substring, --threshold, --minutes, --min-requests o --cooldown",
        )
        .into());
    }

    let project = client.find_project(project_reference).await?;
    let answer = client.update_alert(&project.id, alert, &body).await?;
    if format.answered_in_json(&answer.raw) {
        return Ok(());
    }

    let alert = &answer.body.alert;
    println!(
        "  {} {}: {}",
        theme::CHECK.style(theme::success()),
        sanitise::plain(&alert.name).style(theme::strong()),
        sanitise::plain(&alert.description).style(theme::muted()),
    );
    Ok(())
}

pub async fn switch(
    client: &ApiClient,
    project_reference: &str,
    alert: &str,
    enabled: bool,
    format: Format,
) -> Result<()> {
    let project = client.find_project(project_reference).await?;
    let answer = client
        .update_alert(
            &project.id,
            alert,
            &serde_json::json!({ "enabled": enabled }),
        )
        .await?;
    if format.answered_in_json(&answer.raw) {
        return Ok(());
    }

    println!(
        "  {} {} {}",
        theme::CHECK.style(theme::success()),
        sanitise::plain(&answer.body.alert.name).style(theme::strong()),
        if enabled {
            "vigila de nuevo"
        } else {
            "en pausa"
        }
        .style(theme::muted()),
    );
    Ok(())
}

pub async fn remove(
    client: &ApiClient,
    project_reference: &str,
    alert: &str,
    confirmation: Option<&str>,
    format: Format,
) -> Result<()> {
    let project = client.find_project(project_reference).await?;
    let found = client.alert(&project.id, alert).await?.body.alert;

    if confirmation != Some(found.name.as_str()) {
        return Err(exit::Refused::invalid(format!(
            "para borrar la alerta `{}` repite su nombre con --confirm {}",
            sanitise::plain(&found.name),
            shell_word(&found.name)
        ))
        .into());
    }

    let removal = client.delete_alert(&project.id, &found.id).await?;
    if format.is_json() {
        output::emit(&removal);
        return Ok(());
    }

    println!(
        "  {} {} borrada",
        theme::CHECK.style(theme::success()),
        sanitise::plain(&found.name).style(theme::strong()),
    );
    Ok(())
}

fn add_body(draft: &AlertDraft<'_>, service_environment_id: &str) -> Value {
    let mut body = Map::new();
    body.insert("serviceEnvironmentId".into(), service_environment_id.into());
    match draft.kind {
        AlertKind::Logs(pattern) => {
            body.insert("kind".into(), "log".into());
            body.insert("pattern".into(), pattern.into());
            body.insert(
                "patternKind".into(),
                if draft.regex { "regex" } else { "substring" }.into(),
            );
            if let Some(count) = draft.count {
                body.insert("threshold".into(), count.into());
            }
            if let Some(minutes) = draft.minutes {
                body.insert("windowMinutes".into(), minutes.into());
            }
        }
        AlertKind::ErrorRate(percent) => {
            body.insert("kind".into(), "error-rate".into());
            body.insert("threshold".into(), percent.into());
        }
        AlertKind::Latency(millis) => {
            body.insert("kind".into(), "latency".into());
            body.insert("threshold".into(), millis.into());
        }
    }
    if let Some(minimum) = draft.min_requests {
        body.insert("minimumRequests".into(), minimum.into());
    }
    if let Some(cooldown) = draft.cooldown {
        body.insert("cooldownMinutes".into(), cooldown.into());
    }
    if let Some(name) = draft.name {
        body.insert("name".into(), name.into());
    }
    Value::Object(body)
}

fn change_body(change: &AlertChange<'_>) -> Value {
    let mut body = Map::new();
    if let Some(name) = change.name {
        body.insert("name".into(), name.into());
    }
    if let Some(pattern) = change.pattern {
        body.insert("pattern".into(), pattern.into());
    }
    if let Some(kind) = change.pattern_kind {
        body.insert("patternKind".into(), kind.into());
    }
    if let Some(threshold) = change.threshold {
        body.insert("threshold".into(), threshold.into());
    }
    if let Some(minutes) = change.minutes {
        body.insert("windowMinutes".into(), minutes.into());
    }
    if let Some(minimum) = change.min_requests {
        body.insert("minimumRequests".into(), minimum.into());
    }
    if let Some(cooldown) = change.cooldown {
        body.insert("cooldownMinutes".into(), cooldown.into());
    }
    Value::Object(body)
}

fn state_cell(alert: &AlertRule) -> Cell {
    if !alert.enabled {
        return Cell::styled("en pausa", theme::muted());
    }
    if alert.state == "firing" {
        Cell::styled("disparada", theme::danger())
    } else {
        Cell::styled("tranquila", theme::success())
    }
}

fn last_fired(alert: &AlertRule) -> String {
    alert
        .fired_at
        .as_deref()
        .and_then(time::relative_time)
        .unwrap_or_else(|| "nunca".to_owned())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn draft(kind: AlertKind<'static>) -> AlertDraft<'static> {
        AlertDraft {
            project: "tienda",
            service: "api",
            kind,
            regex: false,
            count: None,
            minutes: None,
            min_requests: None,
            cooldown: None,
            name: None,
        }
    }

    #[test]
    fn it_should_send_a_log_alert_as_a_substring_unless_told() {
        assert_eq!(
            add_body(&draft(AlertKind::Logs("ERROR")), "sve_1")["patternKind"],
            "substring"
        );
    }

    #[test]
    fn it_should_send_a_regular_expression_when_asked() {
        let regex = AlertDraft {
            regex: true,
            ..draft(AlertKind::Logs("timeout \\d+"))
        };
        assert_eq!(add_body(&regex, "sve_1")["patternKind"], "regex");
    }

    #[test]
    fn it_should_send_the_error_rate_as_the_threshold() {
        assert_eq!(
            add_body(&draft(AlertKind::ErrorRate(2.5)), "sve_1")["threshold"],
            2.5
        );
    }

    #[test]
    fn it_should_send_a_latency_alert_with_its_kind() {
        assert_eq!(
            add_body(&draft(AlertKind::Latency(800)), "sve_1")["kind"],
            "latency"
        );
    }

    #[test]
    fn it_should_send_only_what_changes() {
        let change = AlertChange {
            cooldown: Some(10),
            ..AlertChange::default()
        };
        assert_eq!(
            change_body(&change),
            serde_json::json!({ "cooldownMinutes": 10 })
        );
    }
}