zilliz 1.4.2

TUI and CLI tool for managing Zilliz Cloud clusters and Milvus operations
Documentation
use anyhow::{bail, 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::OutputOpts;
use super::endpoint::resolve_control_plane_url;

fn resolve_base_url(models: &Models, config_mgr: &ConfigManager) -> String {
    resolve_control_plane_url(config_mgr, &models.control_plane, None)
}

/// Parse and validate a sessionTTL string (e.g. "30m", "1h", "90s").
/// Returns the validated string unchanged, or an error if format/floor is wrong.
fn validate_session_ttl(ttl: &str) -> Result<()> {
    let ttl = ttl.trim();
    if ttl.is_empty() {
        bail!("sessionTTL cannot be empty. Use format: <number><s|m|h> (e.g. 30m, 1h, 90s)");
    }
    let unit = ttl.chars().last().unwrap();
    if !matches!(unit, 's' | 'm' | 'h') {
        bail!(
            "Invalid sessionTTL '{}'. Unit must be s (seconds), m (minutes), or h (hours)",
            ttl
        );
    }
    let num_str = &ttl[..ttl.len() - unit.len_utf8()];
    let n: u64 = num_str.parse().map_err(|_| {
        anyhow::anyhow!(
            "Invalid sessionTTL '{}': '{}' is not a valid number",
            ttl,
            num_str
        )
    })?;
    let secs = match unit {
        's' => n,
        'm' => n * 60,
        'h' => n * 3600,
        _ => unreachable!(),
    };
    if secs < 60 {
        bail!(
            "sessionTTL must be at least 60s (1 minute). Got '{}' = {}s.",
            ttl,
            secs
        );
    }
    Ok(())
}

/// Mirror of cloud-control-api's `InstanceNameValidator` (regex
/// `^[A-Za-z0-9 _\-\p{IsHan}]{1,64}$` from
/// `vdc/cloud-commons/common-lib/.../RegexConstants.INSTANCE_NAME_REGEX`).
/// Done char-by-char to avoid pulling in the `regex` crate as a direct dep
/// for one fail-fast check; the server still applies the canonical regex.
fn validate_cluster_name(name: &str) -> Result<()> {
    if name.is_empty() {
        bail!("--cluster-name must not be blank");
    }
    let len = name.chars().count();
    if len > 64 {
        bail!("--cluster-name must be at most 64 characters (got {})", len);
    }
    for c in name.chars() {
        let ok = c.is_ascii_alphanumeric()
            || c == ' '
            || c == '_'
            || c == '-'
            // CJK Unified Ideographs + Extension A (covers \p{IsHan} for the
            // common case; server validates the full property set strictly).
            || ('\u{4E00}'..='\u{9FFF}').contains(&c)
            || ('\u{3400}'..='\u{4DBF}').contains(&c);
        if !ok {
            bail!(
                "--cluster-name contains invalid character '{}'. \
                 Allowed: letters, digits, space, '_', '-', or Chinese characters.",
                c
            );
        }
    }
    Ok(())
}

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 an on-demand cluster.\n\n\
             Usage: zilliz on-demand-cluster create [OPTIONS]\n\n\
             Options:\n\
             \x20 {:28}{:30}Project ID (required)\n\
             \x20 {:28}{:30}Cloud region (required, e.g. aws-us-east-1)\n\
             \x20 {:28}{:30}Compute units (required, >= 8)\n\
             \x20 {:28}{:30}Cluster display name (required, max 64 chars)\n\
             \x20 {:28}{:30}Auto-suspend TTL: 30m, 1h, 90s (min 60s) [default: 60s]\n\
             \x20 {:28}{:30}Max query node CU\n\
             \x20 {:28}{:30}Max query node replicas\n",
            "--project-id",
            "<string>",
            "--region-id",
            "<string>",
            "--cu-size",
            "<integer>",
            "--cluster-name",
            "<string>",
            "--session-ttl",
            "<string>",
            "--max-query-node-cu",
            "<integer>",
            "--max-query-node-replicas",
            "<integer>",
        );
        return Ok(());
    }

    let mut project_id: Option<String> = None;
    let mut region_id: Option<String> = None;
    let mut cu_size: Option<u32> = None;
    let mut session_ttl: Option<String> = None;
    let mut max_query_node_cu: Option<u32> = None;
    let mut max_query_node_replicas: Option<u32> = None;
    let mut cluster_name: Option<String> = None;
    let mut i = 0;

    while i < raw_args.len() {
        let flag = raw_args[i].as_str();
        match flag {
            "--project-id" => {
                i += 1;
                let v = raw_args
                    .get(i)
                    .ok_or_else(|| anyhow::anyhow!("--project-id requires a value"))?;
                project_id = Some(v.clone());
            }
            "--region-id" => {
                i += 1;
                let v = raw_args
                    .get(i)
                    .ok_or_else(|| anyhow::anyhow!("--region-id requires a value"))?;
                region_id = Some(v.clone());
            }
            "--cu-size" => {
                i += 1;
                let v = raw_args
                    .get(i)
                    .ok_or_else(|| anyhow::anyhow!("--cu-size requires a value"))?;
                cu_size = Some(
                    v.parse()
                        .map_err(|_| anyhow::anyhow!("--cu-size must be an integer >= 8"))?,
                );
            }
            "--session-ttl" => {
                i += 1;
                let v = raw_args
                    .get(i)
                    .ok_or_else(|| anyhow::anyhow!("--session-ttl requires a value"))?;
                validate_session_ttl(v)?;
                session_ttl = Some(v.clone());
            }
            "--max-query-node-cu" => {
                i += 1;
                let v = raw_args
                    .get(i)
                    .ok_or_else(|| anyhow::anyhow!("--max-query-node-cu requires a value"))?;
                max_query_node_cu =
                    Some(v.parse().map_err(|_| {
                        anyhow::anyhow!("--max-query-node-cu must be an integer >= 1")
                    })?);
            }
            "--max-query-node-replicas" => {
                i += 1;
                let v = raw_args
                    .get(i)
                    .ok_or_else(|| anyhow::anyhow!("--max-query-node-replicas requires a value"))?;
                max_query_node_replicas = Some(v.parse().map_err(|_| {
                    anyhow::anyhow!("--max-query-node-replicas must be an integer >= 1")
                })?);
            }
            "--cluster-name" => {
                i += 1;
                let v = raw_args
                    .get(i)
                    .ok_or_else(|| anyhow::anyhow!("--cluster-name requires a value"))?;
                cluster_name = Some(v.clone());
            }
            _ => {}
        }
        i += 1;
    }

    let project_id = project_id.ok_or_else(|| anyhow::anyhow!("--project-id is required"))?;
    let region_id = region_id.ok_or_else(|| anyhow::anyhow!("--region-id is required"))?;
    let cu_size = cu_size.ok_or_else(|| anyhow::anyhow!("--cu-size is required"))?;
    if cu_size < 8 {
        bail!("--cu-size must be >= 8");
    }
    let cluster_name = cluster_name.ok_or_else(|| anyhow::anyhow!("--cluster-name is required"))?;
    let trimmed = cluster_name.trim();
    if trimmed.is_empty() {
        bail!("--cluster-name must not be blank");
    }
    validate_cluster_name(trimmed)?;

    let mut body = json!({
        "projectId": project_id,
        "regionId": region_id,
        "cuSize": cu_size,
        "clusterName": trimmed,
    });
    if let Some(ttl) = session_ttl {
        body["sessionTTL"] = json!(ttl);
    }
    if let Some(v) = max_query_node_cu {
        body["maxQueryNodeCU"] = json!(v);
    }
    if let Some(v) = max_query_node_replicas {
        body["maxQueryNodeReplicas"] = json!(v);
    }

    let api_key = resolve_api_key(output_opts.api_key, config_mgr).ok_or(ApiError::NoApiKey)?;
    let client = ApiClient::new(api_key, resolve_base_url(models, config_mgr));
    let result = client
        .call(
            "POST",
            "/v2/clusters/createOnDemandCluster",
            None,
            Some(&body),
        )
        .await?;

    super::dispatch::print_output_with_opts(&result, output_opts, None);
    Ok(())
}

/// Create a standalone VectorLake instance.
pub async fn create_vectorlake(
    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 standalone VectorLake instance.\n\n\
             Usage: zilliz cluster create-vectorlake [OPTIONS]\n\n\
             Options:\n\
             \x20 {:28}{:30}Project ID (required)\n\
             \x20 {:28}{:30}Cloud region (required, e.g. aws-us-east-1)\n\
             \x20 {:28}{:30}Auto-suspend TTL: 30m, 1h, 90s (min 60s)\n\
             \x20 {:28}{:30}Max query node CU\n\
             \x20 {:28}{:30}Max query node replicas\n",
            "--project-id",
            "<string>",
            "--region-id",
            "<string>",
            "--session-ttl",
            "<string>",
            "--max-query-node-cu",
            "<integer>",
            "--max-query-node-replicas",
            "<integer>",
        );
        return Ok(());
    }

    let mut project_id: Option<String> = None;
    let mut region_id: Option<String> = None;
    let mut session_ttl: Option<String> = None;
    let mut max_query_node_cu: Option<u32> = None;
    let mut max_query_node_replicas: Option<u32> = None;
    let mut i = 0;

    while i < raw_args.len() {
        let flag = raw_args[i].as_str();
        match flag {
            "--project-id" => {
                i += 1;
                let v = raw_args
                    .get(i)
                    .ok_or_else(|| anyhow::anyhow!("--project-id requires a value"))?;
                project_id = Some(v.clone());
            }
            "--region-id" => {
                i += 1;
                let v = raw_args
                    .get(i)
                    .ok_or_else(|| anyhow::anyhow!("--region-id requires a value"))?;
                region_id = Some(v.clone());
            }
            "--session-ttl" => {
                i += 1;
                let v = raw_args
                    .get(i)
                    .ok_or_else(|| anyhow::anyhow!("--session-ttl requires a value"))?;
                validate_session_ttl(v)?;
                session_ttl = Some(v.clone());
            }
            "--max-query-node-cu" => {
                i += 1;
                let v = raw_args
                    .get(i)
                    .ok_or_else(|| anyhow::anyhow!("--max-query-node-cu requires a value"))?;
                max_query_node_cu = Some(
                    v.parse()
                        .map_err(|_| anyhow::anyhow!("--max-query-node-cu must be an integer"))?,
                );
            }
            "--max-query-node-replicas" => {
                i += 1;
                let v = raw_args
                    .get(i)
                    .ok_or_else(|| anyhow::anyhow!("--max-query-node-replicas requires a value"))?;
                max_query_node_replicas = Some(v.parse().map_err(|_| {
                    anyhow::anyhow!("--max-query-node-replicas must be an integer")
                })?);
            }
            _ => {}
        }
        i += 1;
    }

    let project_id = project_id.ok_or_else(|| anyhow::anyhow!("--project-id is required"))?;
    let region_id = region_id.ok_or_else(|| anyhow::anyhow!("--region-id is required"))?;

    let mut body = json!({
        "projectId": project_id,
        "regionId": region_id,
    });
    if let Some(ttl) = session_ttl {
        body["sessionTTL"] = json!(ttl);
    }
    if let Some(v) = max_query_node_cu {
        body["maxQueryNodeCU"] = json!(v);
    }
    if let Some(v) = max_query_node_replicas {
        body["maxQueryNodeReplicas"] = json!(v);
    }

    let api_key = resolve_api_key(output_opts.api_key, config_mgr).ok_or(ApiError::NoApiKey)?;
    let client = ApiClient::new(api_key, resolve_base_url(models, config_mgr));
    let result = client
        .call("POST", "/v2/clusters/createVectorLake", None, Some(&body))
        .await?;

    super::dispatch::print_output_with_opts(&result, output_opts, None);
    Ok(())
}

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

    #[test]
    fn valid_session_ttl_accepted() {
        assert!(validate_session_ttl("30m").is_ok());
        assert!(validate_session_ttl("1h").is_ok());
        assert!(validate_session_ttl("60s").is_ok());
        assert!(validate_session_ttl("1m").is_ok());
        assert!(validate_session_ttl("120s").is_ok());
        assert!(validate_session_ttl("3600s").is_ok());
    }

    #[test]
    fn session_ttl_below_floor_rejected() {
        assert!(validate_session_ttl("59s").is_err());
        assert!(validate_session_ttl("30s").is_err());
        assert!(validate_session_ttl("0s").is_err());
    }

    #[test]
    fn session_ttl_bad_format_rejected() {
        assert!(validate_session_ttl("30").is_err());
        assert!(validate_session_ttl("abc").is_err());
        assert!(validate_session_ttl("30d").is_err());
        assert!(validate_session_ttl("").is_err());
    }

    #[test]
    fn cluster_name_accepts_allowed_chars() {
        assert!(validate_cluster_name("qc-1").is_ok());
        assert!(validate_cluster_name("My Cluster_01").is_ok());
        assert!(validate_cluster_name("查询集群").is_ok());
        assert!(validate_cluster_name(&"a".repeat(64)).is_ok());
    }

    #[test]
    fn cluster_name_rejects_invalid() {
        assert!(validate_cluster_name("").is_err());
        assert!(validate_cluster_name(&"a".repeat(65)).is_err());
        assert!(validate_cluster_name("bad/name").is_err());
        assert!(validate_cluster_name("bad@name").is_err());
    }
}