Skip to content

Commit 4050023

Browse files
feat(enterprise): add module management commands (#242)
* feat(enterprise): add module management commands - Add module list, get, upload, delete, and config-bdb commands - Fix Module struct to match actual API response (module_name instead of name, version as u32) - Remove upgrade-bdb command as endpoint doesn't exist in API - Update tests to match new field names - Document correct config-bdb format in CLAUDE.md Note: upload command needs multipart/form-data implementation Implements #153 * feat(enterprise): implement multipart module upload with v2/v1 fallback - Add multipart/form-data support to reqwest client - Implement module upload using v2 endpoint with v1 fallback - Add proper error handling for missing upload endpoints - Update tests to handle v2/v1 fallback behavior - Document v1 vs v2 API strategy in CLAUDE.md - Note that module upload may not be available on all instances The upload command now properly handles multipart uploads and gracefully falls back from v2 to v1 when needed. * fix(enterprise): fix doc test for ModuleHandler The doc test incorrectly referenced v1() and v2() methods that don't exist. ModuleHandler methods are called directly, with upload() handling v2/v1 fallback internally.
1 parent 84c35e6 commit 4050023

File tree

11 files changed

+364
-124
lines changed

11 files changed

+364
-124
lines changed

Cargo.lock

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
5454
async-trait = "0.1"
5555

5656
# HTTP and APIs
57-
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
57+
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
5858
url = "2.5"
5959
base64 = "0.22"
6060
chrono = { version = "0.4", features = ["serde"] }

crates/redis-enterprise/src/client.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,34 @@ impl EnterpriseClient {
297297
}
298298
}
299299

300+
/// POST request with multipart/form-data for file uploads
301+
pub async fn post_multipart<T: DeserializeOwned>(
302+
&self,
303+
path: &str,
304+
file_data: Vec<u8>,
305+
field_name: &str,
306+
file_name: &str,
307+
) -> Result<T> {
308+
let url = format!("{}{}", self.base_url, path);
309+
debug!("POST {} (multipart)", url);
310+
311+
let part = reqwest::multipart::Part::bytes(file_data).file_name(file_name.to_string());
312+
313+
let form = reqwest::multipart::Form::new().part(field_name.to_string(), part);
314+
315+
let response = self
316+
.client
317+
.post(&url)
318+
.basic_auth(&self.username, Some(&self.password))
319+
.multipart(form)
320+
.send()
321+
.await
322+
.map_err(|e| self.map_reqwest_error(e, &url))?;
323+
324+
trace!("Response status: {}", response.status());
325+
self.handle_response(response).await
326+
}
327+
300328
/// Get a reference to self for handler construction
301329
pub fn rest_client(&self) -> Self {
302330
self.clone()

crates/redis-enterprise/src/lib.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,11 @@
167167
//! let v1_actions = actions.v1().list().await?; // GET /v1/actions
168168
//! let v2_actions = actions.v2().list().await?; // GET /v2/actions
169169
//!
170-
//! // Modules: v1 and v2
170+
//! // Modules
171171
//! let modules = ModuleHandler::new(client.clone());
172-
//! let all = modules.v1().list().await?; // GET /v1/modules
173-
//! // v2 upload body can be an opaque Value or multipart in a higher-level helper
174-
//! let uploaded = modules.v2().upload(serde_json::json!({"name": "search", "data": "..."})).await?;
172+
//! let all = modules.list().await?; // GET /v1/modules
173+
//! // Upload uses v2 endpoint with fallback to v1
174+
//! let uploaded = modules.upload(b"module_data".to_vec(), "module.zip").await?;
175175
//! # Ok(())
176176
//! # }
177177
//! ```
@@ -387,7 +387,7 @@ pub use nodes::{Node, NodeActionRequest, NodeHandler, NodeStats};
387387
pub use users::{CreateUserRequest, Role, RoleHandler, UpdateUserRequest, User, UserHandler};
388388

389389
// Module management
390-
pub use modules::{Module, ModuleHandler, UploadModuleRequest};
390+
pub use modules::{Module, ModuleHandler};
391391

392392
// Action tracking
393393
pub use actions::{Action, ActionHandler};

crates/redis-enterprise/src/modules.rs

Lines changed: 22 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ use serde_json::Value;
1414
#[derive(Debug, Clone, Serialize, Deserialize)]
1515
pub struct Module {
1616
pub uid: String,
17-
pub name: String,
18-
pub version: String,
17+
pub module_name: Option<String>,
18+
pub version: Option<u32>,
1919
pub semantic_version: Option<String>,
2020
pub author: Option<String>,
2121
pub description: Option<String>,
@@ -24,18 +24,13 @@ pub struct Module {
2424
pub command_line_args: Option<String>,
2525
pub capabilities: Option<Vec<String>>,
2626
pub min_redis_version: Option<String>,
27-
pub min_redis_pack_version: Option<String>,
28-
27+
pub compatible_redis_version: Option<String>,
28+
pub display_name: Option<String>,
29+
pub is_bundled: Option<bool>,
2930
#[serde(flatten)]
3031
pub extra: Value,
3132
}
3233

33-
/// Module upload request
34-
#[derive(Debug, Serialize)]
35-
pub struct UploadModuleRequest {
36-
pub module: Vec<u8>, // Binary module data
37-
}
38-
3934
/// Module handler for managing Redis modules
4035
pub struct ModuleHandler {
4136
client: RestClient,
@@ -59,14 +54,23 @@ impl ModuleHandler {
5954
self.client.get(&format!("/v1/modules/{}", uid)).await
6055
}
6156

62-
/// Upload new module
63-
pub async fn upload(&self, module_data: Vec<u8>) -> Result<Module> {
64-
// Note: This endpoint typically requires multipart/form-data
65-
// The actual implementation would need to handle file upload
66-
let request = UploadModuleRequest {
67-
module: module_data,
68-
};
69-
self.client.post("/v1/modules", &request).await
57+
/// Upload new module (tries v2 first, falls back to v1)
58+
pub async fn upload(&self, module_data: Vec<u8>, file_name: &str) -> Result<Value> {
59+
// Try v2 first (returns action_uid for async tracking)
60+
match self
61+
.client
62+
.post_multipart("/v2/modules", module_data.clone(), "module", file_name)
63+
.await
64+
{
65+
Ok(response) => Ok(response),
66+
Err(crate::error::RestError::NotFound) => {
67+
// v2 endpoint doesn't exist, try v1
68+
self.client
69+
.post_multipart("/v1/modules", module_data, "module", file_name)
70+
.await
71+
}
72+
Err(e) => Err(e),
73+
}
7074
}
7175

7276
/// Delete module
@@ -87,93 +91,4 @@ impl ModuleHandler {
8791
.post(&format!("/v1/modules/config/bdb/{}", bdb_uid), &config)
8892
.await
8993
}
90-
91-
/// Upgrade modules for a specific database - POST /v1/modules/upgrade/bdb/{uid}
92-
pub async fn upgrade_bdb(&self, bdb_uid: u32, body: Value) -> Result<Module> {
93-
self.client
94-
.post(&format!("/v1/modules/upgrade/bdb/{}", bdb_uid), &body)
95-
.await
96-
}
97-
98-
/// Upload module via v2 API - POST /v2/modules
99-
pub async fn upload_v2(&self, body: Value) -> Result<Module> {
100-
self.client.post("/v2/modules", &body).await
101-
}
102-
103-
/// Delete module via v2 API - DELETE /v2/modules/{uid}
104-
pub async fn delete_v2(&self, uid: &str) -> Result<()> {
105-
self.client.delete(&format!("/v2/modules/{}", uid)).await
106-
}
107-
108-
// Versioned accessors
109-
pub fn v1(&self) -> v1::ModulesV1 {
110-
v1::ModulesV1::new(self.client.clone())
111-
}
112-
113-
pub fn v2(&self) -> v2::ModulesV2 {
114-
v2::ModulesV2::new(self.client.clone())
115-
}
116-
}
117-
118-
pub mod v1 {
119-
use super::{Module, RestClient};
120-
use crate::error::Result;
121-
use serde_json::Value;
122-
123-
pub struct ModulesV1 {
124-
client: RestClient,
125-
}
126-
127-
impl ModulesV1 {
128-
pub(crate) fn new(client: RestClient) -> Self {
129-
Self { client }
130-
}
131-
132-
pub async fn list(&self) -> Result<Vec<Module>> {
133-
self.client.get("/v1/modules").await
134-
}
135-
136-
pub async fn get(&self, uid: &str) -> Result<Module> {
137-
self.client.get(&format!("/v1/modules/{}", uid)).await
138-
}
139-
140-
pub async fn upload(&self, data: Vec<u8>) -> Result<Module> {
141-
let body = serde_json::json!({ "module": data });
142-
self.client.post("/v1/modules", &body).await
143-
}
144-
145-
pub async fn delete(&self, uid: &str) -> Result<()> {
146-
self.client.delete(&format!("/v1/modules/{}", uid)).await
147-
}
148-
149-
pub async fn update(&self, uid: &str, updates: Value) -> Result<Module> {
150-
self.client
151-
.put(&format!("/v1/modules/{}", uid), &updates)
152-
.await
153-
}
154-
}
155-
}
156-
157-
pub mod v2 {
158-
use super::{Module, RestClient};
159-
use crate::error::Result;
160-
use serde_json::Value;
161-
162-
pub struct ModulesV2 {
163-
client: RestClient,
164-
}
165-
166-
impl ModulesV2 {
167-
pub(crate) fn new(client: RestClient) -> Self {
168-
Self { client }
169-
}
170-
171-
pub async fn upload(&self, body: Value) -> Result<Module> {
172-
self.client.post("/v2/modules", &body).await
173-
}
174-
175-
pub async fn delete(&self, uid: &str) -> Result<()> {
176-
self.client.delete(&format!("/v2/modules/{}", uid)).await
177-
}
178-
}
17994
}

crates/redis-enterprise/tests/module_tests.rs

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ fn no_content_response() -> ResponseTemplate {
2121
fn test_module() -> serde_json::Value {
2222
json!({
2323
"uid": "1",
24-
"name": "RedisSearch",
25-
"version": "2.6.1",
26-
"status": "loaded",
24+
"module_name": "RedisSearch",
25+
"version": 20601,
26+
"semantic_version": "2.6.1",
2727
"capabilities": ["search", "index"]
2828
})
2929
}
@@ -39,9 +39,9 @@ async fn test_module_list() {
3939
test_module(),
4040
{
4141
"uid": "2",
42-
"name": "RedisJSON",
43-
"version": "2.4.0",
44-
"status": "loaded",
42+
"module_name": "RedisJSON",
43+
"version": 20400,
44+
"semantic_version": "2.4.0",
4545
"capabilities": ["json"]
4646
}
4747
])))
@@ -87,13 +87,22 @@ async fn test_module_get() {
8787
assert!(result.is_ok());
8888
let module = result.unwrap();
8989
assert_eq!(module.uid, "1");
90-
assert_eq!(module.name, "RedisSearch");
90+
assert_eq!(module.module_name, Some("RedisSearch".to_string()));
9191
}
9292

9393
#[tokio::test]
9494
async fn test_module_upload() {
9595
let mock_server = MockServer::start().await;
9696

97+
// Mock v2 endpoint as not found
98+
Mock::given(method("POST"))
99+
.and(path("/v2/modules"))
100+
.and(basic_auth("admin", "password"))
101+
.respond_with(ResponseTemplate::new(404))
102+
.mount(&mock_server)
103+
.await;
104+
105+
// Mock v1 endpoint as success
97106
Mock::given(method("POST"))
98107
.and(path("/v1/modules"))
99108
.and(basic_auth("admin", "password"))
@@ -109,12 +118,13 @@ async fn test_module_upload() {
109118
.unwrap();
110119

111120
let handler = ModuleHandler::new(client);
112-
let result = handler.upload(vec![1, 2, 3, 4]).await; // Mock binary data
121+
let result = handler.upload(vec![1, 2, 3, 4], "test.zip").await; // Mock binary data
113122

114123
assert!(result.is_ok());
115-
let module = result.unwrap();
116-
assert_eq!(module.uid, "1");
117-
assert_eq!(module.name, "RedisSearch");
124+
let response = result.unwrap();
125+
// Response is now a Value, not a Module
126+
assert_eq!(response["uid"], "1");
127+
assert_eq!(response["module_name"], "RedisSearch");
118128
}
119129

120130
#[tokio::test]

crates/redisctl/src/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,10 @@ pub enum EnterpriseCommands {
992992
/// Log operations
993993
#[command(subcommand)]
994994
Logs(crate::commands::enterprise::logs::LogsCommands),
995+
996+
/// Module management operations
997+
#[command(subcommand)]
998+
Module(crate::commands::enterprise::module::ModuleCommands),
995999
}
9961000

9971001
// Placeholder command structures - will be expanded in later PRs

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ pub mod database;
88
pub mod database_impl;
99
pub mod logs;
1010
pub mod logs_impl;
11+
pub mod module;
12+
pub mod module_impl;
1113
pub mod node;
1214
pub mod node_impl;
1315
pub mod rbac;

0 commit comments

Comments
 (0)