fundaia 0.55.0

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

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

use crate::api::client::ApiClient;
use crate::api::models::EdgeAccessPage;
use crate::exit;
use crate::output::Format;
use crate::render::sanitise;
use crate::render::theme;

pub async fn maintenance(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    enabled: bool,
    message: Option<&str>,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, service_reference).await?;
    let answer = client
        .set_maintenance(&service.id, enabled, message)
        .await?;

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

    if answer.body.maintenance.enabled {
        println!(
            "  {} {} está en mantenimiento",
            theme::CHECK.style(theme::success()),
            sanitise::plain(&service.name).style(theme::strong()),
        );
        println!(
            "  {}",
            "Quien entre verá la página de mantenimiento; el servicio sigue en marcha. \
             `fundaia maintenance … --off` lo vuelve a servir."
                .style(theme::muted()),
        );
    } else {
        println!(
            "  {} {} vuelve a responder",
            theme::CHECK.style(theme::success()),
            sanitise::plain(&service.name).style(theme::strong()),
        );
    }
    Ok(())
}

pub async fn protect(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    username: &str,
    password: Option<&str>,
    format: Format,
) -> Result<()> {
    let password = match password {
        Some(given) => given.to_owned(),
        None => read_password(format)?,
    };
    let (_, service) = client.locate(project_reference, service_reference).await?;
    let answer = client
        .protect_service(&service.id, username, &password)
        .await?;

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

    println!(
        "  {} {} pide usuario y contraseña ({})",
        theme::CHECK.style(theme::success()),
        sanitise::plain(&service.name).style(theme::strong()),
        sanitise::plain(
            answer
                .body
                .protection
                .username
                .as_deref()
                .unwrap_or(username)
        )
        .style(theme::muted()),
    );
    Ok(())
}

pub async fn unprotect(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, service_reference).await?;
    let answer = client.unprotect_service(&service.id).await?;

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

    println!(
        "  {} {} ya no pide contraseña",
        theme::CHECK.style(theme::success()),
        sanitise::plain(&service.name).style(theme::strong()),
    );
    Ok(())
}

pub struct AllowRequest<'a> {
    pub entries: &'a [String],
    pub add: bool,
    pub clear: bool,
    pub list: bool,
}

pub async fn allow(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    request: AllowRequest<'_>,
    format: Format,
) -> Result<()> {
    let (_, service) = client.locate(project_reference, service_reference).await?;

    let answer = if request.list {
        client.edge_access(&service.id).await?
    } else if request.clear {
        client.clear_allowlist(&service.id).await?
    } else {
        let mut entries: Vec<String> = Vec::new();
        if request.add {
            let current = client.edge_access(&service.id).await?;
            entries.extend(
                current
                    .body
                    .ip_allowlist
                    .map(|list| list.entries)
                    .unwrap_or_default(),
            );
        }
        entries.extend(request.entries.iter().cloned());
        client.set_allowlist(&service.id, &entries).await?
    };

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

    let name = sanitise::plain(&service.name);
    match answer
        .body
        .ip_allowlist
        .as_ref()
        .filter(|list| list.enabled)
    {
        Some(list) => {
            println!(
                "  {} {} solo deja entrar a {}:",
                if request.list {
                    theme::ARROW.style(theme::accent()).to_string()
                } else {
                    theme::CHECK.style(theme::success()).to_string()
                },
                name.style(theme::strong()),
                if list.entries.len() == 1 {
                    "una dirección".to_owned()
                } else {
                    format!("{} direcciones o redes", list.entries.len())
                },
            );
            for entry in &list.entries {
                println!("    {}", sanitise::plain(entry));
            }
            println!(
                "  {}",
                "Las demás reciben un 403, también quien llega sin una dirección conocida."
                    .style(theme::muted()),
            );
        }
        None => println!(
            "  {} {} deja entrar a cualquiera: no tiene lista de direcciones IP",
            theme::CHECK.style(theme::success()),
            name.style(theme::strong()),
        ),
    }
    if request.list {
        print_rate_limit(&answer.body);
    }
    Ok(())
}

pub async fn rate_limit(
    client: &ApiClient,
    project_reference: &str,
    service_reference: &str,
    rate: &str,
    format: Format,
) -> Result<()> {
    let wanted = rate_of(rate)?;
    let (_, service) = client.locate(project_reference, service_reference).await?;
    let answer = match wanted {
        Some(per_minute) => client.set_rate_limit(&service.id, per_minute).await?,
        None => client.clear_rate_limit(&service.id).await?,
    };

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

    let name = sanitise::plain(&service.name);
    match limit_of(&answer.body) {
        Some(per_minute) => println!(
            "  {} {} admite {per_minute} peticiones por minuto de cada dirección; las demás reciben un 429",
            theme::CHECK.style(theme::success()),
            name.style(theme::strong()),
        ),
        None => println!(
            "  {} {} ya no limita las peticiones",
            theme::CHECK.style(theme::success()),
            name.style(theme::strong()),
        ),
    }
    Ok(())
}

fn print_rate_limit(edge: &EdgeAccessPage) {
    if let Some(per_minute) = limit_of(edge) {
        println!(
            "  {} {per_minute} peticiones por minuto y dirección",
            "límite".style(theme::muted()),
        );
    }
}

fn limit_of(edge: &EdgeAccessPage) -> Option<u32> {
    edge.rate_limit
        .as_ref()
        .filter(|limit| limit.enabled)
        .and_then(|limit| limit.per_minute)
}

pub fn rate_of(text: &str) -> Result<Option<u32>> {
    let written = text.trim().to_ascii_lowercase();
    if written == "off" {
        return Ok(None);
    }

    let number = written
        .strip_suffix("/min")
        .or_else(|| written.strip_suffix("/m"))
        .unwrap_or(&written)
        .trim();
    match number.parse::<u32>() {
        Ok(per_minute) if (1..=100_000).contains(&per_minute) => Ok(Some(per_minute)),
        _ => Err(exit::Refused::invalid(format!(
            "`{}` no es un límite: escribe cuántas peticiones por minuto (600/min, de 1 a 100000) u off",
            sanitise::plain(text)
        ))
        .into()),
    }
}

fn read_password(format: Format) -> Result<String> {
    let interactive = !format.is_json() && std::io::IsTerminal::is_terminal(&std::io::stdin());
    let password = if interactive {
        eprint!("  Contraseña (no se ve al escribir): ");
        rpassword::read_password().context("no se pudo leer la contraseña del teclado")?
    } else {
        let mut line = String::new();
        std::io::stdin()
            .lock()
            .read_line(&mut line)
            .context("no se pudo leer la contraseña de la entrada")?;
        line
    };

    let password = password.trim_end_matches(['\r', '\n']).to_owned();
    if password.is_empty() {
        bail!("sin contraseña no se protege nada: pásala con --password, FUNDAIA_SERVICE_PASSWORD o por la entrada");
    }
    Ok(password)
}

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

    #[test]
    fn it_should_read_a_limit_per_minute() {
        assert_eq!(rate_of("600/min").ok(), Some(Some(600)));
    }

    #[test]
    fn it_should_read_a_bare_number_as_per_minute() {
        assert_eq!(rate_of("120").ok(), Some(Some(120)));
    }

    #[test]
    fn it_should_read_off_as_no_limit() {
        assert_eq!(rate_of("OFF").ok(), Some(None));
    }

    #[test]
    fn it_should_refuse_a_limit_of_nothing() {
        assert!(rate_of("0/min").is_err());
    }

    #[test]
    fn it_should_refuse_a_limit_per_hour_as_a_validation_failure() {
        let refused = rate_of("600/h").expect_err("a rate per hour is not one per minute");
        assert_eq!(exit::classify(&refused).code(), 4);
    }
}