Skip to content

Commit ff0b844

Browse files
feat: add database upgrade command for Redis version upgrades (#442)
* feat: add database upgrade command for Redis version upgrades - Add DatabaseUpgradeRequest and ModuleUpgrade types to redis-enterprise library - Add upgrade_redis_version() method to DatabaseHandler with full parameter support - Implement 'redisctl enterprise database upgrade' CLI command - Add safety checks for database status, persistence, and replication - Support --version, --preserve-roles, --force-restart and other upgrade options - Add structured JSON output support - Add CLI test for upgrade command help - Addresses issue #422 * test: add integration test for database upgrade_redis_version method * test: update upgrade test with real API response from Docker cluster The actual API returns the full BDB object with action_uid embedded, not just action_uid + description. Updated mock to match real response captured from Docker cluster test. * fix(clippy): use derive for Default impl on OutputFormat Rust 1.91 introduced a new lint 'derivable_impls' that flags manual Default implementations that can be derived. Changed OutputFormat to use #[derive(Default)] with #[default] attribute on the Json variant. Same fix as applied to PR #444 to pass CI on Rust 1.91.0. * fix(redis-enterprise): correct doctest imports for upgrade_redis_version Use EnterpriseClient and BdbHandler which are properly exported from the crate root, instead of internal Client type.
1 parent ae043d7 commit ff0b844

File tree

6 files changed

+350
-0
lines changed

6 files changed

+350
-0
lines changed

crates/redis-enterprise/src/bdb.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,55 @@ pub struct ExportResponse {
133133
pub extra: Value,
134134
}
135135

136+
/// Module information for database upgrade
137+
#[derive(Debug, Clone, Serialize, Deserialize)]
138+
pub struct ModuleUpgrade {
139+
/// Module name
140+
pub module_name: String,
141+
/// Module version
142+
#[serde(skip_serializing_if = "Option::is_none")]
143+
pub new_version: Option<String>,
144+
/// Module arguments
145+
#[serde(skip_serializing_if = "Option::is_none")]
146+
pub module_args: Option<String>,
147+
}
148+
149+
/// Request for database upgrade operation
150+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
151+
pub struct DatabaseUpgradeRequest {
152+
/// Target Redis version (optional, defaults to latest)
153+
#[serde(skip_serializing_if = "Option::is_none")]
154+
pub redis_version: Option<String>,
155+
156+
/// Preserve master/replica roles (requires extra failover)
157+
#[serde(skip_serializing_if = "Option::is_none")]
158+
pub preserve_roles: Option<bool>,
159+
160+
/// Restart shards even if no version change
161+
#[serde(skip_serializing_if = "Option::is_none")]
162+
pub force_restart: Option<bool>,
163+
164+
/// Allow data loss in non-replicated, non-persistent databases
165+
#[serde(skip_serializing_if = "Option::is_none")]
166+
pub may_discard_data: Option<bool>,
167+
168+
/// Force data discard even if replicated/persistent
169+
#[serde(skip_serializing_if = "Option::is_none")]
170+
pub force_discard: Option<bool>,
171+
172+
/// Keep current CRDT protocol version
173+
#[serde(skip_serializing_if = "Option::is_none")]
174+
pub keep_crdt_protocol_version: Option<bool>,
175+
176+
/// Maximum parallel shard upgrades (default: all shards)
177+
#[serde(skip_serializing_if = "Option::is_none")]
178+
pub parallel_shards_upgrade: Option<u32>,
179+
180+
/// Modules to upgrade alongside Redis
181+
#[serde(skip_serializing_if = "Option::is_none")]
182+
pub modules: Option<Vec<ModuleUpgrade>>,
183+
}
184+
136185
/// Database information from the REST API - 100% field coverage (152/152 fields)
137186
#[derive(Debug, Clone, Serialize, Deserialize)]
138187
pub struct DatabaseInfo {
@@ -856,6 +905,50 @@ impl DatabaseHandler {
856905
.await
857906
}
858907

908+
/// Upgrade database Redis version and/or modules (BDB.UPGRADE)
909+
///
910+
/// # Examples
911+
///
912+
/// ```no_run
913+
/// # use redis_enterprise::EnterpriseClient;
914+
/// # use redis_enterprise::bdb::{BdbHandler, DatabaseUpgradeRequest};
915+
/// # async fn example() -> redis_enterprise::Result<()> {
916+
/// let client = EnterpriseClient::builder()
917+
/// .base_url("https://localhost:9443")
918+
/// .username("admin")
919+
/// .password("password")
920+
/// .insecure(true)
921+
/// .build()?;
922+
/// let db_handler = BdbHandler::new(client);
923+
///
924+
/// // Upgrade to latest Redis version
925+
/// let request = DatabaseUpgradeRequest {
926+
/// redis_version: None, // defaults to latest
927+
/// preserve_roles: Some(true),
928+
/// ..Default::default()
929+
/// };
930+
/// db_handler.upgrade_redis_version(1, request).await?;
931+
///
932+
/// // Upgrade to specific Redis version
933+
/// let request = DatabaseUpgradeRequest {
934+
/// redis_version: Some("7.4.2".to_string()),
935+
/// preserve_roles: Some(true),
936+
/// ..Default::default()
937+
/// };
938+
/// db_handler.upgrade_redis_version(1, request).await?;
939+
/// # Ok(())
940+
/// # }
941+
/// ```
942+
pub async fn upgrade_redis_version(
943+
&self,
944+
uid: u32,
945+
request: DatabaseUpgradeRequest,
946+
) -> Result<DatabaseActionResponse> {
947+
self.client
948+
.post(&format!("/v1/bdbs/{}/upgrade", uid), &request)
949+
.await
950+
}
951+
859952
/// Reset database password (BDB.RESET_PASSWORD)
860953
pub async fn reset_password(
861954
&self,

crates/redis-enterprise/tests/database_tests.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,3 +453,62 @@ async fn test_passwords_delete_and_reset_status() {
453453
let reset = handler.backup_reset_status(1).await.unwrap();
454454
assert_eq!(reset["status"], "reset");
455455
}
456+
457+
#[tokio::test]
458+
async fn test_database_upgrade_redis_version() {
459+
let mock_server = MockServer::start().await;
460+
461+
// Mock data captured from: curl -k -u "[email protected]:Redis123!" -X POST \
462+
// -H "Content-Type: application/json" -d '{"force_restart": true}' \
463+
// https://localhost:9443/v1/bdbs/1/upgrade
464+
// Real API returns full BDB object with action_uid embedded
465+
Mock::given(method("POST"))
466+
.and(path("/v1/bdbs/1/upgrade"))
467+
.and(basic_auth("admin", "password"))
468+
.respond_with(success_response(json!({
469+
"action_uid": "591d9dcb-ddd7-48a9-a04d-bd5d4d6834d0",
470+
"uid": 1,
471+
"name": "test-db",
472+
"status": "active",
473+
"redis_version": "7.4",
474+
"version": "7.4.2",
475+
"memory_size": 1073741824,
476+
"type": "redis",
477+
"replication": false,
478+
"persistence": "disabled",
479+
"port": 18367,
480+
"shards_count": 1,
481+
"oss_cluster": false
482+
})))
483+
.mount(&mock_server)
484+
.await;
485+
486+
let client = EnterpriseClient::builder()
487+
.base_url(mock_server.uri())
488+
.username("admin")
489+
.password("password")
490+
.build()
491+
.unwrap();
492+
493+
let handler = BdbHandler::new(client);
494+
495+
let request = redis_enterprise::bdb::DatabaseUpgradeRequest {
496+
redis_version: Some("7.4.2".to_string()),
497+
preserve_roles: Some(true),
498+
force_restart: Some(false),
499+
may_discard_data: Some(false),
500+
force_discard: Some(false),
501+
keep_crdt_protocol_version: Some(false),
502+
parallel_shards_upgrade: None,
503+
modules: None,
504+
};
505+
506+
let result = handler.upgrade_redis_version(1, request).await;
507+
assert!(result.is_ok());
508+
let response = result.unwrap();
509+
assert_eq!(response.action_uid, "591d9dcb-ddd7-48a9-a04d-bd5d4d6834d0");
510+
// Verify the flattened extra fields are captured
511+
assert_eq!(response.extra["uid"], 1);
512+
assert_eq!(response.extra["name"], "test-db");
513+
assert_eq!(response.extra["redis_version"], "7.4");
514+
}

crates/redisctl/src/cli.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2196,6 +2196,36 @@ pub enum EnterpriseDatabaseCommands {
21962196
data: String,
21972197
},
21982198

2199+
/// Upgrade database Redis version
2200+
Upgrade {
2201+
/// Database ID
2202+
id: u32,
2203+
/// Target Redis version (defaults to latest)
2204+
#[arg(long)]
2205+
version: Option<String>,
2206+
/// Preserve master/replica roles (requires extra failover)
2207+
#[arg(long)]
2208+
preserve_roles: bool,
2209+
/// Restart shards even if no version change
2210+
#[arg(long)]
2211+
force_restart: bool,
2212+
/// Allow data loss in non-replicated, non-persistent databases
2213+
#[arg(long)]
2214+
may_discard_data: bool,
2215+
/// Force data discard even if replicated/persistent
2216+
#[arg(long)]
2217+
force_discard: bool,
2218+
/// Keep current CRDT protocol version
2219+
#[arg(long)]
2220+
keep_crdt_protocol_version: bool,
2221+
/// Maximum parallel shard upgrades
2222+
#[arg(long)]
2223+
parallel_shards_upgrade: Option<u32>,
2224+
/// Skip confirmation prompt
2225+
#[arg(long)]
2226+
force: bool,
2227+
},
2228+
21992229
/// Get ACL configuration
22002230
GetAcl {
22012231
/// Database ID

crates/redisctl/src/commands/enterprise/database.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,34 @@ pub async fn handle_database_command(
9898
)
9999
.await
100100
}
101+
EnterpriseDatabaseCommands::Upgrade {
102+
id,
103+
version,
104+
preserve_roles,
105+
force_restart,
106+
may_discard_data,
107+
force_discard,
108+
keep_crdt_protocol_version,
109+
parallel_shards_upgrade,
110+
force,
111+
} => {
112+
database_impl::upgrade_database(
113+
conn_mgr,
114+
profile_name,
115+
*id,
116+
version.as_deref(),
117+
*preserve_roles,
118+
*force_restart,
119+
*may_discard_data,
120+
*force_discard,
121+
*keep_crdt_protocol_version,
122+
*parallel_shards_upgrade,
123+
*force,
124+
output_format,
125+
query,
126+
)
127+
.await
128+
}
101129
EnterpriseDatabaseCommands::GetAcl { id } => {
102130
database_impl::get_database_acl(conn_mgr, profile_name, *id, output_format, query).await
103131
}

crates/redisctl/src/commands/enterprise/database_impl.rs

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,3 +440,129 @@ pub async fn get_database_clients(
440440
print_formatted_output(data, output_format)?;
441441
Ok(())
442442
}
443+
444+
/// Upgrade database Redis version
445+
#[allow(clippy::too_many_arguments)]
446+
pub async fn upgrade_database(
447+
conn_mgr: &ConnectionManager,
448+
profile_name: Option<&str>,
449+
id: u32,
450+
version: Option<&str>,
451+
preserve_roles: bool,
452+
force_restart: bool,
453+
may_discard_data: bool,
454+
force_discard: bool,
455+
keep_crdt_protocol_version: bool,
456+
parallel_shards_upgrade: Option<u32>,
457+
force: bool,
458+
output_format: OutputFormat,
459+
query: Option<&str>,
460+
) -> CliResult<()> {
461+
use redis_enterprise::bdb::{DatabaseHandler, DatabaseInfo, DatabaseUpgradeRequest};
462+
463+
let client = conn_mgr.create_enterprise_client(profile_name).await?;
464+
465+
// Get current database info
466+
let db_handler = DatabaseHandler::new(client);
467+
let db: DatabaseInfo = db_handler.get(id).await?;
468+
let current_version = db.redis_version.as_deref().unwrap_or("unknown");
469+
470+
// Determine target version
471+
let target_version = if let Some(v) = version {
472+
v.to_string()
473+
} else {
474+
// Get latest version from cluster - for now just use current
475+
// TODO: Get from cluster info when we add that endpoint
476+
current_version.to_string()
477+
};
478+
479+
// Safety checks unless --force
480+
if !force {
481+
// Check if database is active
482+
if db.status.as_deref() != Some("active") {
483+
return Err(RedisCtlError::InvalidInput {
484+
message: format!(
485+
"Database is not active (status: {}). Use --force to upgrade anyway.",
486+
db.status.as_deref().unwrap_or("unknown")
487+
),
488+
});
489+
}
490+
491+
// Warn about persistence (check if persistence is disabled/none)
492+
let has_persistence = db
493+
.persistence
494+
.as_deref()
495+
.map(|p| p != "disabled")
496+
.unwrap_or(false);
497+
if !has_persistence && !may_discard_data {
498+
eprintln!("Warning: Database has no persistence enabled.");
499+
eprintln!("If upgrade fails, data may be lost.");
500+
eprintln!("Use --may-discard-data to proceed.");
501+
return Err(RedisCtlError::InvalidInput {
502+
message: "Upgrade cancelled for safety".to_string(),
503+
});
504+
}
505+
506+
// Warn about replication (check if replication is enabled)
507+
let has_replication = db.replication.unwrap_or(false);
508+
if !has_replication {
509+
eprintln!("Warning: Database has no replication enabled.");
510+
eprintln!("Upgrade will cause downtime.");
511+
eprintln!("Use --force to proceed.");
512+
return Err(RedisCtlError::InvalidInput {
513+
message: "Upgrade cancelled for safety".to_string(),
514+
});
515+
}
516+
}
517+
518+
// Display upgrade info
519+
if matches!(output_format, OutputFormat::Table | OutputFormat::Auto) {
520+
println!("Upgrading database '{}' (db:{})...", db.name, id);
521+
println!(" Current version: {}", current_version);
522+
println!(" Target version: {}", target_version);
523+
}
524+
525+
// Build upgrade request
526+
let request = DatabaseUpgradeRequest {
527+
redis_version: Some(target_version.clone()),
528+
preserve_roles: Some(preserve_roles),
529+
force_restart: Some(force_restart),
530+
may_discard_data: Some(may_discard_data),
531+
force_discard: Some(force_discard),
532+
keep_crdt_protocol_version: Some(keep_crdt_protocol_version),
533+
parallel_shards_upgrade,
534+
modules: None,
535+
};
536+
537+
// Call upgrade API
538+
let response = db_handler.upgrade_redis_version(id, request).await?;
539+
540+
// Handle output
541+
match output_format {
542+
OutputFormat::Json => {
543+
let output = serde_json::json!({
544+
"database_id": id,
545+
"database_name": db.name,
546+
"old_version": current_version,
547+
"new_version": target_version,
548+
"action_uid": response.action_uid,
549+
"status": "upgrade_initiated"
550+
});
551+
println!("{}", serde_json::to_string_pretty(&output)?);
552+
}
553+
OutputFormat::Table | OutputFormat::Auto => {
554+
println!("Upgrade initiated (action_uid: {})", response.action_uid);
555+
println!(
556+
"Use 'redisctl enterprise database get {}' to check status",
557+
id
558+
);
559+
}
560+
_ => {
561+
let data = serde_json::to_value(&response)?;
562+
let filtered = handle_output(data, output_format, query)?;
563+
print_formatted_output(filtered, output_format)?;
564+
}
565+
}
566+
567+
Ok(())
568+
}

crates/redisctl/tests/cli_basic_tests.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,20 @@ fn test_profile_remove_missing_name() {
258258
.stderr(predicate::str::contains("required"));
259259
}
260260

261+
#[test]
262+
fn test_enterprise_database_upgrade_help() {
263+
redisctl()
264+
.arg("enterprise")
265+
.arg("database")
266+
.arg("upgrade")
267+
.arg("--help")
268+
.assert()
269+
.success()
270+
.stdout(predicate::str::contains("Upgrade database Redis version"))
271+
.stdout(predicate::str::contains("--version"))
272+
.stdout(predicate::str::contains("--preserve-roles"));
273+
}
274+
261275
#[test]
262276
fn test_payment_method_help() {
263277
redisctl()

0 commit comments

Comments
 (0)