fundaia 0.5.0

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

use anyhow::{bail, Result};
use owo_colors::OwoColorize;

use crate::api::client::ApiClient;
use crate::api::models::Deployment;
use crate::render::logs::{render_line, LineFormat};
use crate::render::pipeline::{epoch_millis, format_millis, PipelineView};
use crate::render::theme;

/// Deploying, watched.
///
/// The whole reason this command is worth having over `curl`: it starts the
/// deployment and then *stays*, redrawing the six stages in place while a tail
/// of the build scrolls underneath. What you get is the shape of a pipeline —
/// which step it is on, how long each one took, and where it stopped — rather
/// than a status word you have to poll for by hand.
///
/// Two loops, deliberately not one. The pipeline is polled, because it is a
/// derived view the server recomputes and there is nothing to stream; the log
/// is streamed, because it is a firehose and polling it would either lag or
/// hammer. They run concurrently and write to the same terminal, which is why
/// the pipeline is redrawn below the log rather than above it.
pub async fn run(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    restart: bool,
    follow: bool,
) -> Result<()> {
    let project = client.find_project(project_reference).await?;
    let service = client.find_service(&project.id, service_reference).await?;

    let deployment = if restart {
        client.restart(&service.id).await?
    } else {
        client.deploy(&service.id).await?
    };

    println!();
    println!(
        "  {} {} {} despliegue #{}",
        theme::ARROW.style(theme::accent()),
        project.name.style(theme::muted()),
        service.name.style(theme::strong()),
        deployment.number.style(theme::strong()),
    );
    println!();

    if !follow {
        println!(
            "  {}",
            format!(
                "Sigue el progreso con: fundaia logs {} {}",
                project.slug, service.slug
            )
            .style(theme::muted()),
        );
        return Ok(());
    }

    watch(client, &deployment).await
}

/// Puts a previous deployment back, and watches that go out.
///
/// The image is reused rather than rebuilt, which is the whole point of a
/// rollback — so the pipeline it draws skips the checkout and the build, and
/// says so rather than showing two stages that never move.
pub async fn rollback(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    number: u32,
) -> Result<()> {
    let project = client.find_project(project_reference).await?;
    let service = client.find_service(&project.id, service_reference).await?;

    let deployments = client.deployments(&service.id).await?;
    let Some(target) = deployments
        .iter()
        .find(|deployment| deployment.number == number)
    else {
        bail!("`{}` has no deployment #{number}", service.name);
    };

    let started = client.rollback(&target.id).await?;

    println!();
    println!(
        "  {} Volviendo {} a #{}",
        theme::ARROW.style(theme::accent()),
        service.name.style(theme::strong()),
        number.style(theme::strong()),
    );
    println!();

    watch(client, &started).await
}

/// Follows a deployment to its end, or to the first Ctrl-C.
pub async fn watch(client: &ApiClient, deployment: &Deployment) -> Result<()> {
    let interactive = std::io::IsTerminal::is_terminal(&std::io::stdout());
    let mut view = PipelineView::new(interactive);

    let logs = tokio::spawn({
        // Cloned rather than borrowed: the task outlives this scope, and the
        // clone shares the connection pool so it costs nothing.
        let client = client.clone();
        let deployment_id = deployment.id.clone();
        async move { stream_logs(&client, &deployment_id).await }
    });

    let outcome = poll_pipeline(client, &deployment.id, &mut view).await;

    // The log task ends on its own when the server closes the stream; aborting
    // covers the case where the deployment failed before anything was written.
    logs.abort();

    outcome
}

async fn poll_pipeline(
    client: &ApiClient,
    deployment_id: &str,
    view: &mut PipelineView,
) -> Result<()> {
    loop {
        let detail = client.deployment(deployment_id).await?;

        if detail.pipeline.finished {
            view.finish(&detail.pipeline);
            return report(&detail.deployment, detail.pipeline.succeeded);
        }

        view.draw(&detail.pipeline);
        // A second is the granularity of the thing being watched: stages take
        // seconds to minutes, and polling faster only costs the server.
        tokio::time::sleep(Duration::from_secs(1)).await;
    }
}

fn report(deployment: &Deployment, succeeded: bool) -> Result<()> {
    let elapsed = deployment
        .finished_at
        .as_ref()
        .and_then(|finished| Some(epoch_millis(finished)? - epoch_millis(&deployment.created_at)?))
        .map(format_millis)
        .unwrap_or_else(|| "".to_owned());

    println!();

    if succeeded {
        println!(
            "  {} Despliegue #{} en marcha en {}",
            theme::CHECK.style(theme::success()),
            deployment.number,
            elapsed.style(theme::strong()),
        );
        println!();
        return Ok(());
    }

    println!(
        "  {} Despliegue #{} ha fallado tras {}",
        theme::CROSS.style(theme::danger()),
        deployment.number,
        elapsed,
    );
    println!();

    // A non-zero exit, so `fundaia deploy … && something` behaves.
    bail!(deployment
        .failure_message
        .clone()
        .unwrap_or_else(|| "the deployment failed".to_owned()))
}

/// The build's own output, under the pipeline.
async fn stream_logs(client: &ApiClient, deployment_id: &str) -> Result<()> {
    client
        .stream_log(deployment_id, 0, |line| {
            println!("  {}", render_line(&line, LineFormat::plain()));
        })
        .await
}