Skip to content

Commit 4c808d1

Browse files
authored
feat(phase-5): add bucket quota command and bucket tag support (#15)
1 parent 9c0f2d8 commit 4c808d1

10 files changed

Lines changed: 748 additions & 141 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,8 @@ rc admin heal status local --json
169169
| `share` | Generate presigned URLs |
170170
| `pipe` | Upload from stdin |
171171
| `version` | Manage bucket versioning |
172-
| `tag` | Manage object tags |
172+
| `tag` | Manage bucket and object tags |
173+
| `quota` | Manage bucket quota |
173174
| `completions` | Generate shell completion scripts |
174175

175176
### Admin Subcommands

crates/cli/src/commands/mod.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ mod mb;
2222
mod mirror;
2323
mod mv;
2424
mod pipe;
25+
mod quota;
2526
mod rb;
2627
mod rm;
2728
mod share;
@@ -126,10 +127,14 @@ pub enum Commands {
126127
#[command(subcommand)]
127128
Version(version::VersionCommands),
128129

129-
/// Manage object tags
130+
/// Manage bucket and object tags
130131
#[command(subcommand)]
131132
Tag(tag::TagCommands),
132133

134+
/// Manage bucket quota
135+
#[command(subcommand)]
136+
Quota(quota::QuotaCommands),
137+
133138
// Phase 6: Utilities
134139
/// Generate shell completion scripts
135140
Completions(completions::CompletionsArgs),
@@ -172,6 +177,9 @@ pub async fn execute(cli: Cli) -> ExitCode {
172177
version::execute(version::VersionArgs { command: cmd }, output_config).await
173178
}
174179
Commands::Tag(cmd) => tag::execute(tag::TagArgs { command: cmd }, output_config).await,
180+
Commands::Quota(cmd) => {
181+
quota::execute(quota::QuotaArgs { command: cmd }, output_config).await
182+
}
175183
Commands::Completions(args) => completions::execute(args),
176184
}
177185
}

crates/cli/src/commands/quota.rs

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
//! quota command - Manage bucket quotas
2+
//!
3+
//! Set, inspect, or clear quota on a bucket.
4+
5+
use clap::{Args, Subcommand};
6+
use rc_core::admin::{AdminApi, BucketQuota};
7+
use serde::Serialize;
8+
9+
use crate::exit_code::ExitCode;
10+
use crate::output::{Formatter, OutputConfig};
11+
12+
use super::admin::get_admin_client;
13+
14+
/// Manage bucket quota
15+
#[derive(Args, Debug)]
16+
pub struct QuotaArgs {
17+
#[command(subcommand)]
18+
pub command: QuotaCommands,
19+
}
20+
21+
#[derive(Subcommand, Debug)]
22+
pub enum QuotaCommands {
23+
/// Set bucket quota
24+
Set(SetQuotaArgs),
25+
26+
/// Show bucket quota information
27+
Info(BucketArg),
28+
29+
/// Clear bucket quota
30+
Clear(BucketArg),
31+
}
32+
33+
#[derive(Args, Debug)]
34+
pub struct BucketArg {
35+
/// Bucket path (alias/bucket)
36+
pub path: String,
37+
}
38+
39+
#[derive(Args, Debug)]
40+
pub struct SetQuotaArgs {
41+
/// Bucket path (alias/bucket)
42+
pub path: String,
43+
44+
/// Quota value (bytes or units like 1G, 500M, 10KB)
45+
pub size: String,
46+
}
47+
48+
#[derive(Debug, Serialize)]
49+
#[serde(rename_all = "camelCase")]
50+
struct QuotaOutput {
51+
bucket: String,
52+
quota: Option<u64>,
53+
quota_human: Option<String>,
54+
usage: u64,
55+
usage_human: String,
56+
quota_type: String,
57+
}
58+
59+
/// Execute the quota command
60+
pub async fn execute(args: QuotaArgs, output_config: OutputConfig) -> ExitCode {
61+
match args.command {
62+
QuotaCommands::Set(set_args) => execute_set(set_args, output_config).await,
63+
QuotaCommands::Info(bucket_arg) => execute_info(bucket_arg, output_config).await,
64+
QuotaCommands::Clear(bucket_arg) => execute_clear(bucket_arg, output_config).await,
65+
}
66+
}
67+
68+
async fn execute_set(args: SetQuotaArgs, output_config: OutputConfig) -> ExitCode {
69+
let formatter = Formatter::new(output_config);
70+
71+
let (alias_name, bucket) = match parse_bucket_path(&args.path) {
72+
Ok(parts) => parts,
73+
Err(err) => {
74+
formatter.error(&err);
75+
return ExitCode::UsageError;
76+
}
77+
};
78+
79+
let quota_bytes = match parse_quota_size(&args.size) {
80+
Ok(size) => size,
81+
Err(err) => {
82+
formatter.error(&err);
83+
return ExitCode::UsageError;
84+
}
85+
};
86+
87+
let client = match get_admin_client(&alias_name, &formatter) {
88+
Ok(client) => client,
89+
Err(code) => return code,
90+
};
91+
92+
match client.set_bucket_quota(&bucket, quota_bytes).await {
93+
Ok(quota) => {
94+
print_quota_result(&formatter, &quota);
95+
ExitCode::Success
96+
}
97+
Err(err) => {
98+
formatter.error(&format!("Failed to set bucket quota: {err}"));
99+
exit_code_from_error(&err)
100+
}
101+
}
102+
}
103+
104+
async fn execute_info(args: BucketArg, output_config: OutputConfig) -> ExitCode {
105+
let formatter = Formatter::new(output_config);
106+
107+
let (alias_name, bucket) = match parse_bucket_path(&args.path) {
108+
Ok(parts) => parts,
109+
Err(err) => {
110+
formatter.error(&err);
111+
return ExitCode::UsageError;
112+
}
113+
};
114+
115+
let client = match get_admin_client(&alias_name, &formatter) {
116+
Ok(client) => client,
117+
Err(code) => return code,
118+
};
119+
120+
match client.get_bucket_quota(&bucket).await {
121+
Ok(quota) => {
122+
print_quota_result(&formatter, &quota);
123+
ExitCode::Success
124+
}
125+
Err(err) => {
126+
formatter.error(&format!("Failed to get bucket quota: {err}"));
127+
exit_code_from_error(&err)
128+
}
129+
}
130+
}
131+
132+
async fn execute_clear(args: BucketArg, output_config: OutputConfig) -> ExitCode {
133+
let formatter = Formatter::new(output_config);
134+
135+
let (alias_name, bucket) = match parse_bucket_path(&args.path) {
136+
Ok(parts) => parts,
137+
Err(err) => {
138+
formatter.error(&err);
139+
return ExitCode::UsageError;
140+
}
141+
};
142+
143+
let client = match get_admin_client(&alias_name, &formatter) {
144+
Ok(client) => client,
145+
Err(code) => return code,
146+
};
147+
148+
match client.clear_bucket_quota(&bucket).await {
149+
Ok(quota) => {
150+
print_quota_result(&formatter, &quota);
151+
ExitCode::Success
152+
}
153+
Err(err) => {
154+
formatter.error(&format!("Failed to clear bucket quota: {err}"));
155+
exit_code_from_error(&err)
156+
}
157+
}
158+
}
159+
160+
fn print_quota_result(formatter: &Formatter, quota: &BucketQuota) {
161+
if formatter.is_json() {
162+
formatter.json(&QuotaOutput {
163+
bucket: quota.bucket.clone(),
164+
quota: quota.quota,
165+
quota_human: quota.quota.map(format_human_size),
166+
usage: quota.size,
167+
usage_human: format_human_size(quota.size),
168+
quota_type: quota.quota_type.clone(),
169+
});
170+
return;
171+
}
172+
173+
formatter.println(&format!("Bucket: {}", quota.bucket));
174+
let limit_text = quota
175+
.quota
176+
.map(format_human_size)
177+
.unwrap_or_else(|| "unlimited".to_string());
178+
formatter.println(&format!("Quota: {limit_text}"));
179+
formatter.println(&format!("Usage: {}", format_human_size(quota.size)));
180+
formatter.println(&format!("Type: {}", quota.quota_type));
181+
}
182+
183+
fn parse_bucket_path(path: &str) -> Result<(String, String), String> {
184+
if path.trim().is_empty() {
185+
return Err("Path cannot be empty".to_string());
186+
}
187+
188+
let parts: Vec<&str> = path.splitn(2, '/').collect();
189+
190+
if parts.len() < 2 || parts[0].is_empty() {
191+
return Err("Alias name is required (alias/bucket)".to_string());
192+
}
193+
194+
let bucket = parts[1].trim_end_matches('/');
195+
if bucket.is_empty() {
196+
return Err("Bucket name is required (alias/bucket)".to_string());
197+
}
198+
199+
Ok((parts[0].to_string(), bucket.to_string()))
200+
}
201+
202+
fn parse_quota_size(value: &str) -> Result<u64, String> {
203+
let value = value.trim();
204+
if value.is_empty() {
205+
return Err("Quota size cannot be empty".to_string());
206+
}
207+
208+
let split_index = value
209+
.find(|ch: char| !ch.is_ascii_digit())
210+
.unwrap_or(value.len());
211+
212+
let (number_part, unit_part) = value.split_at(split_index);
213+
if number_part.is_empty() {
214+
return Err(format!("Invalid quota size: '{value}'"));
215+
}
216+
217+
let number = number_part
218+
.parse::<u64>()
219+
.map_err(|_| format!("Invalid quota size number: '{number_part}'"))?;
220+
221+
let multiplier = match unit_part.trim().to_uppercase().as_str() {
222+
"" | "B" => 1,
223+
"K" | "KB" | "KIB" => 1024,
224+
"M" | "MB" | "MIB" => 1024 * 1024,
225+
"G" | "GB" | "GIB" => 1024 * 1024 * 1024,
226+
"T" | "TB" | "TIB" => 1024_u64.pow(4),
227+
_ => return Err(format!("Invalid quota size unit: '{unit_part}'")),
228+
};
229+
230+
number
231+
.checked_mul(multiplier)
232+
.ok_or_else(|| format!("Quota size is too large: '{value}'"))
233+
}
234+
235+
fn format_human_size(bytes: u64) -> String {
236+
humansize::format_size(bytes, humansize::BINARY)
237+
}
238+
239+
fn exit_code_from_error(error: &rc_core::Error) -> ExitCode {
240+
ExitCode::from_i32(error.exit_code()).unwrap_or(ExitCode::GeneralError)
241+
}
242+
243+
#[cfg(test)]
244+
mod tests {
245+
use super::*;
246+
247+
#[test]
248+
fn test_parse_bucket_path() {
249+
let (alias, bucket) = parse_bucket_path("local/my-bucket").unwrap();
250+
assert_eq!(alias, "local");
251+
assert_eq!(bucket, "my-bucket");
252+
253+
let (alias, bucket) = parse_bucket_path("local/my-bucket/").unwrap();
254+
assert_eq!(alias, "local");
255+
assert_eq!(bucket, "my-bucket");
256+
}
257+
258+
#[test]
259+
fn test_parse_bucket_path_errors() {
260+
assert!(parse_bucket_path("").is_err());
261+
assert!(parse_bucket_path("local").is_err());
262+
assert!(parse_bucket_path("/my-bucket").is_err());
263+
assert!(parse_bucket_path("local/").is_err());
264+
}
265+
266+
#[test]
267+
fn test_parse_quota_size() {
268+
assert_eq!(parse_quota_size("1024").unwrap(), 1024);
269+
assert_eq!(parse_quota_size("1K").unwrap(), 1024);
270+
assert_eq!(parse_quota_size("1KB").unwrap(), 1024);
271+
assert_eq!(parse_quota_size("1M").unwrap(), 1024 * 1024);
272+
assert_eq!(parse_quota_size("2G").unwrap(), 2 * 1024 * 1024 * 1024);
273+
}
274+
275+
#[test]
276+
fn test_parse_quota_size_errors() {
277+
assert!(parse_quota_size("").is_err());
278+
assert!(parse_quota_size("abc").is_err());
279+
assert!(parse_quota_size("1X").is_err());
280+
}
281+
282+
#[tokio::test]
283+
async fn test_execute_set_invalid_path_returns_usage_error() {
284+
let args = QuotaArgs {
285+
command: QuotaCommands::Set(SetQuotaArgs {
286+
path: "invalid-path".to_string(),
287+
size: "1G".to_string(),
288+
}),
289+
};
290+
291+
let code = execute(args, OutputConfig::default()).await;
292+
assert_eq!(code, ExitCode::UsageError);
293+
}
294+
295+
#[tokio::test]
296+
async fn test_execute_set_invalid_size_returns_usage_error() {
297+
let args = QuotaArgs {
298+
command: QuotaCommands::Set(SetQuotaArgs {
299+
path: "local/my-bucket".to_string(),
300+
size: "1X".to_string(),
301+
}),
302+
};
303+
304+
let code = execute(args, OutputConfig::default()).await;
305+
assert_eq!(code, ExitCode::UsageError);
306+
}
307+
}

0 commit comments

Comments
 (0)