From 056a47c530a1919f11bb78fb041909138bc18b82 Mon Sep 17 00:00:00 2001 From: overtrue Date: Tue, 24 Feb 2026 11:09:02 +0800 Subject: [PATCH] feat(phase-5): add bucket quota command and bucket tag support --- README.md | 3 +- crates/cli/src/commands/mod.rs | 10 +- crates/cli/src/commands/quota.rs | 307 ++++++++++++++++++++++++++ crates/cli/src/commands/tag.rs | 367 +++++++++++++++++++++---------- crates/core/src/admin/mod.rs | 13 +- crates/core/src/admin/types.rs | 35 +++ crates/core/src/traits.rs | 16 ++ crates/s3/src/admin.rs | 37 +++- crates/s3/src/client.rs | 98 +++++++-- docs/TEST_MATRIX.md | 3 +- 10 files changed, 748 insertions(+), 141 deletions(-) create mode 100644 crates/cli/src/commands/quota.rs diff --git a/README.md b/README.md index 82dbcf6..20ff4fb 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,8 @@ rc admin heal status local --json | `share` | Generate presigned URLs | | `pipe` | Upload from stdin | | `version` | Manage bucket versioning | -| `tag` | Manage object tags | +| `tag` | Manage bucket and object tags | +| `quota` | Manage bucket quota | | `completions` | Generate shell completion scripts | ### Admin Subcommands diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 703e318..48325a1 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -22,6 +22,7 @@ mod mb; mod mirror; mod mv; mod pipe; +mod quota; mod rb; mod rm; mod share; @@ -126,10 +127,14 @@ pub enum Commands { #[command(subcommand)] Version(version::VersionCommands), - /// Manage object tags + /// Manage bucket and object tags #[command(subcommand)] Tag(tag::TagCommands), + /// Manage bucket quota + #[command(subcommand)] + Quota(quota::QuotaCommands), + // Phase 6: Utilities /// Generate shell completion scripts Completions(completions::CompletionsArgs), @@ -172,6 +177,9 @@ pub async fn execute(cli: Cli) -> ExitCode { version::execute(version::VersionArgs { command: cmd }, output_config).await } Commands::Tag(cmd) => tag::execute(tag::TagArgs { command: cmd }, output_config).await, + Commands::Quota(cmd) => { + quota::execute(quota::QuotaArgs { command: cmd }, output_config).await + } Commands::Completions(args) => completions::execute(args), } } diff --git a/crates/cli/src/commands/quota.rs b/crates/cli/src/commands/quota.rs new file mode 100644 index 0000000..233ced8 --- /dev/null +++ b/crates/cli/src/commands/quota.rs @@ -0,0 +1,307 @@ +//! quota command - Manage bucket quotas +//! +//! Set, inspect, or clear quota on a bucket. + +use clap::{Args, Subcommand}; +use rc_core::admin::{AdminApi, BucketQuota}; +use serde::Serialize; + +use crate::exit_code::ExitCode; +use crate::output::{Formatter, OutputConfig}; + +use super::admin::get_admin_client; + +/// Manage bucket quota +#[derive(Args, Debug)] +pub struct QuotaArgs { + #[command(subcommand)] + pub command: QuotaCommands, +} + +#[derive(Subcommand, Debug)] +pub enum QuotaCommands { + /// Set bucket quota + Set(SetQuotaArgs), + + /// Show bucket quota information + Info(BucketArg), + + /// Clear bucket quota + Clear(BucketArg), +} + +#[derive(Args, Debug)] +pub struct BucketArg { + /// Bucket path (alias/bucket) + pub path: String, +} + +#[derive(Args, Debug)] +pub struct SetQuotaArgs { + /// Bucket path (alias/bucket) + pub path: String, + + /// Quota value (bytes or units like 1G, 500M, 10KB) + pub size: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct QuotaOutput { + bucket: String, + quota: Option, + quota_human: Option, + usage: u64, + usage_human: String, + quota_type: String, +} + +/// Execute the quota command +pub async fn execute(args: QuotaArgs, output_config: OutputConfig) -> ExitCode { + match args.command { + QuotaCommands::Set(set_args) => execute_set(set_args, output_config).await, + QuotaCommands::Info(bucket_arg) => execute_info(bucket_arg, output_config).await, + QuotaCommands::Clear(bucket_arg) => execute_clear(bucket_arg, output_config).await, + } +} + +async fn execute_set(args: SetQuotaArgs, output_config: OutputConfig) -> ExitCode { + let formatter = Formatter::new(output_config); + + let (alias_name, bucket) = match parse_bucket_path(&args.path) { + Ok(parts) => parts, + Err(err) => { + formatter.error(&err); + return ExitCode::UsageError; + } + }; + + let quota_bytes = match parse_quota_size(&args.size) { + Ok(size) => size, + Err(err) => { + formatter.error(&err); + return ExitCode::UsageError; + } + }; + + let client = match get_admin_client(&alias_name, &formatter) { + Ok(client) => client, + Err(code) => return code, + }; + + match client.set_bucket_quota(&bucket, quota_bytes).await { + Ok(quota) => { + print_quota_result(&formatter, "a); + ExitCode::Success + } + Err(err) => { + formatter.error(&format!("Failed to set bucket quota: {err}")); + exit_code_from_error(&err) + } + } +} + +async fn execute_info(args: BucketArg, output_config: OutputConfig) -> ExitCode { + let formatter = Formatter::new(output_config); + + let (alias_name, bucket) = match parse_bucket_path(&args.path) { + Ok(parts) => parts, + Err(err) => { + formatter.error(&err); + return ExitCode::UsageError; + } + }; + + let client = match get_admin_client(&alias_name, &formatter) { + Ok(client) => client, + Err(code) => return code, + }; + + match client.get_bucket_quota(&bucket).await { + Ok(quota) => { + print_quota_result(&formatter, "a); + ExitCode::Success + } + Err(err) => { + formatter.error(&format!("Failed to get bucket quota: {err}")); + exit_code_from_error(&err) + } + } +} + +async fn execute_clear(args: BucketArg, output_config: OutputConfig) -> ExitCode { + let formatter = Formatter::new(output_config); + + let (alias_name, bucket) = match parse_bucket_path(&args.path) { + Ok(parts) => parts, + Err(err) => { + formatter.error(&err); + return ExitCode::UsageError; + } + }; + + let client = match get_admin_client(&alias_name, &formatter) { + Ok(client) => client, + Err(code) => return code, + }; + + match client.clear_bucket_quota(&bucket).await { + Ok(quota) => { + print_quota_result(&formatter, "a); + ExitCode::Success + } + Err(err) => { + formatter.error(&format!("Failed to clear bucket quota: {err}")); + exit_code_from_error(&err) + } + } +} + +fn print_quota_result(formatter: &Formatter, quota: &BucketQuota) { + if formatter.is_json() { + formatter.json(&QuotaOutput { + bucket: quota.bucket.clone(), + quota: quota.quota, + quota_human: quota.quota.map(format_human_size), + usage: quota.size, + usage_human: format_human_size(quota.size), + quota_type: quota.quota_type.clone(), + }); + return; + } + + formatter.println(&format!("Bucket: {}", quota.bucket)); + let limit_text = quota + .quota + .map(format_human_size) + .unwrap_or_else(|| "unlimited".to_string()); + formatter.println(&format!("Quota: {limit_text}")); + formatter.println(&format!("Usage: {}", format_human_size(quota.size))); + formatter.println(&format!("Type: {}", quota.quota_type)); +} + +fn parse_bucket_path(path: &str) -> Result<(String, String), String> { + if path.trim().is_empty() { + return Err("Path cannot be empty".to_string()); + } + + let parts: Vec<&str> = path.splitn(2, '/').collect(); + + if parts.len() < 2 || parts[0].is_empty() { + return Err("Alias name is required (alias/bucket)".to_string()); + } + + let bucket = parts[1].trim_end_matches('/'); + if bucket.is_empty() { + return Err("Bucket name is required (alias/bucket)".to_string()); + } + + Ok((parts[0].to_string(), bucket.to_string())) +} + +fn parse_quota_size(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err("Quota size cannot be empty".to_string()); + } + + let split_index = value + .find(|ch: char| !ch.is_ascii_digit()) + .unwrap_or(value.len()); + + let (number_part, unit_part) = value.split_at(split_index); + if number_part.is_empty() { + return Err(format!("Invalid quota size: '{value}'")); + } + + let number = number_part + .parse::() + .map_err(|_| format!("Invalid quota size number: '{number_part}'"))?; + + let multiplier = match unit_part.trim().to_uppercase().as_str() { + "" | "B" => 1, + "K" | "KB" | "KIB" => 1024, + "M" | "MB" | "MIB" => 1024 * 1024, + "G" | "GB" | "GIB" => 1024 * 1024 * 1024, + "T" | "TB" | "TIB" => 1024_u64.pow(4), + _ => return Err(format!("Invalid quota size unit: '{unit_part}'")), + }; + + number + .checked_mul(multiplier) + .ok_or_else(|| format!("Quota size is too large: '{value}'")) +} + +fn format_human_size(bytes: u64) -> String { + humansize::format_size(bytes, humansize::BINARY) +} + +fn exit_code_from_error(error: &rc_core::Error) -> ExitCode { + ExitCode::from_i32(error.exit_code()).unwrap_or(ExitCode::GeneralError) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_bucket_path() { + let (alias, bucket) = parse_bucket_path("local/my-bucket").unwrap(); + assert_eq!(alias, "local"); + assert_eq!(bucket, "my-bucket"); + + let (alias, bucket) = parse_bucket_path("local/my-bucket/").unwrap(); + assert_eq!(alias, "local"); + assert_eq!(bucket, "my-bucket"); + } + + #[test] + fn test_parse_bucket_path_errors() { + assert!(parse_bucket_path("").is_err()); + assert!(parse_bucket_path("local").is_err()); + assert!(parse_bucket_path("/my-bucket").is_err()); + assert!(parse_bucket_path("local/").is_err()); + } + + #[test] + fn test_parse_quota_size() { + assert_eq!(parse_quota_size("1024").unwrap(), 1024); + assert_eq!(parse_quota_size("1K").unwrap(), 1024); + assert_eq!(parse_quota_size("1KB").unwrap(), 1024); + assert_eq!(parse_quota_size("1M").unwrap(), 1024 * 1024); + assert_eq!(parse_quota_size("2G").unwrap(), 2 * 1024 * 1024 * 1024); + } + + #[test] + fn test_parse_quota_size_errors() { + assert!(parse_quota_size("").is_err()); + assert!(parse_quota_size("abc").is_err()); + assert!(parse_quota_size("1X").is_err()); + } + + #[tokio::test] + async fn test_execute_set_invalid_path_returns_usage_error() { + let args = QuotaArgs { + command: QuotaCommands::Set(SetQuotaArgs { + path: "invalid-path".to_string(), + size: "1G".to_string(), + }), + }; + + let code = execute(args, OutputConfig::default()).await; + assert_eq!(code, ExitCode::UsageError); + } + + #[tokio::test] + async fn test_execute_set_invalid_size_returns_usage_error() { + let args = QuotaArgs { + command: QuotaCommands::Set(SetQuotaArgs { + path: "local/my-bucket".to_string(), + size: "1X".to_string(), + }), + }; + + let code = execute(args, OutputConfig::default()).await; + assert_eq!(code, ExitCode::UsageError); + } +} diff --git a/crates/cli/src/commands/tag.rs b/crates/cli/src/commands/tag.rs index 1de1e63..a30524d 100644 --- a/crates/cli/src/commands/tag.rs +++ b/crates/cli/src/commands/tag.rs @@ -1,6 +1,6 @@ -//! tag command - Manage object tags +//! tag command - Manage bucket and object tags //! -//! Get, set, or remove tags on S3 objects. +//! Get, set, or remove tags on buckets and objects. use clap::{Args, Subcommand}; use rc_core::{AliasManager, ObjectStore as _, RemotePath}; @@ -11,7 +11,7 @@ use std::collections::HashMap; use crate::exit_code::ExitCode; use crate::output::{Formatter, OutputConfig}; -/// Manage object tags +/// Manage bucket and object tags #[derive(Args, Debug)] pub struct TagArgs { #[command(subcommand)] @@ -20,19 +20,19 @@ pub struct TagArgs { #[derive(Subcommand, Debug)] pub enum TagCommands { - /// List tags for an object - List(ObjectPathArg), + /// List tags for a bucket or object + List(TagPathArg), - /// Set tags for an object + /// Set tags for a bucket or object Set(SetTagArgs), - /// Remove all tags from an object - Remove(ObjectPathArg), + /// Remove all tags from a bucket or object + Remove(TagPathArg), } #[derive(Args, Debug)] -pub struct ObjectPathArg { - /// Path to the object (alias/bucket/key) +pub struct TagPathArg { + /// Path to a bucket or object (alias/bucket or alias/bucket/key) pub path: String, /// Force operation even if capability detection fails @@ -42,7 +42,7 @@ pub struct ObjectPathArg { #[derive(Args, Debug)] pub struct SetTagArgs { - /// Path to the object (alias/bucket/key) + /// Path to a bucket or object (alias/bucket or alias/bucket/key) pub path: String, /// Tags to set (key=value format, can specify multiple) @@ -61,6 +61,34 @@ struct TagOutput { count: usize, } +#[derive(Debug, Clone, PartialEq, Eq)] +enum TagTarget { + Bucket { + alias: String, + bucket: String, + }, + Object { + alias: String, + bucket: String, + key: String, + }, +} + +impl TagTarget { + fn alias_name(&self) -> &str { + match self { + Self::Bucket { alias, .. } | Self::Object { alias, .. } => alias, + } + } + + fn kind_name(&self) -> &'static str { + match self { + Self::Bucket { .. } => "bucket", + Self::Object { .. } => "object", + } + } +} + /// Execute the tag command pub async fn execute(args: TagArgs, output_config: OutputConfig) -> ExitCode { match args.command { @@ -70,48 +98,46 @@ pub async fn execute(args: TagArgs, output_config: OutputConfig) -> ExitCode { } } -async fn execute_list(args: ObjectPathArg, output_config: OutputConfig) -> ExitCode { +async fn execute_list(args: TagPathArg, output_config: OutputConfig) -> ExitCode { let formatter = Formatter::new(output_config); - let (alias_name, bucket, key) = match parse_object_path(&args.path) { - Ok(p) => p, - Err(e) => { - formatter.error(&e); + let target = match parse_tag_path(&args.path) { + Ok(target) => target, + Err(error) => { + formatter.error(&error); return ExitCode::UsageError; } }; - let client = match setup_client(&alias_name, args.force, &formatter).await { - Ok(c) => c, + let client = match setup_client(target.alias_name(), args.force, &formatter).await { + Ok(client) => client, Err(code) => return code, }; - let path = RemotePath::new(&alias_name, &bucket, &key); - - match client.get_object_tags(&path).await { - Ok(tags) => { - if formatter.is_json() { - let output = TagOutput { - path: args.path.clone(), - count: tags.len(), - tags, - }; - formatter.json(&output); - } else if tags.is_empty() { - formatter.println("No tags found."); - } else { - formatter.println(&format!("Tags for '{}':", args.path)); - for (k, v) in &tags { - formatter.println(&format!(" {k}={v}")); - } - } - ExitCode::Success + let tags = match get_tags_for_target(&client, &target).await { + Ok(tags) => tags, + Err(error) => { + formatter.error(&format!("Failed to get tags: {error}")); + return ExitCode::GeneralError; } - Err(e) => { - formatter.error(&format!("Failed to get tags: {e}")); - ExitCode::GeneralError + }; + + if formatter.is_json() { + formatter.json(&TagOutput { + path: args.path, + count: tags.len(), + tags, + }); + } else if tags.is_empty() { + formatter.println("No tags found."); + } else { + formatter.println(&format!("Tags for {} '{}':", target.kind_name(), args.path)); + for (key, value) in &tags { + formatter.println(&format!(" {key}={value}")); } } + + ExitCode::Success } async fn execute_set(args: SetTagArgs, output_config: OutputConfig) -> ExitCode { @@ -122,117 +148,168 @@ async fn execute_set(args: SetTagArgs, output_config: OutputConfig) -> ExitCode return ExitCode::UsageError; } - let (alias_name, bucket, key) = match parse_object_path(&args.path) { - Ok(p) => p, - Err(e) => { - formatter.error(&e); + let target = match parse_tag_path(&args.path) { + Ok(target) => target, + Err(error) => { + formatter.error(&error); return ExitCode::UsageError; } }; - // Parse tags - let mut tags = HashMap::new(); - for tag_str in &args.tags { - match tag_str.split_once('=') { - Some((k, v)) => { - if k.is_empty() { - formatter.error(&format!( - "Invalid tag format: '{tag_str}' (key cannot be empty)" - )); - return ExitCode::UsageError; - } - tags.insert(k.to_string(), v.to_string()); - } - None => { - formatter.error(&format!( - "Invalid tag format: '{tag_str}' (expected key=value)" - )); - return ExitCode::UsageError; - } + let tags = match parse_tags(&args.tags) { + Ok(tags) => tags, + Err(error) => { + formatter.error(&error); + return ExitCode::UsageError; } - } + }; - let client = match setup_client(&alias_name, args.force, &formatter).await { - Ok(c) => c, + let client = match setup_client(target.alias_name(), args.force, &formatter).await { + Ok(client) => client, Err(code) => return code, }; - let path = RemotePath::new(&alias_name, &bucket, &key); - - match client.set_object_tags(&path, tags.clone()).await { + match set_tags_for_target(&client, &target, tags.clone()).await { Ok(()) => { if formatter.is_json() { - let output = TagOutput { - path: args.path.clone(), + formatter.json(&TagOutput { + path: args.path, count: tags.len(), tags, - }; - formatter.json(&output); + }); } else { - formatter.println(&format!("Set {} tag(s) on '{}'", tags.len(), args.path)); + formatter.println(&format!( + "Set {} tag(s) on {} '{}'", + tags.len(), + target.kind_name(), + args.path + )); } ExitCode::Success } - Err(e) => { - formatter.error(&format!("Failed to set tags: {e}")); + Err(error) => { + formatter.error(&format!("Failed to set tags: {error}")); ExitCode::GeneralError } } } -async fn execute_remove(args: ObjectPathArg, output_config: OutputConfig) -> ExitCode { +async fn execute_remove(args: TagPathArg, output_config: OutputConfig) -> ExitCode { let formatter = Formatter::new(output_config); - let (alias_name, bucket, key) = match parse_object_path(&args.path) { - Ok(p) => p, - Err(e) => { - formatter.error(&e); + let target = match parse_tag_path(&args.path) { + Ok(target) => target, + Err(error) => { + formatter.error(&error); return ExitCode::UsageError; } }; - let client = match setup_client(&alias_name, args.force, &formatter).await { - Ok(c) => c, + let client = match setup_client(target.alias_name(), args.force, &formatter).await { + Ok(client) => client, Err(code) => return code, }; - let path = RemotePath::new(&alias_name, &bucket, &key); - - match client.delete_object_tags(&path).await { + match delete_tags_for_target(&client, &target).await { Ok(()) => { if formatter.is_json() { let output = serde_json::json!({ "path": args.path, - "status": "removed" + "status": "removed", }); formatter.json(&output); } else { - formatter.println(&format!("Removed all tags from '{}'", args.path)); + formatter.println(&format!( + "Removed all tags from {} '{}'", + target.kind_name(), + args.path + )); } ExitCode::Success } - Err(e) => { - formatter.error(&format!("Failed to remove tags: {e}")); + Err(error) => { + formatter.error(&format!("Failed to remove tags: {error}")); ExitCode::GeneralError } } } +async fn get_tags_for_target( + client: &S3Client, + target: &TagTarget, +) -> rc_core::Result> { + match target { + TagTarget::Bucket { bucket, .. } => client.get_bucket_tags(bucket).await, + TagTarget::Object { alias, bucket, key } => { + let path = RemotePath::new(alias, bucket, key); + client.get_object_tags(&path).await + } + } +} + +async fn set_tags_for_target( + client: &S3Client, + target: &TagTarget, + tags: HashMap, +) -> rc_core::Result<()> { + match target { + TagTarget::Bucket { bucket, .. } => client.set_bucket_tags(bucket, tags).await, + TagTarget::Object { alias, bucket, key } => { + let path = RemotePath::new(alias, bucket, key); + client.set_object_tags(&path, tags).await + } + } +} + +async fn delete_tags_for_target(client: &S3Client, target: &TagTarget) -> rc_core::Result<()> { + match target { + TagTarget::Bucket { bucket, .. } => client.delete_bucket_tags(bucket).await, + TagTarget::Object { alias, bucket, key } => { + let path = RemotePath::new(alias, bucket, key); + client.delete_object_tags(&path).await + } + } +} + +fn parse_tags(tags: &[String]) -> Result, String> { + let mut parsed = HashMap::new(); + + for tag_str in tags { + match tag_str.split_once('=') { + Some((key, value)) => { + if key.is_empty() { + return Err(format!( + "Invalid tag format: '{tag_str}' (key cannot be empty)" + )); + } + parsed.insert(key.to_string(), value.to_string()); + } + None => { + return Err(format!( + "Invalid tag format: '{tag_str}' (expected key=value)" + )); + } + } + } + + Ok(parsed) +} + async fn setup_client( alias_name: &str, force: bool, formatter: &Formatter, ) -> Result { let alias_manager = match AliasManager::new() { - Ok(am) => am, - Err(e) => { - formatter.error(&format!("Failed to load aliases: {e}")); + Ok(manager) => manager, + Err(error) => { + formatter.error(&format!("Failed to load aliases: {error}")); return Err(ExitCode::GeneralError); } }; let alias = match alias_manager.get(alias_name) { - Ok(a) => a, + Ok(alias) => alias, Err(_) => { formatter.error(&format!("Alias '{alias_name}' not found")); return Err(ExitCode::NotFound); @@ -240,14 +317,13 @@ async fn setup_client( }; let client = match S3Client::new(alias).await { - Ok(c) => c, - Err(e) => { - formatter.error(&format!("Failed to create S3 client: {e}")); + Ok(client) => client, + Err(error) => { + formatter.error(&format!("Failed to create S3 client: {error}")); return Err(ExitCode::NetworkError); } }; - // Check capabilities if !force { match client.capabilities().await { Ok(caps) => { @@ -257,8 +333,8 @@ async fn setup_client( return Err(ExitCode::UnsupportedFeature); } } - Err(e) => { - formatter.error(&format!("Failed to detect capabilities: {e}")); + Err(error) => { + formatter.error(&format!("Failed to detect capabilities: {error}")); return Err(ExitCode::NetworkError); } } @@ -267,22 +343,34 @@ async fn setup_client( Ok(client) } -fn parse_object_path(path: &str) -> Result<(String, String, String), String> { - if path.is_empty() { +fn parse_tag_path(path: &str) -> Result { + if path.trim().is_empty() { return Err("Path cannot be empty".to_string()); } let parts: Vec<&str> = path.splitn(3, '/').collect(); - if parts.len() < 3 || parts[2].is_empty() { - return Err("Object key is required (alias/bucket/key)".to_string()); + if parts.len() < 2 || parts[0].is_empty() { + return Err("Path must include alias and bucket (alias/bucket)".to_string()); } - Ok(( - parts[0].to_string(), - parts[1].to_string(), - parts[2].to_string(), - )) + let bucket = parts[1].trim_end_matches('/'); + if bucket.is_empty() { + return Err("Bucket name is required (alias/bucket)".to_string()); + } + + if parts.len() == 2 || (parts.len() == 3 && parts[2].is_empty()) { + return Ok(TagTarget::Bucket { + alias: parts[0].to_string(), + bucket: bucket.to_string(), + }); + } + + Ok(TagTarget::Object { + alias: parts[0].to_string(), + bucket: bucket.to_string(), + key: parts[2].to_string(), + }) } #[cfg(test)] @@ -290,18 +378,57 @@ mod tests { use super::*; #[test] - fn test_parse_object_path() { - let (alias, bucket, key) = parse_object_path("myalias/mybucket/path/to/file.txt").unwrap(); - assert_eq!(alias, "myalias"); - assert_eq!(bucket, "mybucket"); - assert_eq!(key, "path/to/file.txt"); + fn test_parse_tag_path_bucket() { + let target = parse_tag_path("myalias/mybucket").unwrap(); + assert_eq!( + target, + TagTarget::Bucket { + alias: "myalias".to_string(), + bucket: "mybucket".to_string(), + } + ); + + let target = parse_tag_path("myalias/mybucket/").unwrap(); + assert_eq!( + target, + TagTarget::Bucket { + alias: "myalias".to_string(), + bucket: "mybucket".to_string(), + } + ); + } + + #[test] + fn test_parse_tag_path_object() { + let target = parse_tag_path("myalias/mybucket/path/to/file.txt").unwrap(); + assert_eq!( + target, + TagTarget::Object { + alias: "myalias".to_string(), + bucket: "mybucket".to_string(), + key: "path/to/file.txt".to_string(), + } + ); + } + + #[test] + fn test_parse_tag_path_errors() { + assert!(parse_tag_path("").is_err()); + assert!(parse_tag_path("myalias").is_err()); + assert!(parse_tag_path("/mybucket").is_err()); + assert!(parse_tag_path("myalias/").is_err()); + } + + #[test] + fn test_parse_tags() { + let tags = parse_tags(&["env=prod".to_string(), "team=infra".to_string()]).unwrap(); + assert_eq!(tags.get("env"), Some(&"prod".to_string())); + assert_eq!(tags.get("team"), Some(&"infra".to_string())); } #[test] - fn test_parse_object_path_errors() { - assert!(parse_object_path("").is_err()); - assert!(parse_object_path("myalias").is_err()); - assert!(parse_object_path("myalias/mybucket").is_err()); - assert!(parse_object_path("myalias/mybucket/").is_err()); + fn test_parse_tags_errors() { + assert!(parse_tags(&["invalid".to_string()]).is_err()); + assert!(parse_tags(&["=value".to_string()]).is_err()); } } diff --git a/crates/core/src/admin/mod.rs b/crates/core/src/admin/mod.rs index 8640c7d..273b2c4 100644 --- a/crates/core/src/admin/mod.rs +++ b/crates/core/src/admin/mod.rs @@ -12,7 +12,7 @@ pub use cluster::{ ObjectsInfo, ServerInfo, UsageInfo, }; pub use types::{ - CreateServiceAccountRequest, Group, GroupStatus, Policy, PolicyEntity, PolicyInfo, + BucketQuota, CreateServiceAccountRequest, Group, GroupStatus, Policy, PolicyEntity, PolicyInfo, ServiceAccount, SetPolicyRequest, UpdateGroupMembersRequest, User, UserStatus, }; @@ -127,6 +127,17 @@ pub trait AdminApi: Send + Sync { /// Delete a service account async fn delete_service_account(&self, access_key: &str) -> Result<()>; + + // ==================== Bucket Quota Operations ==================== + + /// Set bucket quota in bytes + async fn set_bucket_quota(&self, bucket: &str, quota: u64) -> Result; + + /// Get bucket quota information + async fn get_bucket_quota(&self, bucket: &str) -> Result; + + /// Clear bucket quota + async fn clear_bucket_quota(&self, bucket: &str) -> Result; } #[cfg(test)] diff --git a/crates/core/src/admin/types.rs b/crates/core/src/admin/types.rs index 5480dbf..beab87d 100644 --- a/crates/core/src/admin/types.rs +++ b/crates/core/src/admin/types.rs @@ -299,6 +299,23 @@ pub struct CreateServiceAccountRequest { pub description: Option, } +/// Bucket quota information returned by Admin API +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BucketQuota { + /// Bucket name + pub bucket: String, + + /// Quota limit in bytes (None means unlimited) + pub quota: Option, + + /// Current bucket usage in bytes + pub size: u64, + + /// Quota type (currently only HARD) + pub quota_type: String, +} + #[cfg(test)] mod tests { use super::*; @@ -426,4 +443,22 @@ mod tests { ); assert!("invalid".parse::().is_err()); } + + #[test] + fn test_bucket_quota_serialization() { + let quota = BucketQuota { + bucket: "my-bucket".to_string(), + quota: Some(1024), + size: 512, + quota_type: "HARD".to_string(), + }; + + let json = serde_json::to_string("a).unwrap(); + assert!(json.contains("my-bucket")); + assert!(json.contains("quotaType")); + + let decoded: BucketQuota = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.bucket, "my-bucket"); + assert_eq!(decoded.quota, Some(1024)); + } } diff --git a/crates/core/src/traits.rs b/crates/core/src/traits.rs index 42c6b17..17708b0 100644 --- a/crates/core/src/traits.rs +++ b/crates/core/src/traits.rs @@ -246,6 +246,12 @@ pub trait ObjectStore: Send + Sync { path: &RemotePath, ) -> Result>; + /// Get bucket tags + async fn get_bucket_tags( + &self, + bucket: &str, + ) -> Result>; + /// Set object tags async fn set_object_tags( &self, @@ -253,8 +259,18 @@ pub trait ObjectStore: Send + Sync { tags: std::collections::HashMap, ) -> Result<()>; + /// Set bucket tags + async fn set_bucket_tags( + &self, + bucket: &str, + tags: std::collections::HashMap, + ) -> Result<()>; + /// Delete object tags async fn delete_object_tags(&self, path: &RemotePath) -> Result<()>; + + /// Delete bucket tags + async fn delete_bucket_tags(&self, bucket: &str) -> Result<()>; // async fn get_versioning(&self, bucket: &str) -> Result; // async fn set_versioning(&self, bucket: &str, enabled: bool) -> Result<()>; // async fn get_tags(&self, path: &RemotePath) -> Result>; diff --git a/crates/s3/src/admin.rs b/crates/s3/src/admin.rs index 058f6c8..f0680a2 100644 --- a/crates/s3/src/admin.rs +++ b/crates/s3/src/admin.rs @@ -10,9 +10,9 @@ use aws_sigv4::http_request::{ }; use aws_sigv4::sign::v4; use rc_core::admin::{ - AdminApi, ClusterInfo, CreateServiceAccountRequest, Group, GroupStatus, HealStartRequest, - HealStatus, Policy, PolicyEntity, PolicyInfo, ServiceAccount, UpdateGroupMembersRequest, User, - UserStatus, + AdminApi, BucketQuota, ClusterInfo, CreateServiceAccountRequest, Group, GroupStatus, + HealStartRequest, HealStatus, Policy, PolicyEntity, PolicyInfo, ServiceAccount, + UpdateGroupMembersRequest, User, UserStatus, }; use rc_core::{Alias, Error, Result}; use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; @@ -343,6 +343,14 @@ struct SetPolicyApiRequest { entity_name: String, } +/// Request body for setting bucket quota +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SetBucketQuotaApiRequest { + quota: u64, + quota_type: String, +} + #[async_trait] impl AdminApi for AdminClient { // ==================== Cluster Operations ==================== @@ -649,6 +657,29 @@ impl AdminApi for AdminClient { ) .await } + + // ==================== Bucket Quota Operations ==================== + + async fn set_bucket_quota(&self, bucket: &str, quota: u64) -> Result { + let path = format!("/quota/{}", urlencoding::encode(bucket)); + let body = serde_json::to_vec(&SetBucketQuotaApiRequest { + quota, + quota_type: "HARD".to_string(), + }) + .map_err(Error::Json)?; + + self.request(Method::PUT, &path, None, Some(&body)).await + } + + async fn get_bucket_quota(&self, bucket: &str) -> Result { + let path = format!("/quota/{}", urlencoding::encode(bucket)); + self.request(Method::GET, &path, None, None).await + } + + async fn clear_bucket_quota(&self, bucket: &str) -> Result { + let path = format!("/quota/{}", urlencoding::encode(bucket)); + self.request(Method::DELETE, &path, None, None).await + } } #[cfg(test)] diff --git a/crates/s3/src/client.rs b/crates/s3/src/client.rs index 1908c77..707a895 100644 --- a/crates/s3/src/client.rs +++ b/crates/s3/src/client.rs @@ -90,6 +90,27 @@ impl S3Client { } } +fn build_tagging( + tags: std::collections::HashMap, +) -> Result { + use aws_sdk_s3::types::{Tag, Tagging}; + + let mut tag_set = Vec::with_capacity(tags.len()); + for (key, value) in tags { + let tag = Tag::builder() + .key(key) + .value(value) + .build() + .map_err(|e| Error::General(format!("invalid tag: {e}")))?; + tag_set.push(tag); + } + + Tagging::builder() + .set_tag_set(Some(tag_set)) + .build() + .map_err(|e| Error::General(format!("invalid tagging payload: {e}"))) +} + #[async_trait] impl ObjectStore for S3Client { async fn list_buckets(&self) -> Result> { @@ -593,14 +614,46 @@ impl ObjectStore for S3Client { &self, path: &RemotePath, ) -> Result> { - let response = self + let response = match self .inner .get_object_tagging() .bucket(&path.bucket) .key(&path.key) .send() .await - .map_err(|e| Error::General(format!("get_object_tags: {e}")))?; + { + Ok(response) => response, + Err(e) => { + if e.to_string().contains("NoSuchTagSet") { + return Ok(std::collections::HashMap::new()); + } + return Err(Error::General(format!("get_object_tags: {e}"))); + } + }; + + let mut tags = std::collections::HashMap::new(); + for tag in response.tag_set() { + let key = tag.key(); + let value = tag.value(); + tags.insert(key.to_string(), value.to_string()); + } + + Ok(tags) + } + + async fn get_bucket_tags( + &self, + bucket: &str, + ) -> Result> { + let response = match self.inner.get_bucket_tagging().bucket(bucket).send().await { + Ok(response) => response, + Err(e) => { + if e.to_string().contains("NoSuchTagSet") { + return Ok(std::collections::HashMap::new()); + } + return Err(Error::General(format!("get_bucket_tags: {e}"))); + } + }; let mut tags = std::collections::HashMap::new(); for tag in response.tag_set() { @@ -617,17 +670,7 @@ impl ObjectStore for S3Client { path: &RemotePath, tags: std::collections::HashMap, ) -> Result<()> { - use aws_sdk_s3::types::{Tag, Tagging}; - - let tag_set: Vec = tags - .into_iter() - .map(|(k, v)| Tag::builder().key(k).value(v).build().expect("valid tag")) - .collect(); - - let tagging = Tagging::builder() - .set_tag_set(Some(tag_set)) - .build() - .expect("valid tagging"); + let tagging = build_tagging(tags)?; self.inner .put_object_tagging() @@ -641,6 +684,24 @@ impl ObjectStore for S3Client { Ok(()) } + async fn set_bucket_tags( + &self, + bucket: &str, + tags: std::collections::HashMap, + ) -> Result<()> { + let tagging = build_tagging(tags)?; + + self.inner + .put_bucket_tagging() + .bucket(bucket) + .tagging(tagging) + .send() + .await + .map_err(|e| Error::General(format!("set_bucket_tags: {e}")))?; + + Ok(()) + } + async fn delete_object_tags(&self, path: &RemotePath) -> Result<()> { self.inner .delete_object_tagging() @@ -652,6 +713,17 @@ impl ObjectStore for S3Client { Ok(()) } + + async fn delete_bucket_tags(&self, bucket: &str) -> Result<()> { + self.inner + .delete_bucket_tagging() + .bucket(bucket) + .send() + .await + .map_err(|e| Error::General(format!("delete_bucket_tags: {e}")))?; + + Ok(()) + } } #[cfg(test)] diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index f408fdc..1f20ed8 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -20,7 +20,7 @@ | basic | ls, mb, rb, cat, head, stat | core | | transfer | cp, mv, rm, pipe | core | | advanced | find, diff, mirror, tree, share | core | -| optional | version, retention, tag, watch, sql | optional | +| optional | version, quota, retention, tag, watch, sql | optional | ## 测试层级 @@ -250,4 +250,3 @@ pub fn run_command(args: &[&str]) -> CommandResult { ```bash cargo tarpaulin --workspace --out html ``` -