fundaia 0.7.2

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::render::theme;

/// What the platform is doing, as it does it.
///
/// The terminal half of the notices the web interface shows. A push to a
/// followed branch has always deployed on its own; what nobody had was a way to
/// watch it happen without keeping a browser tab open, which is the wrong tool
/// for somebody who is already in a shell waiting for a build.
///
/// Scoped by name like everything else here. Unscoped it is every project this
/// token can reach, which is the useful default for one person with one
/// instance and far too much for a shared one.
pub async fn run(
    client: &ApiClient,
    project_reference: Option<&str>,
    service_reference: Option<&str>,
) -> 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,
    };

    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);

    /*
     * Reconnects rather than ending, which is the whole difference between a
     * watch and a request.
     *
     * A stream ends for reasons that have nothing to do with the watcher: the
     * platform deploys itself and restarts, a laptop's network changes, a proxy
     * closes an idle connection. The browser's `EventSource` reconnects on its
     * own and nobody notices; a terminal that exited on the first of those
     * would be a command you had to keep restarting by hand.
     *
     * Quietly, and with a pause. A server that is down is a server every retry
     * finds down, and a tight loop against it is a denial of service written by
     * accident. Only a failure that repeats is worth printing.
     */
    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 let Some(line) = describe(&event) {
                    println!("{line}");
                }
            })
            .await;

        match outcome {
            // A clean end is the socket closing, not an error: say nothing.
            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;
    }
}

/// Quiet for the first few, so a deploy's restart passes unremarked.
const NOISY_AFTER: u32 = 3;
/// Long enough to outlast a deploy and a nap; short of waiting for ever.
const GIVE_UP_AFTER: u32 = 20;

/// Backs off as failures repeat, and stays out of the way when they do not.
///
/// A quarter of a second after a clean close, because that is a deploy's
/// restart or a proxy tidying up and the platform is about to be back. After a
/// real failure it climbs to fifteen seconds and stops there: long enough not
/// to hammer a server that is down, short enough that nobody notices the gap
/// once it recovers.
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;
    }

    // An event that belongs to no service — the envelope allows it — is out of
    // scope the moment a service was asked for.
    service_id.is_none_or(|wanted| event.service_environment_id.as_deref() == Some(wanted))
}

/// One line, or nothing when there is nothing worth a line.
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()),
            event
                .key
                .as_deref()
                .unwrap_or("una variable")
                .style(theme::strong()),
            match event.action.as_deref() {
                Some("removed") => "eliminada",
                Some("sealed") => "sellada",
                _ => "actualizada",
            },
        )),
        // The state of a service follows from its deployment, and printing both
        // is the same news twice a second apart.
        _ => None,
    }
}

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

    // Coloured by the same table the rest of the tool uses, so a status means
    // the same thing here as it does in `status` and in `history`.
    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}"),
    }
}

/// The symbol and the verb for a deployment status.
///
/// Its own words rather than `state_word`: that one names what a *service* is
/// ("dormido"), and these name what just happened to it ("se ha dormido"). An
/// unknown status is passed through rather than replaced, so a newer server
/// saying something this build has never heard of still prints it.
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" | "superseded" => (theme::SKIP, "reemplazado"),
        other => (theme::PENDING, other),
    }
}

/// Why this deployment exists, when the answer is not "somebody pressed deploy".
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: {subject}"),
            (None, Some(sha)) => format!("nuevo commit {}", &sha[..sha.len().min(7)]),
            _ => "la rama se movió".to_string(),
        }),
        Some("variable-change") => Some("cambiaron las variables".to_string()),
        Some("rollback") => Some("vuelta atrás".to_string()),
        _ => None,
    }
}

#[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,
        }
    }

    #[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));
    }

    /// Otherwise a server that stays down is polled harder the longer it is down.
    #[test]
    fn it_should_stop_backing_off_at_fifteen_seconds() {
        assert_eq!(backoff_for(50), Duration::from_secs(15));
    }
}