fundaia 0.53.0

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use std::time::Duration;

use anyhow::Result;
use owo_colors::OwoColorize;
use tokio::time::sleep;

use crate::api::client::ApiClient;
use crate::api::models::InfraEvent;
use crate::output::{self, Format};
use crate::render::table::short_sha;
use crate::render::{sanitise, theme};

pub async fn run(
    client: &ApiClient,
    project_reference: Option<&str>,
    service_reference: Option<&str>,
    format: Format,
) -> Result<()> {
    let project = match project_reference {
        Some(reference) => Some(client.find_project(reference).await?),
        None => None,
    };

    let service = match (&project, service_reference) {
        (Some(project), Some(reference)) => {
            Some(client.find_service(&project.id, reference).await?)
        }
        _ => None,
    };

    if !format.is_json() {
        let scope = match (&project, &service) {
            (Some(project), Some(service)) => format!("{} · {}", project.name, service.name),
            (Some(project), None) => project.name.clone(),
            _ => "todo".to_string(),
        };

        println!(
            "  {} escuchando {} · Ctrl-C para salir",
            theme::ARROW.style(theme::accent()),
            scope.style(theme::strong()),
        );
    }

    let project_id = project.map(|one| one.id);
    let service_id = service.map(|one| one.id);

    let mut consecutive_failures = 0_u32;

    loop {
        let outcome = client
            .stream_events(|event| {
                if !in_scope(&event, project_id.as_deref(), service_id.as_deref()) {
                    return;
                }
                if format.is_json() {
                    output::emit(&event);
                    return;
                }
                if let Some(line) = describe(&event) {
                    println!("{line}");
                }
            })
            .await;

        match outcome {
            Ok(()) => consecutive_failures = 0,
            Err(failure) => {
                consecutive_failures += 1;
                if consecutive_failures == NOISY_AFTER {
                    eprintln!(
                        "  {} sin conexión: {failure}. Reintentando…",
                        theme::CROSS.style(theme::warning())
                    );
                }
                if consecutive_failures >= GIVE_UP_AFTER {
                    return Err(failure);
                }
            }
        }

        sleep(backoff_for(consecutive_failures)).await;
    }
}

const NOISY_AFTER: u32 = 3;
const GIVE_UP_AFTER: u32 = 20;

fn backoff_for(failures: u32) -> Duration {
    if failures == 0 {
        return Duration::from_millis(250);
    }

    Duration::from_secs(u64::from(failures).pow(2).min(15))
}

fn in_scope(event: &InfraEvent, project_id: Option<&str>, service_id: Option<&str>) -> bool {
    if project_id.is_some_and(|wanted| event.project_id != wanted) {
        return false;
    }

    service_id.is_none_or(|wanted| event.service_environment_id.as_deref() == Some(wanted))
}

fn describe(event: &InfraEvent) -> Option<String> {
    match event.kind.as_str() {
        "deployment-status" => Some(describe_deployment(event)),
        "variable-changed" => Some(format!(
            "  {} {} {}",
            theme::PENDING.style(theme::muted()),
            sanitise::plain(event.key.as_deref().unwrap_or("una variable")).style(theme::strong()),
            match event.action.as_deref() {
                Some("removed") => "eliminada",
                Some("sealed") => "sellada",
                _ => "actualizada",
            },
        )),
        _ => None,
    }
}

fn describe_deployment(event: &InfraEvent) -> String {
    let name = sanitise::plain(event.service_name.as_deref().unwrap_or("un servicio"));
    let status = event.status.as_deref().unwrap_or("");
    let (mark, word) = mark_for(status);

    let symbol = mark.style(theme::state_style(status)).to_string();
    let subject = name.style(theme::strong()).to_string();

    match reason_for(event) {
        Some(because) => format!("  {symbol} {subject} {word} · {because}"),
        None => format!("  {symbol} {subject} {word}"),
    }
}

fn mark_for(status: &str) -> (&'static str, &str) {
    match status {
        "queued" => (theme::ARROW, "desplegando"),
        "building" => (theme::PENDING, "construyendo"),
        "deploying" => (theme::PENDING, "arrancando"),
        "active" => (theme::CHECK, "en marcha"),
        "failed" | "crashed" => (theme::CROSS, "ha fallado"),
        "stopped" => (theme::SKIP, "se ha dormido"),
        "cancelled" => (theme::SKIP, "cancelado"),
        "superseded" => (theme::SKIP, "reemplazado"),
        other => (theme::PENDING, other),
    }
}

fn reason_for(event: &InfraEvent) -> Option<String> {
    if event.status.as_deref() != Some("queued") {
        return None;
    }

    match event.trigger.as_deref() {
        Some("git-push") => Some(match (&event.commit_subject, &event.commit_sha) {
            (Some(subject), _) => format!("nuevo commit: {}", sanitise::plain(subject)),
            (None, Some(sha)) => format!("nuevo commit {}", short_sha(sha)),
            _ => "la rama se movió".to_string(),
        }),
        Some("variable-change") => Some("cambiaron las variables".to_string()),
        Some("created") => Some("servicio recién creado".to_string()),
        Some("image-update") => Some("imagen nueva en el registro".to_string()),
        Some("promotion") => Some("promovido desde otro entorno".to_string()),
        Some("hook") => Some("lo pidió un deploy hook".to_string()),
        Some("rollback") => Some(match &event.auto_rollback {
            Some(rollback) => format!(
                "Fundaia vuelve a la versión {} porque la {} se cayó a los {}",
                rollback.to,
                rollback.from,
                lifetime_words(rollback.after_seconds)
            ),
            None => "vuelta atrás".to_string(),
        }),
        _ => None,
    }
}

fn lifetime_words(seconds: u64) -> String {
    if seconds < 90 {
        format!("{seconds} s")
    } else {
        format!("{} min", (seconds + 30) / 60)
    }
}

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

    fn event(kind: &str) -> InfraEvent {
        InfraEvent {
            kind: kind.to_string(),
            project_id: "prj_1".to_string(),
            service_environment_id: Some("sve_1".to_string()),
            deployment_id: Some("dep_1".to_string()),
            status: None,
            state: None,
            trigger: None,
            number: None,
            service_name: Some("api".to_string()),
            commit_sha: None,
            commit_subject: None,
            key: None,
            action: None,
            auto_rollback: None,
        }
    }

    #[test]
    fn it_should_keep_an_event_from_the_project_being_watched() {
        assert!(in_scope(&event("deployment-status"), Some("prj_1"), None));
    }

    #[test]
    fn it_should_drop_an_event_from_another_project() {
        assert!(!in_scope(&event("deployment-status"), Some("prj_2"), None));
    }

    #[test]
    fn it_should_drop_an_event_from_another_service() {
        assert!(!in_scope(
            &event("deployment-status"),
            Some("prj_1"),
            Some("sve_2")
        ));
    }

    #[test]
    fn it_should_keep_everything_when_nothing_was_asked_for() {
        assert!(in_scope(&event("deployment-status"), None, None));
    }

    #[test]
    fn it_should_say_which_commit_started_a_deployment() {
        let mut one = event("deployment-status");
        one.status = Some("queued".to_string());
        one.trigger = Some("git-push".to_string());
        one.commit_subject = Some("fix the health check".to_string());

        assert_eq!(
            reason_for(&one).unwrap(),
            "nuevo commit: fix the health check"
        );
    }

    #[test]
    fn it_should_fall_back_to_the_short_sha_when_there_is_no_subject() {
        let mut one = event("deployment-status");
        one.status = Some("queued".to_string());
        one.trigger = Some("git-push".to_string());
        one.commit_sha = Some("abc1234567890".to_string());

        assert_eq!(reason_for(&one).unwrap(), "nuevo commit abc1234");
    }

    #[test]
    fn it_should_give_no_reason_for_a_deployment_somebody_started_by_hand() {
        let mut one = event("deployment-status");
        one.status = Some("queued".to_string());
        one.trigger = Some("manual".to_string());

        assert!(reason_for(&one).is_none());
    }

    #[test]
    fn it_should_name_the_variable_that_changed() {
        let mut one = event("variable-changed");
        one.key = Some("DATABASE_URL".to_string());
        one.action = Some("set".to_string());

        assert!(describe(&one).unwrap().contains("DATABASE_URL"));
    }

    #[test]
    fn it_should_print_nothing_for_a_kind_it_does_not_draw() {
        assert!(describe(&event("usage")).is_none());
    }

    #[test]
    fn it_should_come_back_quickly_after_a_clean_close() {
        assert_eq!(backoff_for(0), Duration::from_millis(250));
    }

    #[test]
    fn it_should_wait_longer_as_failures_repeat() {
        assert!(backoff_for(3) > backoff_for(1));
    }

    #[test]
    fn it_should_stop_backing_off_at_fifteen_seconds() {
        assert_eq!(backoff_for(50), Duration::from_secs(15));
    }

    #[test]
    fn it_should_say_why_fundaia_went_back_on_its_own() {
        let mut one = event("deployment-status");
        one.status = Some("queued".to_string());
        one.trigger = Some("rollback".to_string());
        one.auto_rollback = Some(crate::api::models::AutoRollbackNotice {
            from: 8,
            to: 7,
            after_seconds: 42,
        });
        assert_eq!(
            reason_for(&one).as_deref(),
            Some("Fundaia vuelve a la versión 7 porque la 8 se cayó a los 42 s")
        );
    }

    #[test]
    fn it_should_count_a_longer_life_in_minutes() {
        assert_eq!(lifetime_words(540), "9 min");
    }

    #[test]
    fn it_should_say_a_deploy_hook_asked_for_a_deployment() {
        let mut one = event("deployment-status");
        one.status = Some("queued".to_string());
        one.trigger = Some("hook".to_string());
        assert_eq!(reason_for(&one).as_deref(), Some("lo pidió un deploy hook"));
    }
}