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)
}
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(())
}
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 == '-'
|| ('\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(())
}
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());
}
}