Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/redisctl/src/cli/enterprise.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,10 @@ pub enum EnterpriseDatabaseCommands {
# With specific port
redisctl enterprise database create --name service-db --memory 1073741824 --port 12000

# With modules (auto-resolves name to module)
redisctl enterprise database create --name search-db --memory 1073741824 \\
--module search --module ReJSON

# Complete configuration from file
redisctl enterprise database create --data @database.json

Expand Down Expand Up @@ -424,6 +428,11 @@ NOTE: Memory size is in bytes. Common values:
#[arg(long)]
redis_password: Option<String>,

/// Module to enable (by name, can be repeated). Use 'module list' to see available modules.
/// Format: module_name or module_name:args (e.g., --module search --module ReJSON)
#[arg(long = "module", value_name = "NAME[:ARGS]")]
modules: Vec<String>,

/// Advanced: Full database configuration as JSON string or @file.json
#[arg(long)]
data: Option<String>,
Expand Down
2 changes: 2 additions & 0 deletions crates/redisctl/src/commands/enterprise/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub async fn handle_database_command(
proxy_policy,
crdb,
redis_password,
modules,
data,
dry_run,
} => {
Expand All @@ -52,6 +53,7 @@ pub async fn handle_database_command(
proxy_policy.as_deref(),
*crdb,
redis_password.as_deref(),
modules,
data.as_deref(),
*dry_run,
output_format,
Expand Down
102 changes: 102 additions & 0 deletions crates/redisctl/src/commands/enterprise/database_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ pub async fn create_database(
proxy_policy: Option<&str>,
crdb: bool,
redis_password: Option<&str>,
modules: &[String],
data: Option<&str>,
dry_run: bool,
output_format: OutputFormat,
Expand Down Expand Up @@ -145,6 +146,107 @@ pub async fn create_database(
);
}

// Handle module resolution if --module flags were provided
if !modules.is_empty() {
let module_handler = redis_enterprise::ModuleHandler::new(
conn_mgr.create_enterprise_client(profile_name).await?,
);
let available_modules = module_handler.list().await.map_err(RedisCtlError::from)?;

let mut module_list: Vec<Value> = Vec::new();

for module_spec in modules {
// Parse module_name:args format
let (module_name, module_args) = if let Some(idx) = module_spec.find(':') {
let (name, args) = module_spec.split_at(idx);
(name.trim(), Some(args[1..].trim())) // Skip the ':' character
} else {
(module_spec.as_str(), None)
};

// Find matching module (case-insensitive)
let matching: Vec<_> = available_modules
.iter()
.filter(|m| {
m.module_name
.as_ref()
.map(|n| n.eq_ignore_ascii_case(module_name))
.unwrap_or(false)
})
.collect();

match matching.len() {
0 => {
// No exact match - try partial match and suggest
let partial_matches: Vec<_> = available_modules
.iter()
.filter(|m| {
m.module_name
.as_ref()
.map(|n| n.to_lowercase().contains(&module_name.to_lowercase()))
.unwrap_or(false)
})
.collect();

if partial_matches.is_empty() {
return Err(RedisCtlError::InvalidInput {
message: format!(
"Module '{}' not found. Use 'enterprise module list' to see available modules.",
module_name
),
});
} else {
let suggestions: Vec<_> = partial_matches
.iter()
.filter_map(|m| m.module_name.as_deref())
.collect();
return Err(RedisCtlError::InvalidInput {
message: format!(
"Module '{}' not found. Did you mean one of: {}?",
module_name,
suggestions.join(", ")
),
});
}
}
1 => {
// Build module config using the actual module name from the API
let actual_name = matching[0].module_name.as_deref().unwrap_or(module_name);
let mut module_config = serde_json::json!({
"module_name": actual_name
});
if let Some(args) = module_args {
module_config["module_args"] = serde_json::json!(args);
}
module_list.push(module_config);
}
_ => {
// Multiple matches - show versions and ask user to be specific
let versions: Vec<_> = matching
.iter()
.map(|m| {
format!(
"{} (version: {})",
m.module_name.as_deref().unwrap_or("unknown"),
m.semantic_version.as_deref().unwrap_or("unknown")
)
})
.collect();
return Err(RedisCtlError::InvalidInput {
message: format!(
"Multiple modules found matching '{}'. Available versions:\n {}",
module_name,
versions.join("\n ")
),
});
}
}
}

// Add module_list to request (CLI modules override --data modules)
request_obj.insert("module_list".to_string(), serde_json::json!(module_list));
}

let path = if dry_run {
"/v1/bdbs/dry-run"
} else {
Expand Down
12 changes: 9 additions & 3 deletions crates/redisctl/src/commands/enterprise/module.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
use clap::Subcommand;
use clap::{ArgGroup, Subcommand};

#[derive(Debug, Subcommand)]
pub enum ModuleCommands {
/// List all available modules
#[command(visible_alias = "ls")]
List,

/// Get module details
/// Get module details by UID or name
#[command(group(ArgGroup::new("identifier").required(true).args(["uid", "name"])))]
Get {
/// Module UID
uid: String,
#[arg(conflicts_with = "name")]
uid: Option<String>,

/// Module name (e.g., "ReJSON", "search")
#[arg(long, conflicts_with = "uid")]
name: Option<String>,
},

/// Upload new module
Expand Down
92 changes: 88 additions & 4 deletions crates/redisctl/src/commands/enterprise/module_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,16 @@ pub async fn handle_module_commands(
) -> CliResult<()> {
match cmd {
ModuleCommands::List => handle_list(conn_mgr, profile_name, output_format, query).await,
ModuleCommands::Get { uid } => {
handle_get(conn_mgr, profile_name, uid, output_format, query).await
ModuleCommands::Get { uid, name } => {
handle_get(
conn_mgr,
profile_name,
uid.as_deref(),
name.as_deref(),
output_format,
query,
)
.await
}
ModuleCommands::Upload { file } => {
handle_upload(conn_mgr, profile_name, file, output_format, query).await
Expand Down Expand Up @@ -61,14 +69,90 @@ async fn handle_list(
async fn handle_get(
conn_mgr: &ConnectionManager,
profile_name: Option<&str>,
uid: &str,
uid: Option<&str>,
name: Option<&str>,
output_format: OutputFormat,
query: Option<&str>,
) -> CliResult<()> {
let client = conn_mgr.create_enterprise_client(profile_name).await?;
let handler = ModuleHandler::new(client);

let module = handler.get(uid).await.map_err(RedisCtlError::from)?;
// Resolve module UID from name if provided
let resolved_uid = if let Some(module_name) = name {
let modules = handler.list().await.map_err(RedisCtlError::from)?;
let matching: Vec<_> = modules
.iter()
.filter(|m| {
m.module_name
.as_ref()
.map(|n| n.eq_ignore_ascii_case(module_name))
.unwrap_or(false)
})
.collect();

match matching.len() {
0 => {
// No exact match - try partial match and suggest
let partial_matches: Vec<_> = modules
.iter()
.filter(|m| {
m.module_name
.as_ref()
.map(|n| n.to_lowercase().contains(&module_name.to_lowercase()))
.unwrap_or(false)
})
.collect();

if partial_matches.is_empty() {
return Err(anyhow::anyhow!(
"No module found with name '{}'. Use 'module list' to see available modules.",
module_name
)
.into());
} else {
let suggestions: Vec<_> = partial_matches
.iter()
.filter_map(|m| m.module_name.as_deref())
.collect();
return Err(anyhow::anyhow!(
"No module found with name '{}'. Did you mean one of: {}?",
module_name,
suggestions.join(", ")
)
.into());
}
}
1 => matching[0].uid.clone(),
_ => {
// Multiple matches - show versions and ask user to be specific
let versions: Vec<_> = matching
.iter()
.map(|m| {
format!(
"{} (uid: {}, version: {})",
m.module_name.as_deref().unwrap_or("unknown"),
m.uid,
m.semantic_version.as_deref().unwrap_or("unknown")
)
})
.collect();
return Err(anyhow::anyhow!(
"Multiple modules found with name '{}'. Please use --uid to specify:\n {}",
module_name,
versions.join("\n ")
)
.into());
}
}
} else {
uid.expect("Either uid or name must be provided")
.to_string()
};

let module = handler
.get(&resolved_uid)
.await
.map_err(RedisCtlError::from)?;

let module_json = serde_json::to_value(&module)?;
let output_data = if let Some(q) = query {
Expand Down