From eb475cf5e09237dcdcf9ba3ad93be3f38b2fbfef Mon Sep 17 00:00:00 2001 From: Josh Rotenberg Date: Fri, 12 Sep 2025 21:14:43 -0700 Subject: [PATCH 1/3] feat(enterprise): add action commands for task management - Add list, get, status, cancel, and list-for-bdb subcommands - Support both v1 and v2 API endpoints with --v2 flag - Add filtering by status and action type for list command - Integrate with existing output formatting and JMESPath query support - Test coverage against Docker compose environment Implements #166 --- crates/redisctl/src/cli.rs | 3 + .../src/commands/enterprise/actions.rs | 253 ++++++++++++++++++ .../redisctl/src/commands/enterprise/mod.rs | 1 + crates/redisctl/src/main.rs | 10 + 4 files changed, 267 insertions(+) create mode 100644 crates/redisctl/src/commands/enterprise/actions.rs diff --git a/crates/redisctl/src/cli.rs b/crates/redisctl/src/cli.rs index 21867a91..9887fa59 100644 --- a/crates/redisctl/src/cli.rs +++ b/crates/redisctl/src/cli.rs @@ -959,6 +959,9 @@ pub enum CloudCommands { /// Enterprise-specific commands (placeholder for now) #[derive(Subcommand, Debug)] pub enum EnterpriseCommands { + /// Action (task) operations + #[command(subcommand)] + Action(crate::commands::enterprise::actions::ActionCommands), /// Cluster operations #[command(subcommand)] Cluster(EnterpriseClusterCommands), diff --git a/crates/redisctl/src/commands/enterprise/actions.rs b/crates/redisctl/src/commands/enterprise/actions.rs new file mode 100644 index 00000000..004df5b1 --- /dev/null +++ b/crates/redisctl/src/commands/enterprise/actions.rs @@ -0,0 +1,253 @@ +use anyhow::Context; +use clap::Subcommand; +use redis_enterprise::ActionHandler; + +use crate::cli::OutputFormat; +use crate::connection::ConnectionManager; +use crate::error::Result as CliResult; + +#[derive(Debug, Clone, Subcommand)] +pub enum ActionCommands { + /// List all actions (tasks) in the cluster + List { + /// Filter by action status (running, completed, failed) + #[arg(long)] + status: Option, + + /// Filter by action type + #[arg(long)] + action_type: Option, + + /// Use v2 API endpoint (default: v1) + #[arg(long)] + v2: bool, + }, + + /// Get details of a specific action by UID + Get { + /// Action UID + uid: String, + + /// Use v2 API endpoint (default: v1) + #[arg(long)] + v2: bool, + }, + + /// Get the status of a specific action + Status { + /// Action UID + uid: String, + + /// Use v2 API endpoint (default: v1) + #[arg(long)] + v2: bool, + }, + + /// Cancel a running action + Cancel { + /// Action UID + uid: String, + }, + + /// List actions for a specific database + #[command(name = "list-for-bdb")] + ListForBdb { + /// Database UID + bdb_uid: u32, + }, +} + +impl ActionCommands { + pub async fn execute( + &self, + conn_mgr: &ConnectionManager, + profile_name: Option<&str>, + output_format: OutputFormat, + query: Option<&str>, + ) -> CliResult<()> { + let client = conn_mgr.create_enterprise_client(profile_name).await?; + let handler = ActionHandler::new(client); + + match self { + ActionCommands::List { + status, + action_type, + v2, + } => { + let actions = if *v2 { + handler + .list_v2() + .await + .context("Failed to list actions (v2)")? + } else { + handler.list().await.context("Failed to list actions")? + }; + + // Convert to JSON Value for filtering and output + let mut response = serde_json::to_value(&actions)?; + + // Apply filters if provided + if status.is_some() || action_type.is_some() { + if let Some(actions) = response.as_array_mut() { + actions.retain(|action| { + let mut keep = true; + + if let Some(status_filter) = status { + if let Some(action_status) = + action.get("status").and_then(|s| s.as_str()) + { + keep = + keep && action_status.eq_ignore_ascii_case(status_filter); + } else { + keep = false; + } + } + + if let Some(type_filter) = action_type { + if let Some(action_type) = + action.get("type").and_then(|t| t.as_str()) + { + keep = keep && action_type.eq_ignore_ascii_case(type_filter); + } else { + keep = false; + } + } + + keep + }); + } + } + + let output_data = if let Some(q) = query { + super::utils::apply_jmespath(&response, q)? + } else { + response + }; + super::utils::print_formatted_output(output_data, output_format)?; + } + + ActionCommands::Get { uid, v2 } => { + let action = if *v2 { + handler + .get_v2(uid) + .await + .context(format!("Failed to get action {} (v2)", uid))? + } else { + handler + .get(uid) + .await + .context(format!("Failed to get action {}", uid))? + }; + + // Convert to JSON Value + let response = serde_json::to_value(&action)?; + + let output_data = if let Some(q) = query { + super::utils::apply_jmespath(&response, q)? + } else { + response + }; + super::utils::print_formatted_output(output_data, output_format)?; + } + + ActionCommands::Status { uid, v2 } => { + let action = if *v2 { + handler + .get_v2(uid) + .await + .context(format!("Failed to get action status {} (v2)", uid))? + } else { + handler + .get(uid) + .await + .context(format!("Failed to get action status {}", uid))? + }; + + // Extract just the status information + let response = serde_json::to_value(&action)?; + let status_info = if let Some(obj) = response.as_object() { + let mut status = serde_json::Map::new(); + if let Some(v) = obj.get("uid") { + status.insert("uid".to_string(), v.clone()); + } + if let Some(v) = obj.get("status") { + status.insert("status".to_string(), v.clone()); + } + if let Some(v) = obj.get("progress") { + status.insert("progress".to_string(), v.clone()); + } + if let Some(v) = obj.get("error") { + status.insert("error".to_string(), v.clone()); + } + if let Some(v) = obj.get("type") { + status.insert("type".to_string(), v.clone()); + } + if let Some(v) = obj.get("description") { + status.insert("description".to_string(), v.clone()); + } + serde_json::Value::Object(status) + } else { + response + }; + + let output_data = if let Some(q) = query { + super::utils::apply_jmespath(&status_info, q)? + } else { + status_info + }; + super::utils::print_formatted_output(output_data, output_format)?; + } + + ActionCommands::Cancel { uid } => { + handler + .cancel(uid) + .await + .context(format!("Failed to cancel action {}", uid))?; + + // Create success response + let response = serde_json::json!({ + "status": "success", + "message": format!("Action '{}' cancelled successfully", uid) + }); + + let output_data = if let Some(q) = query { + super::utils::apply_jmespath(&response, q)? + } else { + response + }; + super::utils::print_formatted_output(output_data, output_format)?; + } + + ActionCommands::ListForBdb { bdb_uid } => { + let actions = handler + .list_for_bdb(*bdb_uid) + .await + .context(format!("Failed to list actions for database {}", bdb_uid))?; + + // Convert to JSON Value + let response = serde_json::to_value(&actions)?; + + let output_data = if let Some(q) = query { + super::utils::apply_jmespath(&response, q)? + } else { + response + }; + super::utils::print_formatted_output(output_data, output_format)?; + } + } + + Ok(()) + } +} + +pub async fn handle_action_command( + conn_mgr: &ConnectionManager, + profile_name: Option<&str>, + action_cmd: ActionCommands, + output_format: OutputFormat, + query: Option<&str>, +) -> CliResult<()> { + action_cmd + .execute(conn_mgr, profile_name, output_format, query) + .await +} diff --git a/crates/redisctl/src/commands/enterprise/mod.rs b/crates/redisctl/src/commands/enterprise/mod.rs index 426ae62d..f3910f8e 100644 --- a/crates/redisctl/src/commands/enterprise/mod.rs +++ b/crates/redisctl/src/commands/enterprise/mod.rs @@ -1,5 +1,6 @@ //! Enterprise command implementations +pub mod actions; pub mod cluster; pub mod cluster_impl; pub mod crdb; diff --git a/crates/redisctl/src/main.rs b/crates/redisctl/src/main.rs index b0291998..9a05c53e 100644 --- a/crates/redisctl/src/main.rs +++ b/crates/redisctl/src/main.rs @@ -186,6 +186,16 @@ async fn execute_enterprise_command( use cli::EnterpriseCommands::*; match enterprise_cmd { + Action(action_cmd) => { + commands::enterprise::actions::handle_action_command( + conn_mgr, + profile, + action_cmd.clone(), + output, + query, + ) + .await + } Cluster(cluster_cmd) => { commands::enterprise::cluster::handle_cluster_command( conn_mgr, From 3078e5cd7d1a11d9cf65f0097205082aac2895a3 Mon Sep 17 00:00:00 2001 From: Josh Rotenberg Date: Sat, 13 Sep 2025 10:33:48 -0700 Subject: [PATCH 2/3] fix: address clippy warnings in action commands - Add #[allow(dead_code)] attributes for methods used via trait impl - Fix collapsible if statement using if-let chain syntax - Ensure all clippy checks pass with -D warnings flag --- .../src/commands/enterprise/actions.rs | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/crates/redisctl/src/commands/enterprise/actions.rs b/crates/redisctl/src/commands/enterprise/actions.rs index 004df5b1..b9191933 100644 --- a/crates/redisctl/src/commands/enterprise/actions.rs +++ b/crates/redisctl/src/commands/enterprise/actions.rs @@ -58,6 +58,7 @@ pub enum ActionCommands { } impl ActionCommands { + #[allow(dead_code)] pub async fn execute( &self, conn_mgr: &ConnectionManager, @@ -87,35 +88,32 @@ impl ActionCommands { let mut response = serde_json::to_value(&actions)?; // Apply filters if provided - if status.is_some() || action_type.is_some() { - if let Some(actions) = response.as_array_mut() { - actions.retain(|action| { - let mut keep = true; - - if let Some(status_filter) = status { - if let Some(action_status) = - action.get("status").and_then(|s| s.as_str()) - { - keep = - keep && action_status.eq_ignore_ascii_case(status_filter); - } else { - keep = false; - } + if (status.is_some() || action_type.is_some()) + && let Some(actions) = response.as_array_mut() + { + actions.retain(|action| { + let mut keep = true; + + if let Some(status_filter) = status { + if let Some(action_status) = + action.get("status").and_then(|s| s.as_str()) + { + keep = keep && action_status.eq_ignore_ascii_case(status_filter); + } else { + keep = false; } + } - if let Some(type_filter) = action_type { - if let Some(action_type) = - action.get("type").and_then(|t| t.as_str()) - { - keep = keep && action_type.eq_ignore_ascii_case(type_filter); - } else { - keep = false; - } + if let Some(type_filter) = action_type { + if let Some(action_type) = action.get("type").and_then(|t| t.as_str()) { + keep = keep && action_type.eq_ignore_ascii_case(type_filter); + } else { + keep = false; } + } - keep - }); - } + keep + }); } let output_data = if let Some(q) = query { @@ -240,6 +238,7 @@ impl ActionCommands { } } +#[allow(dead_code)] pub async fn handle_action_command( conn_mgr: &ConnectionManager, profile_name: Option<&str>, From a5041d556f3d601b8ca2f310943e058974055ecb Mon Sep 17 00:00:00 2001 From: Josh Rotenberg Date: Sat, 13 Sep 2025 11:33:06 -0700 Subject: [PATCH 3/3] docs(enterprise): add documentation for action commands --- docs/src/SUMMARY.md | 3 + docs/src/enterprise/actions.md | 172 +++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 docs/src/enterprise/actions.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 8e978cea..476269ca 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -35,6 +35,9 @@ - [Modules](./enterprise/modules.md) - [Logs](./enterprise/logs.md) - [Active-Active (CRDB)](./enterprise/crdb.md) +- [Actions (Tasks)](./enterprise/actions.md) +- [Diagnostics](./enterprise/diagnostics.md) +- [Job Scheduler](./enterprise/job-scheduler.md) - [Workflows](./enterprise/workflows.md) - [Raw API Access](./enterprise/api-access.md) diff --git a/docs/src/enterprise/actions.md b/docs/src/enterprise/actions.md new file mode 100644 index 00000000..a03dcbd4 --- /dev/null +++ b/docs/src/enterprise/actions.md @@ -0,0 +1,172 @@ +# Actions (Async Tasks) + +Actions in Redis Enterprise represent asynchronous operations or tasks that are running or have completed. The action commands allow you to monitor and manage these background operations. + +## Overview + +Many Redis Enterprise operations are asynchronous, returning an action ID that can be used to track progress. Actions include database creation/deletion, backup operations, imports/exports, and cluster maintenance tasks. + +## Available Commands + +### List All Actions + +List all actions in the cluster with optional filtering: + +```bash +# List all actions +redisctl enterprise action list + +# Filter by status +redisctl enterprise action list --status completed +redisctl enterprise action list --status running + +# Filter by type +redisctl enterprise action list --type bdb_backup + +# Combine filters +redisctl enterprise action list --status running --type bdb_import + +# Output as table +redisctl enterprise action list -o table +``` + +### Get Action Details + +Get detailed information about a specific action: + +```bash +# Get action by UID +redisctl enterprise action get + +# Get action with specific fields using JMESPath +redisctl enterprise action get -q "status" +``` + +### Check Action Status + +Quick status check for an action (returns just the status field): + +```bash +redisctl enterprise action status +``` + +### Cancel Running Action + +Cancel a running action: + +```bash +redisctl enterprise action cancel +``` + +### List Actions for Database + +List all actions for a specific database: + +```bash +redisctl enterprise action list-for-bdb + +# Filter by status for specific database +redisctl enterprise action list-for-bdb --status running +``` + +## Action Types + +Common action types you'll encounter: + +- `bdb_create` - Database creation +- `bdb_delete` - Database deletion +- `bdb_update` - Database configuration update +- `bdb_backup` - Database backup operation +- `bdb_import` - Database import operation +- `bdb_export` - Database export operation +- `crdb_create` - Active-Active database creation +- `node_join` - Node joining cluster +- `cluster_recovery` - Cluster recovery operation + +## Action Statuses + +Actions can have the following statuses: + +- `queued` - Action is queued for execution +- `running` - Action is currently executing +- `completed` - Action completed successfully +- `failed` - Action failed with errors +- `canceled` - Action was canceled + +## Examples + +### Monitor Database Creation + +```bash +# Create a database (returns action_uid) +ACTION_UID=$(redisctl enterprise database create --data @db.json -q "action_uid") + +# Check status +redisctl enterprise action status $ACTION_UID + +# Get full details when complete +redisctl enterprise action get $ACTION_UID +``` + +### List Recent Failed Actions + +```bash +# List failed actions in table format +redisctl enterprise action list --status failed -o table + +# Get details of a failed action +redisctl enterprise action get -q "{error: error_message, started: start_time}" +``` + +### Cancel Long-Running Import + +```bash +# List running imports +redisctl enterprise action list --status running --type bdb_import + +# Cancel specific import +redisctl enterprise action cancel +``` + +### Monitor All Database Actions + +```bash +# Watch all actions for a database +watch -n 5 "redisctl enterprise action list-for-bdb 1 -o table" +``` + +## Integration with Async Operations + +The action commands work seamlessly with the `--wait` flag available on create/update/delete operations: + +```bash +# This uses action monitoring internally +redisctl enterprise database create --data @db.json --wait + +# Equivalent to manually monitoring: +ACTION_UID=$(redisctl enterprise database create --data @db.json -q "action_uid") +while [ "$(redisctl enterprise action status $ACTION_UID)" = "running" ]; do + sleep 5 +done +``` + +## API Versions + +The action commands support both v1 and v2 API endpoints: +- v2 endpoints (`/v2/actions`) are preferred when available +- v1 endpoints (`/v1/actions`) are used as fallback +- Both return the same data structure + +## Best Practices + +1. **Always check action status** for async operations before proceeding +2. **Use filtering** to reduce output when listing many actions +3. **Save action UIDs** from create/update operations for tracking +4. **Set up monitoring** for critical long-running actions +5. **Check failed actions** for error details to diagnose issues + +## Related Commands + +- [`enterprise database`](./databases.md) - Database operations that create actions +- [`enterprise cluster`](./cluster.md) - Cluster operations that create actions +- [`enterprise crdb`](./crdb.md) - Active-Active operations that create actions \ No newline at end of file