zilliz 1.4.2

TUI and CLI tool for managing Zilliz Cloud clusters and Milvus operations
Documentation
use std::io::{self, BufRead, Write};

use anyhow::{bail, Context, Result};
use serde_json::json;

use crate::api::client::ApiClient;
use crate::api::error::ApiError;
use crate::config::credentials::resolve_api_key;
use crate::config::manager::ConfigManager;
use crate::model::loader::Models;

use super::dispatch::{print_output_with_opts, OutputOpts};

struct CreateOpts {
    name: Option<String>,
    cluster_type: Option<String>,
    project_id: Option<String>,
    region: Option<String>,
    cu_type: Option<String>,
    cu_size: Option<i64>,
    plan: Option<String>,
}

fn parse_create_args(raw_args: &[String]) -> Result<CreateOpts> {
    let mut opts = CreateOpts {
        name: None,
        cluster_type: None,
        project_id: None,
        region: None,
        cu_type: None,
        cu_size: None,
        plan: None,
    };
    let mut i = 0;
    while i < raw_args.len() {
        match raw_args[i].as_str() {
            "--name" => {
                i += 1;
                opts.name = raw_args.get(i).cloned();
            }
            "--type" => {
                i += 1;
                opts.cluster_type = raw_args.get(i).cloned();
            }
            "--project-id" => {
                i += 1;
                opts.project_id = raw_args.get(i).cloned();
            }
            "--region" => {
                i += 1;
                opts.region = raw_args.get(i).cloned();
            }
            "--cu-type" => {
                i += 1;
                opts.cu_type = raw_args.get(i).cloned();
            }
            "--cu-size" => {
                i += 1;
                opts.cu_size = raw_args.get(i).and_then(|s| s.parse().ok());
            }
            "--plan" => {
                i += 1;
                opts.plan = raw_args.get(i).cloned();
            }
            _ => {}
        }
        i += 1;
    }
    Ok(opts)
}

pub async fn create(
    models: &Models,
    config_mgr: &ConfigManager,
    raw_args: &[String],
    output_opts: &OutputOpts<'_>,
) -> Result<()> {
    if raw_args.iter().any(|a| a == "-h" || a == "--help") {
        print!(
            "Create a new cluster.\n\n\
             Usage: zilliz cluster create [OPTIONS]\n\n\
             Options:\n\
             \x20 {:24}{:30}Cluster name\n\
             \x20 {:24}{:30}Cluster type: free, serverless, dedicated\n\
             \x20 {:24}{:30}Project ID (prompted if omitted)\n\
             \x20 {:24}{:30}Region ID (prompted if omitted)\n\
             \x20 {:24}{:30}CU type (dedicated only): Performance-optimized, Capacity-optimized\n\
             \x20 {:24}{:30}Number of CUs (dedicated only)\n\
             \x20 {:24}{:30}Billing plan: Standard, Enterprise, BusinessCritical\n",
            "--name",
            "<string> (required)",
            "--type",
            "<string> (required)",
            "--project-id",
            "<string>",
            "--region",
            "<string>",
            "--cu-type",
            "<string>",
            "--cu-size",
            "<integer>",
            "--plan",
            "<string>",
        );
        return Ok(());
    }
    let mut opts = parse_create_args(raw_args)?;

    let api_key = resolve_api_key(output_opts.api_key, config_mgr).ok_or(ApiError::NoApiKey)?;
    let base_url =
        super::endpoint::resolve_control_plane_url(config_mgr, &models.control_plane, None);
    let client = ApiClient::new(api_key, base_url);

    // Require name
    let name = opts.name.take().context("--name is required")?;

    // Require type
    let cluster_type = opts
        .cluster_type
        .take()
        .context("--type is required (serverless, free, or dedicated)")?;

    // Project ID: prompt if missing
    let project_id = match opts.project_id.take() {
        Some(id) => id,
        None => prompt_project(&client).await?,
    };

    // Region: prompt if missing
    let region = match opts.region.take() {
        Some(r) => r,
        None => prompt_region(&client, &cluster_type.to_lowercase()).await?,
    };

    // Build request
    let (path, body) = match cluster_type.to_lowercase().as_str() {
        "serverless" => (
            "/v2/clusters/createServerless".to_string(),
            json!({
                "clusterName": name,
                "projectId": project_id,
                "regionId": region,
            }),
        ),
        "free" => (
            "/v2/clusters/createFree".to_string(),
            json!({
                "clusterName": name,
                "projectId": project_id,
                "regionId": region,
            }),
        ),
        "dedicated" => {
            let cu_type = opts
                .cu_type
                .take()
                .context("--cu-type is required for dedicated clusters (Performance-optimized or Capacity-optimized)")?;
            let cu_size = opts
                .cu_size
                .context("--cu-size is required for dedicated clusters")?;
            let mut body = json!({
                "clusterName": name,
                "projectId": project_id,
                "regionId": region,
                "cuType": cu_type,
                "cuSize": cu_size,
            });
            if let Some(plan) = opts.plan.take() {
                body["plan"] = json!(plan);
            }
            ("/v2/clusters/createDedicated".to_string(), body)
        }
        _ => bail!(
            "Invalid cluster type '{}'. Use: serverless, free, or dedicated",
            cluster_type
        ),
    };

    let result = client.call("POST", &path, None, Some(&body)).await?;
    print_output_with_opts(&result, output_opts, None);

    Ok(())
}

async fn prompt_project(client: &ApiClient) -> Result<String> {
    let result = client.call("GET", "/v2/projects", None, None).await?;
    let projects = result
        .as_array()
        .or_else(|| result.get("data").and_then(|v| v.as_array()))
        .context("Failed to fetch projects")?;

    if projects.is_empty() {
        bail!("No projects found. Create a project first.");
    }

    println!("Available projects:");
    for (i, p) in projects.iter().enumerate() {
        let name = p.get("projectName").and_then(|v| v.as_str()).unwrap_or("?");
        let id = p.get("projectId").and_then(|v| v.as_str()).unwrap_or("?");
        println!("  [{}] {} ({})", i + 1, name, id);
    }
    print!("Select project [1-{}]: ", projects.len());
    io::stdout().flush()?;

    let mut input = String::new();
    io::stdin().lock().read_line(&mut input)?;
    let idx: usize = input.trim().parse().context("Invalid selection")?;
    if idx < 1 || idx > projects.len() {
        bail!("Selection out of range");
    }

    projects[idx - 1]
        .get("projectId")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .context("Project has no ID")
}

async fn prompt_region(client: &ApiClient, cluster_type: &str) -> Result<String> {
    let result = client.call("GET", "/v2/regions", None, None).await?;
    let all_regions = result
        .as_array()
        .or_else(|| result.get("data").and_then(|v| v.as_array()))
        .context("Failed to fetch regions")?;

    if all_regions.is_empty() {
        bail!("No regions available.");
    }

    let regions: Vec<&serde_json::Value> = all_regions
        .iter()
        .filter(|r| region_supports_cluster_type(r, cluster_type))
        .collect();

    if regions.is_empty() {
        bail!("No regions available for cluster type '{}'.", cluster_type);
    }

    println!("Available regions for cluster type '{}':", cluster_type);
    for (i, r) in regions.iter().enumerate() {
        let id = r.get("regionId").and_then(|v| v.as_str()).unwrap_or("?");
        println!("  [{}] {}", i + 1, id);
    }
    print!("Select region [1-{}]: ", regions.len());
    io::stdout().flush()?;

    let mut input = String::new();
    io::stdin().lock().read_line(&mut input)?;
    let idx: usize = input.trim().parse().context("Invalid selection")?;
    if idx < 1 || idx > regions.len() {
        bail!("Selection out of range");
    }

    regions[idx - 1]
        .get("regionId")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .context("Region has no ID")
}

/// Returns true when `region` advertises support for `cluster_type` via the
/// `supportedClusterTypes` field. If the field is absent (older server that
/// predates cloud-control-api PR #5668), assume support to avoid false
/// negatives.
pub fn region_supports_cluster_type(region: &serde_json::Value, cluster_type: &str) -> bool {
    match region.get("supportedClusterTypes") {
        Some(serde_json::Value::Array(types)) => types
            .iter()
            .filter_map(|v| v.as_str())
            .any(|t| t.eq_ignore_ascii_case(cluster_type)),
        _ => true,
    }
}