cufflink-cli 0.20.0

CLI for the Cufflink CRUD microservice platform — deploy, init, and manage services
use crate::config::CliConfig;
use reqwest::Method;
use serde_json::{json, Value};

/// The id of the service `name` in the tenant's listing `body`.
fn listed(body: &Value, name: &str) -> eyre::Result<String> {
    body["services"]
        .as_array()
        .and_then(|services| services.iter().find(|s| s["name"].as_str() == Some(name)))
        .ok_or_else(|| eyre::eyre!("Service '{}' not found", name))?["id"]
        .as_str()
        .map(str::to_string)
        .ok_or_else(|| eyre::eyre!("Service has no ID"))
}

/// The request deleting the service `id`: even while a deployed service
/// calls one of its extensions when `force`.
fn delete_request(api_url: &str, id: &str, force: bool) -> (Method, String) {
    let query = if force { "?force=true" } else { "" };
    (
        Method::DELETE,
        format!("{api_url}/api/services/{id}{query}"),
    )
}

/// The request setting the service `id`'s status to `status`.
fn status_request(api_url: &str, id: &str, status: &str) -> (Method, String, Value) {
    (
        Method::PATCH,
        format!("{api_url}/api/services/{id}/status"),
        json!({ "status": status }),
    )
}

/// The id of the service `name` of the tenant `config` names.
async fn find(config: &CliConfig, name: &str) -> eyre::Result<String> {
    if let Some(ref env_name) = config.env_name {
        println!("Environment: {}", env_name);
    }
    let resp = config
        .auth_request(
            &config.http_client(),
            Method::GET,
            &format!("{}/api/services", config.api_url),
        )
        .send()
        .await?;
    if !resp.status().is_success() {
        eyre::bail!("Failed to list services: {}", resp.status());
    }
    listed(&resp.json().await?, name)
}

pub async fn set_status(name: &str, status: &str, env: Option<&str>) -> eyre::Result<()> {
    let config = CliConfig::load_with_env(env)?;
    let id = find(&config, name).await?;
    let (method, url, body) = status_request(&config.api_url, &id, status);
    let resp = config
        .auth_request(&config.http_client(), method, &url)
        .json(&body)
        .send()
        .await?;

    if resp.status().is_success() {
        let action = if status == "active" {
            "enabled"
        } else {
            "disabled"
        };
        println!("Service '{}' {}.", name, action);
    } else {
        let status_code = resp.status();
        let body = resp.text().await.unwrap_or_default();
        eyre::bail!(
            "Failed to update service status ({}): {}",
            status_code,
            body
        );
    }

    Ok(())
}

pub async fn delete(name: &str, yes: bool, force: bool, env: Option<&str>) -> eyre::Result<()> {
    let config = CliConfig::load_with_env(env)?;
    let id = find(&config, name).await?;

    if !yes {
        println!(
            "This will permanently delete service '{}' and all its configs, deployments, and data.",
            name
        );
        print!("Are you sure? [y/N] ");
        use std::io::Write;
        std::io::stdout().flush()?;
        let mut input = String::new();
        std::io::stdin().read_line(&mut input)?;
        if !input.trim().eq_ignore_ascii_case("y") {
            println!("Aborted.");
            return Ok(());
        }
    }

    let (method, url) = delete_request(&config.api_url, &id, force);
    let resp = config
        .auth_request(&config.http_client(), method, &url)
        .send()
        .await?;

    if resp.status().is_success() {
        println!("Service '{}' deleted.", name);
    } else {
        let status = resp.status();
        let body = resp.text().await.unwrap_or_default();
        eyre::bail!("Failed to delete service ({}): {}", status, body);
    }

    Ok(())
}

/// A service's `required_platform_features`, comma-separated (`-` for none).
fn required_features(service: &serde_json::Value) -> String {
    let features: Vec<&str> = service["required_platform_features"]
        .as_array()
        .map(|features| features.iter().filter_map(|f| f.as_str()).collect())
        .unwrap_or_default();
    if features.is_empty() {
        "-".to_string()
    } else {
        features.join(",")
    }
}

/// The tenant's services; with `features`, also what each requires of the
/// platform (so a platform rollback can be checked against them).
pub async fn run(features: bool, env: Option<&str>) -> eyre::Result<()> {
    let config = CliConfig::load_with_env(env)?;

    if let Some(ref name) = config.env_name {
        println!("Environment: {}", name);
    }

    let client = config.http_client();

    let resp = config
        .auth_request(
            &client,
            reqwest::Method::GET,
            &format!("{}/api/services", config.api_url),
        )
        .send()
        .await?;

    if !resp.status().is_success() {
        eyre::bail!("Failed to list services: {}", resp.status());
    }

    let body: serde_json::Value = resp.json().await?;
    let services = body["services"].as_array();

    match services {
        Some(services) if !services.is_empty() => {
            use comfy_table::{presets::NOTHING, Table, TableComponent};

            let mut table = Table::new();
            table.load_preset(NOTHING);
            table.set_style(TableComponent::HeaderLines, '-');
            table.set_style(TableComponent::MiddleHeaderIntersections, ' ');
            let header = ["NAME", "VERSION", "MODE", "STATUS", "TENANT", "COMMIT"];
            table.set_header(
                header
                    .into_iter()
                    .chain(features.then_some("FEATURES"))
                    .collect::<Vec<_>>(),
            );

            for svc in services {
                let commit = svc["commit_hash"]
                    .as_str()
                    .map(|h| if h.len() > 7 { &h[..7] } else { h })
                    .unwrap_or("-");
                let row = [
                    svc["name"].as_str().unwrap_or("?").to_string(),
                    format!("v{}", svc["current_version"]),
                    svc["mode"].as_str().unwrap_or("?").to_string(),
                    svc["status"].as_str().unwrap_or("?").to_string(),
                    svc["tenant_slug"].as_str().unwrap_or("?").to_string(),
                    commit.to_string(),
                ];
                table.add_row(
                    row.into_iter()
                        .chain(features.then(|| required_features(svc)))
                        .collect::<Vec<_>>(),
                );
            }

            println!("{table}");
            println!("\n{} service(s)", services.len());
        }
        _ => {
            println!("No services deployed yet.");
            println!("Use `cufflink deploy` to deploy your first service.");
        }
    }

    Ok(())
}

#[cfg(test)]
mod feature_tests {
    use super::required_features;
    use serde_json::json;

    #[test]
    fn required_features_are_listed_or_dashed() {
        assert_eq!(
            required_features(
                &json!({"required_platform_features": ["route_posture", "scoped_permissions"]})
            ),
            "route_posture,scoped_permissions"
        );
        assert_eq!(
            required_features(&json!({"required_platform_features": []})),
            "-"
        );
        assert_eq!(required_features(&json!({"name": "old"})), "-");
    }
}

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

    fn listing() -> Value {
        json!({"services": [
            {"id": "11", "name": "reports", "mode": "wasm"},
            {"id": "22", "name": "ext-google", "mode": "native"},
        ]})
    }

    #[test]
    fn the_listing_names_a_service_by_its_id_whatever_its_mode() {
        assert_eq!(listed(&listing(), "ext-google").expect("listed"), "22");
        assert_eq!(listed(&listing(), "reports").expect("listed"), "11");
        assert_eq!(
            listed(&listing(), "gone")
                .expect_err("unlisted")
                .to_string(),
            "Service 'gone' not found"
        );
    }

    #[test]
    fn delete_and_status_requests_name_the_service() {
        assert_eq!(
            delete_request("https://platform.test", "22", false),
            (
                Method::DELETE,
                "https://platform.test/api/services/22".into()
            )
        );
        assert_eq!(
            delete_request("https://platform.test", "22", true),
            (
                Method::DELETE,
                "https://platform.test/api/services/22?force=true".into()
            )
        );
        assert_eq!(
            status_request("https://platform.test", "22", "inactive"),
            (
                Method::PATCH,
                "https://platform.test/api/services/22/status".into(),
                json!({"status": "inactive"})
            )
        );
    }
}