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
59 changes: 59 additions & 0 deletions crates/redisctl/src/cli/cloud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1673,6 +1673,65 @@ NOTE: The costReportId is returned in the task response after the generation com
#[arg(long = "file", short = 'f')]
file: Option<String>,
},

/// Generate and download a cost report in one step
#[command(after_help = "EXAMPLES:
# Export January 2025 costs to CSV file
redisctl cloud cost-report export --start-date 2025-01-01 --end-date 2025-01-31 \\
--file january-costs.csv

# Export as JSON to stdout
redisctl cloud cost-report export --start-date 2025-01-01 --end-date 2025-01-31 \\
--format json

# Export filtered by subscription and tags
redisctl cloud cost-report export --start-date 2025-01-01 --end-date 2025-01-31 \\
--subscription 12345 --tag team:platform --file team-costs.csv

NOTE: This command combines 'generate --wait' and 'download' into a single operation.
The maximum date range is 40 days.
")]
Export {
/// Start date (YYYY-MM-DD format)
#[arg(long)]
start_date: String,

/// End date (YYYY-MM-DD format, max 40 days from start)
#[arg(long)]
end_date: String,

/// Output format (csv or json)
#[arg(long, value_parser = ["csv", "json"], default_value = "csv")]
format: String,

/// Output file path (defaults to stdout if not specified)
#[arg(long = "file", short = 'f')]
file: Option<String>,

/// Filter by subscription IDs (can be specified multiple times)
#[arg(long = "subscription", value_name = "ID")]
subscription_ids: Vec<i32>,

/// Filter by database IDs (can be specified multiple times)
#[arg(long = "database", value_name = "ID")]
database_ids: Vec<i32>,

/// Filter by subscription type (pro or essentials)
#[arg(long, value_parser = ["pro", "essentials"])]
subscription_type: Option<String>,

/// Filter by regions (can be specified multiple times)
#[arg(long = "region", value_name = "REGION")]
regions: Vec<String>,

/// Filter by tags (format: key:value, can be specified multiple times)
#[arg(long = "tag", value_name = "KEY:VALUE")]
tags: Vec<String>,

/// Maximum time to wait for report generation in seconds
#[arg(long, default_value = "300")]
timeout: u64,
},
}

/// Enterprise workflow commands
Expand Down
254 changes: 252 additions & 2 deletions crates/redisctl/src/commands/cloud/cost_report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
use crate::cli::{CloudCostReportCommands, OutputFormat};
use crate::commands::cloud::async_utils::{AsyncOperationArgs, handle_async_response};
use crate::connection::ConnectionManager;
use crate::error::Result as CliResult;
use crate::error::{RedisCtlError, Result as CliResult};
use anyhow::Context;
use indicatif::{ProgressBar, ProgressStyle};
use redis_cloud::cost_report::{CostReportCreateRequest, CostReportFormat, SubscriptionType, Tag};
use serde_json::json;
use serde_json::{Value, json};
use std::io::Write;
use std::time::{Duration, Instant};
use tokio::time::sleep;

/// Handle cost report commands
pub async fn handle_cost_report_command(
Expand Down Expand Up @@ -54,6 +57,35 @@ pub async fn handle_cost_report_command(
} => {
download_cost_report(conn_mgr, profile_name, cost_report_id, file, output_format).await
}
CloudCostReportCommands::Export {
start_date,
end_date,
format,
file,
subscription_ids,
database_ids,
subscription_type,
regions,
tags,
timeout,
} => {
export_cost_report(
conn_mgr,
profile_name,
start_date,
end_date,
format,
file,
subscription_ids,
database_ids,
subscription_type,
regions,
tags,
timeout,
output_format,
)
.await
}
}
}

Expand Down Expand Up @@ -218,3 +250,221 @@ async fn download_cost_report(

Ok(())
}

/// Export a cost report (generate + wait + download in one step)
#[allow(clippy::too_many_arguments)]
async fn export_cost_report(
conn_mgr: &ConnectionManager,
profile_name: Option<&str>,
start_date: String,
end_date: String,
format: String,
file: Option<String>,
subscription_ids: Vec<i32>,
database_ids: Vec<i32>,
subscription_type: Option<String>,
regions: Vec<String>,
tags: Vec<String>,
timeout: u64,
output_format: OutputFormat,
) -> CliResult<()> {
let client = conn_mgr.create_cloud_client(profile_name).await?;

// Build the request
let mut request = CostReportCreateRequest::new(&start_date, &end_date);

// Set format
request.format = Some(match format.as_str() {
"json" => CostReportFormat::Json,
_ => CostReportFormat::Csv,
});

// Set subscription IDs if provided
if !subscription_ids.is_empty() {
request.subscription_ids = Some(subscription_ids);
}

// Set database IDs if provided
if !database_ids.is_empty() {
request.database_ids = Some(database_ids);
}

// Set subscription type if provided
if let Some(sub_type) = subscription_type {
request.subscription_type = Some(match sub_type.as_str() {
"essentials" => SubscriptionType::Essentials,
_ => SubscriptionType::Pro,
});
}

// Set regions if provided
if !regions.is_empty() {
request.regions = Some(regions);
}

// Parse and set tags if provided
if !tags.is_empty() {
let parsed_tags: Vec<Tag> = tags
.iter()
.filter_map(|t| {
let parts: Vec<&str> = t.splitn(2, ':').collect();
if parts.len() == 2 {
Some(Tag::new(parts[0], parts[1]))
} else {
eprintln!("Warning: Invalid tag format '{}', expected 'key:value'", t);
None
}
})
.collect();
if !parsed_tags.is_empty() {
request.tags = Some(parsed_tags);
}
}

// Convert to JSON for the raw API call
let body = serde_json::to_value(&request).context("Failed to serialize request")?;

// Create progress bar
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg} [{elapsed_precise}]")
.unwrap(),
);
pb.set_message("Generating cost report...");

// Make the API call to generate the report
let response = client
.post_raw("/cost-report", body)
.await
.context("Failed to generate cost report")?;

// Extract task ID
let task_id = response
.get("taskId")
.and_then(|v| v.as_str())
.ok_or_else(|| RedisCtlError::InvalidInput {
message: "No taskId in response".to_string(),
})?;

pb.set_message(format!("Waiting for task {}...", task_id));

// Wait for task to complete and get the result
let task_result = wait_for_task_result(&client, task_id, timeout, &pb).await?;

// Extract cost report ID from task response
let cost_report_id = task_result
.get("response")
.and_then(|r| r.get("resource"))
.and_then(|r| r.get("costReportId"))
.and_then(|v| v.as_str())
.ok_or_else(|| RedisCtlError::InvalidInput {
message: "No costReportId in task response".to_string(),
})?;

pb.set_message(format!("Downloading report {}...", cost_report_id));

// Download the report
let bytes = client
.get_bytes(&format!("/cost-report/{}", cost_report_id))
.await?;

pb.finish_and_clear();

// Write output
match file {
Some(path) => {
std::fs::write(&path, &bytes)
.with_context(|| format!("Failed to write cost report to '{}'", path))?;

match output_format {
OutputFormat::Json => {
let result = json!({
"success": true,
"cost_report_id": cost_report_id,
"output_file": path,
"bytes_written": bytes.len(),
"date_range": {
"start": start_date,
"end": end_date
}
});
println!("{}", serde_json::to_string_pretty(&result)?);
}
_ => {
println!("Cost report exported to '{}' ({} bytes)", path, bytes.len());
}
}
}
None => {
// Write raw content to stdout
std::io::stdout()
.write_all(&bytes)
.context("Failed to write cost report to stdout")?;
}
}

Ok(())
}

/// Wait for a task to complete and return the final task state
async fn wait_for_task_result(
client: &redis_cloud::CloudClient,
task_id: &str,
timeout_secs: u64,
pb: &ProgressBar,
) -> CliResult<Value> {
let start = Instant::now();
let timeout = Duration::from_secs(timeout_secs);
let interval = Duration::from_secs(5);

loop {
let task = client
.get_raw(&format!("/tasks/{}", task_id))
.await
.map_err(|e| RedisCtlError::ApiError {
message: format!("Failed to fetch task {}: {}", task_id, e),
})?;

let state = task
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");

pb.set_message(format!("Task {}: {}", task_id, state));

// Check for terminal states
match state.to_lowercase().as_str() {
"completed" | "processing-completed" | "succeeded" | "success" => {
return Ok(task);
}
"failed" | "error" | "processing-error" => {
let description = task
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(RedisCtlError::ApiError {
message: format!("Task {} failed: {}", task_id, description),
});
}
"cancelled" => {
return Err(RedisCtlError::ApiError {
message: format!("Task {} was cancelled", task_id),
});
}
_ => {}
}

// Check timeout
if start.elapsed() > timeout {
return Err(RedisCtlError::Timeout {
message: format!(
"Task {} did not complete within {} seconds",
task_id, timeout_secs
),
});
}

sleep(interval).await;
}
}
Loading
Loading