mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-10 07:06:53 +00:00
feat: implement bucket quota system (#1461)
Signed-off-by: yxrxy <1532529704@qq.com> Co-authored-by: loverustfs <hello@rustfs.com>
This commit is contained in:
@@ -83,6 +83,7 @@ pub mod kms_keys;
|
||||
pub mod policies;
|
||||
pub mod pools;
|
||||
pub mod profile;
|
||||
pub mod quota;
|
||||
pub mod rebalance;
|
||||
pub mod service_account;
|
||||
pub mod sts;
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Quota admin handlers for HTTP API
|
||||
|
||||
use super::Operation;
|
||||
use crate::admin::auth::validate_admin_request;
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use hyper::StatusCode;
|
||||
use matchit::Params;
|
||||
use rustfs_ecstore::bucket::quota::checker::QuotaChecker;
|
||||
use rustfs_ecstore::bucket::quota::{BucketQuota, QuotaError, QuotaOperation};
|
||||
use rustfs_policy::policy::action::{Action, AdminAction};
|
||||
use s3s::{Body, S3Request, S3Response, S3Result, s3_error};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetBucketQuotaRequest {
|
||||
pub quota: Option<u64>,
|
||||
#[serde(default = "default_quota_type")]
|
||||
pub quota_type: String,
|
||||
}
|
||||
|
||||
fn default_quota_type() -> String {
|
||||
rustfs_config::QUOTA_TYPE_HARD.to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BucketQuotaResponse {
|
||||
pub bucket: String,
|
||||
pub quota: Option<u64>,
|
||||
pub size: u64,
|
||||
/// Current usage size in bytes
|
||||
pub quota_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BucketQuotaStats {
|
||||
pub bucket: String,
|
||||
pub quota_limit: Option<u64>,
|
||||
pub current_usage: u64,
|
||||
pub remaining_quota: Option<u64>,
|
||||
pub usage_percentage: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CheckQuotaRequest {
|
||||
pub operation_type: String,
|
||||
pub operation_size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CheckQuotaResponse {
|
||||
pub bucket: String,
|
||||
pub operation_type: String,
|
||||
pub operation_size: u64,
|
||||
pub allowed: bool,
|
||||
pub current_usage: u64,
|
||||
pub quota_limit: Option<u64>,
|
||||
pub remaining_quota: Option<u64>,
|
||||
}
|
||||
|
||||
/// Quota management handlers
|
||||
pub struct SetBucketQuotaHandler;
|
||||
pub struct GetBucketQuotaHandler;
|
||||
pub struct ClearBucketQuotaHandler;
|
||||
pub struct GetBucketQuotaStatsHandler;
|
||||
pub struct CheckBucketQuotaHandler;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for SetBucketQuotaHandler {
|
||||
#[tracing::instrument(skip_all)]
|
||||
async fn call(&self, mut req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
warn!("handle SetBucketQuota");
|
||||
|
||||
let Some(ref cred) = req.credentials else {
|
||||
return Err(s3_error!(InvalidRequest, "authentication required"));
|
||||
};
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &cred.access_key).await?;
|
||||
|
||||
validate_admin_request(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::AdminAction(AdminAction::SetBucketQuotaAdminAction)],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let bucket = params.get("bucket").unwrap_or("").to_string();
|
||||
if bucket.is_empty() {
|
||||
return Err(s3_error!(InvalidRequest, "bucket name is required"));
|
||||
}
|
||||
|
||||
let body = req
|
||||
.input
|
||||
.store_all_limited(rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE)
|
||||
.await
|
||||
.map_err(|e| s3_error!(InvalidRequest, "failed to read request body: {}", e))?;
|
||||
|
||||
let request: SetBucketQuotaRequest = if body.is_empty() {
|
||||
SetBucketQuotaRequest {
|
||||
quota: None,
|
||||
quota_type: default_quota_type(),
|
||||
}
|
||||
} else {
|
||||
serde_json::from_slice(&body).map_err(|e| s3_error!(InvalidRequest, "invalid JSON: {}", e))?
|
||||
};
|
||||
|
||||
if request.quota_type.to_uppercase() != rustfs_config::QUOTA_TYPE_HARD {
|
||||
return Err(s3_error!(InvalidArgument, "{}", rustfs_config::QUOTA_INVALID_TYPE_ERROR_MSG));
|
||||
}
|
||||
|
||||
let quota = BucketQuota::new(request.quota);
|
||||
|
||||
let metadata_sys_lock = rustfs_ecstore::bucket::metadata_sys::GLOBAL_BucketMetadataSys
|
||||
.get()
|
||||
.ok_or_else(|| s3_error!(InternalError, "{}", rustfs_config::QUOTA_METADATA_SYSTEM_ERROR_MSG))?;
|
||||
let mut quota_checker = QuotaChecker::new(metadata_sys_lock.clone());
|
||||
|
||||
quota_checker
|
||||
.set_quota_config(&bucket, quota.clone())
|
||||
.await
|
||||
.map_err(|e| s3_error!(InternalError, "Failed to set quota: {}", e))?;
|
||||
|
||||
// Get real-time usage from data usage system
|
||||
let current_usage = if let Some(store) = rustfs_ecstore::global::GLOBAL_OBJECT_API.get() {
|
||||
match rustfs_ecstore::data_usage::load_data_usage_from_backend(store.clone()).await {
|
||||
Ok(data_usage_info) => data_usage_info
|
||||
.buckets_usage
|
||||
.get(&bucket)
|
||||
.map(|bucket_usage| bucket_usage.size)
|
||||
.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let response = BucketQuotaResponse {
|
||||
bucket,
|
||||
quota: quota.quota,
|
||||
size: current_usage,
|
||||
quota_type: rustfs_config::QUOTA_TYPE_HARD.to_string(),
|
||||
};
|
||||
|
||||
let json =
|
||||
serde_json::to_string(&response).map_err(|e| s3_error!(InternalError, "Failed to serialize response: {}", e))?;
|
||||
|
||||
Ok(S3Response::new((StatusCode::OK, Body::from(json))))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for GetBucketQuotaHandler {
|
||||
#[tracing::instrument(skip_all)]
|
||||
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
warn!("handle GetBucketQuota");
|
||||
|
||||
let Some(ref cred) = req.credentials else {
|
||||
return Err(s3_error!(InvalidRequest, "authentication required"));
|
||||
};
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &cred.access_key).await?;
|
||||
|
||||
validate_admin_request(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::AdminAction(AdminAction::GetBucketQuotaAdminAction)],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let bucket = params.get("bucket").unwrap_or("").to_string();
|
||||
if bucket.is_empty() {
|
||||
return Err(s3_error!(InvalidRequest, "bucket name is required"));
|
||||
}
|
||||
|
||||
let metadata_sys_lock = rustfs_ecstore::bucket::metadata_sys::GLOBAL_BucketMetadataSys
|
||||
.get()
|
||||
.ok_or_else(|| s3_error!(InternalError, "Bucket metadata system not initialized"))?;
|
||||
|
||||
let quota_checker = QuotaChecker::new(metadata_sys_lock.clone());
|
||||
|
||||
let (quota, current_usage) = quota_checker.get_quota_stats(&bucket).await.map_err(|e| match e {
|
||||
QuotaError::ConfigNotFound { .. } => {
|
||||
s3_error!(NoSuchBucket, "Bucket not found: {}", bucket)
|
||||
}
|
||||
_ => s3_error!(InternalError, "Failed to get quota: {}", e),
|
||||
})?;
|
||||
|
||||
let response = BucketQuotaResponse {
|
||||
bucket,
|
||||
quota: quota.quota,
|
||||
size: current_usage.unwrap_or(0),
|
||||
quota_type: rustfs_config::QUOTA_TYPE_HARD.to_string(),
|
||||
};
|
||||
|
||||
let json =
|
||||
serde_json::to_string(&response).map_err(|e| s3_error!(InternalError, "Failed to serialize response: {}", e))?;
|
||||
|
||||
Ok(S3Response::new((StatusCode::OK, Body::from(json))))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for ClearBucketQuotaHandler {
|
||||
#[tracing::instrument(skip_all)]
|
||||
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
warn!("handle ClearBucketQuota");
|
||||
|
||||
let Some(ref cred) = req.credentials else {
|
||||
return Err(s3_error!(InvalidRequest, "authentication required"));
|
||||
};
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &cred.access_key).await?;
|
||||
|
||||
validate_admin_request(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::AdminAction(AdminAction::SetBucketQuotaAdminAction)],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let bucket = params.get("bucket").unwrap_or("").to_string();
|
||||
if bucket.is_empty() {
|
||||
return Err(s3_error!(InvalidRequest, "bucket name is required"));
|
||||
}
|
||||
|
||||
info!("Clearing quota for bucket: {}", bucket);
|
||||
|
||||
let metadata_sys_lock = rustfs_ecstore::bucket::metadata_sys::GLOBAL_BucketMetadataSys
|
||||
.get()
|
||||
.ok_or_else(|| s3_error!(InternalError, "Bucket metadata system not initialized"))?;
|
||||
|
||||
let mut quota_checker = QuotaChecker::new(metadata_sys_lock.clone());
|
||||
|
||||
// Clear quota (set to None)
|
||||
let quota = BucketQuota::new(None);
|
||||
quota_checker
|
||||
.set_quota_config(&bucket, quota.clone())
|
||||
.await
|
||||
.map_err(|e| s3_error!(InternalError, "Failed to clear quota: {}", e))?;
|
||||
|
||||
info!("Successfully cleared quota for bucket: {}", bucket);
|
||||
|
||||
// Get real-time usage from data usage system
|
||||
let current_usage = if let Some(store) = rustfs_ecstore::global::GLOBAL_OBJECT_API.get() {
|
||||
match rustfs_ecstore::data_usage::load_data_usage_from_backend(store.clone()).await {
|
||||
Ok(data_usage_info) => data_usage_info
|
||||
.buckets_usage
|
||||
.get(&bucket)
|
||||
.map(|bucket_usage| bucket_usage.size)
|
||||
.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let response = BucketQuotaResponse {
|
||||
bucket,
|
||||
quota: None,
|
||||
size: current_usage,
|
||||
quota_type: rustfs_config::QUOTA_TYPE_HARD.to_string(),
|
||||
};
|
||||
|
||||
let json =
|
||||
serde_json::to_string(&response).map_err(|e| s3_error!(InternalError, "Failed to serialize response: {}", e))?;
|
||||
|
||||
Ok(S3Response::new((StatusCode::OK, Body::from(json))))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for GetBucketQuotaStatsHandler {
|
||||
#[tracing::instrument(skip_all)]
|
||||
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
warn!("handle GetBucketQuotaStats");
|
||||
|
||||
let Some(ref cred) = req.credentials else {
|
||||
return Err(s3_error!(InvalidRequest, "authentication required"));
|
||||
};
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &cred.access_key).await?;
|
||||
|
||||
validate_admin_request(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::AdminAction(AdminAction::GetBucketQuotaAdminAction)],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let bucket = params.get("bucket").unwrap_or("").to_string();
|
||||
if bucket.is_empty() {
|
||||
return Err(s3_error!(InvalidRequest, "bucket name is required"));
|
||||
}
|
||||
|
||||
let metadata_sys_lock = rustfs_ecstore::bucket::metadata_sys::GLOBAL_BucketMetadataSys
|
||||
.get()
|
||||
.ok_or_else(|| s3_error!(InternalError, "Bucket metadata system not initialized"))?;
|
||||
|
||||
let quota_checker = QuotaChecker::new(metadata_sys_lock.clone());
|
||||
|
||||
let (quota, current_usage_opt) = quota_checker.get_quota_stats(&bucket).await.map_err(|e| match e {
|
||||
QuotaError::ConfigNotFound { .. } => {
|
||||
s3_error!(NoSuchBucket, "Bucket not found: {}", bucket)
|
||||
}
|
||||
_ => s3_error!(InternalError, "Failed to get quota stats: {}", e),
|
||||
})?;
|
||||
|
||||
let current_usage = current_usage_opt.unwrap_or(0);
|
||||
let usage_percentage = quota.quota.and_then(|limit| {
|
||||
if limit == 0 {
|
||||
None
|
||||
} else {
|
||||
Some((current_usage as f64 / limit as f64) * 100.0)
|
||||
}
|
||||
});
|
||||
|
||||
let remaining_quota = quota.get_remaining_quota(current_usage);
|
||||
|
||||
let response = BucketQuotaStats {
|
||||
bucket,
|
||||
quota_limit: quota.quota,
|
||||
current_usage,
|
||||
remaining_quota,
|
||||
usage_percentage,
|
||||
};
|
||||
|
||||
let json =
|
||||
serde_json::to_string(&response).map_err(|e| s3_error!(InternalError, "Failed to serialize response: {}", e))?;
|
||||
|
||||
Ok(S3Response::new((StatusCode::OK, Body::from(json))))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for CheckBucketQuotaHandler {
|
||||
#[tracing::instrument(skip_all)]
|
||||
async fn call(&self, mut req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
warn!("handle CheckBucketQuota");
|
||||
|
||||
let Some(ref cred) = req.credentials else {
|
||||
return Err(s3_error!(InvalidRequest, "authentication required"));
|
||||
};
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &cred.access_key).await?;
|
||||
|
||||
validate_admin_request(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::AdminAction(AdminAction::GetBucketQuotaAdminAction)],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let bucket = params.get("bucket").unwrap_or("").to_string();
|
||||
if bucket.is_empty() {
|
||||
return Err(s3_error!(InvalidRequest, "bucket name is required"));
|
||||
}
|
||||
|
||||
let body = req
|
||||
.input
|
||||
.store_all_limited(rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE)
|
||||
.await
|
||||
.map_err(|e| s3_error!(InvalidRequest, "failed to read request body: {}", e))?;
|
||||
|
||||
let request: CheckQuotaRequest = if body.is_empty() {
|
||||
return Err(s3_error!(InvalidRequest, "request body cannot be empty"));
|
||||
} else {
|
||||
serde_json::from_slice(&body).map_err(|e| s3_error!(InvalidRequest, "invalid JSON: {}", e))?
|
||||
};
|
||||
|
||||
debug!(
|
||||
"Checking quota for bucket: {}, operation: {}, size: {}",
|
||||
bucket, request.operation_type, request.operation_size
|
||||
);
|
||||
|
||||
let metadata_sys_lock = rustfs_ecstore::bucket::metadata_sys::GLOBAL_BucketMetadataSys
|
||||
.get()
|
||||
.ok_or_else(|| s3_error!(InternalError, "Bucket metadata system not initialized"))?;
|
||||
|
||||
let quota_checker = QuotaChecker::new(metadata_sys_lock.clone());
|
||||
|
||||
let operation: QuotaOperation = match request.operation_type.to_uppercase().as_str() {
|
||||
"PUT" | "PUTOBJECT" => QuotaOperation::PutObject,
|
||||
"COPY" | "COPYOBJECT" => QuotaOperation::CopyObject,
|
||||
"DELETE" | "DELETEOBJECT" => QuotaOperation::DeleteObject,
|
||||
_ => QuotaOperation::PutObject, // Default to PUT operation
|
||||
};
|
||||
|
||||
let result = quota_checker
|
||||
.check_quota(&bucket, operation, request.operation_size)
|
||||
.await
|
||||
.map_err(|e| s3_error!(InternalError, "Failed to check quota: {}", e))?;
|
||||
|
||||
let response = CheckQuotaResponse {
|
||||
bucket,
|
||||
operation_type: request.operation_type,
|
||||
operation_size: request.operation_size,
|
||||
allowed: result.allowed,
|
||||
current_usage: result.current_usage,
|
||||
quota_limit: result.quota_limit,
|
||||
remaining_quota: result.remaining,
|
||||
};
|
||||
|
||||
let json =
|
||||
serde_json::to_string(&response).map_err(|e| s3_error!(InternalError, "Failed to serialize response: {}", e))?;
|
||||
|
||||
Ok(S3Response::new((StatusCode::OK, Body::from(json))))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_quota_type() {
|
||||
assert_eq!(default_quota_type(), "HARD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quota_operation_parsing() {
|
||||
let parse_operation = |operation: &str| match operation.to_uppercase().as_str() {
|
||||
"PUT" | "PUTOBJECT" => QuotaOperation::PutObject,
|
||||
"COPY" | "COPYOBJECT" => QuotaOperation::CopyObject,
|
||||
"DELETE" | "DELETEOBJECT" => QuotaOperation::DeleteObject,
|
||||
_ => QuotaOperation::PutObject,
|
||||
};
|
||||
|
||||
assert!(matches!(parse_operation("put"), QuotaOperation::PutObject));
|
||||
assert!(matches!(parse_operation("PUT"), QuotaOperation::PutObject));
|
||||
assert!(matches!(parse_operation("PutObject"), QuotaOperation::PutObject));
|
||||
assert!(matches!(parse_operation("copy"), QuotaOperation::CopyObject));
|
||||
assert!(matches!(parse_operation("DELETE"), QuotaOperation::DeleteObject));
|
||||
assert!(matches!(parse_operation("unknown"), QuotaOperation::PutObject));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_quota_response_serialization() {
|
||||
let response = BucketQuotaResponse {
|
||||
bucket: "test-bucket".to_string(),
|
||||
quota: Some(2147483648),
|
||||
size: 1073741824,
|
||||
quota_type: rustfs_config::QUOTA_TYPE_HARD.to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
assert!(json.contains("test-bucket"));
|
||||
assert!(json.contains("2147483648"));
|
||||
assert!(json.contains("HARD"));
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -29,7 +29,7 @@ use handlers::{
|
||||
event::{ListNotificationTargets, ListTargetsArns, NotificationTarget, RemoveNotificationTarget},
|
||||
group, kms, kms_dynamic, kms_keys, policies, pools,
|
||||
profile::{TriggerProfileCPU, TriggerProfileMemory},
|
||||
rebalance,
|
||||
quota, rebalance,
|
||||
service_account::{AddServiceAccount, DeleteServiceAccount, InfoServiceAccount, ListServiceAccount, UpdateServiceAccount},
|
||||
sts, tier, user,
|
||||
};
|
||||
@@ -202,6 +202,32 @@ pub fn make_admin_route(console_enabled: bool) -> std::io::Result<impl S3Route>
|
||||
AdminOperation(&tier::ClearTier {}),
|
||||
)?;
|
||||
|
||||
r.insert(
|
||||
Method::PUT,
|
||||
format!("{}{}", ADMIN_PREFIX, "/v3/quota/{bucket}").as_str(),
|
||||
AdminOperation("a::SetBucketQuotaHandler {}),
|
||||
)?;
|
||||
r.insert(
|
||||
Method::GET,
|
||||
format!("{}{}", ADMIN_PREFIX, "/v3/quota/{bucket}").as_str(),
|
||||
AdminOperation("a::GetBucketQuotaHandler {}),
|
||||
)?;
|
||||
r.insert(
|
||||
Method::DELETE,
|
||||
format!("{}{}", ADMIN_PREFIX, "/v3/quota/{bucket}").as_str(),
|
||||
AdminOperation("a::ClearBucketQuotaHandler {}),
|
||||
)?;
|
||||
r.insert(
|
||||
Method::GET,
|
||||
format!("{}{}", ADMIN_PREFIX, "/v3/quota-stats/{bucket}").as_str(),
|
||||
AdminOperation("a::GetBucketQuotaStatsHandler {}),
|
||||
)?;
|
||||
r.insert(
|
||||
Method::POST,
|
||||
format!("{}{}", ADMIN_PREFIX, "/v3/quota-check/{bucket}").as_str(),
|
||||
AdminOperation("a::CheckBucketQuotaHandler {}),
|
||||
)?;
|
||||
|
||||
r.insert(
|
||||
Method::GET,
|
||||
format!("{}{}", ADMIN_PREFIX, "/export-bucket-metadata").as_str(),
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_ecstore::bucket::quota::QuotaError;
|
||||
use rustfs_ecstore::error::StorageError;
|
||||
use s3s::{S3Error, S3ErrorCode};
|
||||
|
||||
@@ -284,6 +285,29 @@ impl From<rustfs_iam::error::Error> for ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<QuotaError> for ApiError {
|
||||
fn from(err: QuotaError) -> Self {
|
||||
let code = match &err {
|
||||
QuotaError::QuotaExceeded { .. } => S3ErrorCode::InvalidRequest,
|
||||
QuotaError::ConfigNotFound { .. } => S3ErrorCode::NoSuchBucket,
|
||||
QuotaError::InvalidConfig { .. } => S3ErrorCode::InvalidArgument,
|
||||
QuotaError::StorageError(_) => S3ErrorCode::InternalError,
|
||||
};
|
||||
|
||||
let message = if code == S3ErrorCode::InternalError {
|
||||
err.to_string()
|
||||
} else {
|
||||
ApiError::error_code_to_message(&code)
|
||||
};
|
||||
|
||||
ApiError {
|
||||
code,
|
||||
message,
|
||||
source: Some(Box::new(err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+111
-10
@@ -40,6 +40,7 @@ use datafusion::arrow::{
|
||||
use futures::StreamExt;
|
||||
use http::{HeaderMap, StatusCode};
|
||||
use metrics::counter;
|
||||
use rustfs_ecstore::bucket::quota::checker::QuotaChecker;
|
||||
use rustfs_ecstore::{
|
||||
bucket::{
|
||||
lifecycle::{
|
||||
@@ -54,6 +55,7 @@ use rustfs_ecstore::{
|
||||
metadata_sys::get_replication_config,
|
||||
object_lock::objectlock_sys::BucketObjectLockSys,
|
||||
policy_sys::PolicySys,
|
||||
quota::QuotaOperation,
|
||||
replication::{
|
||||
DeletedObjectReplicationInfo, ReplicationConfigurationExt, check_replicate_delete, get_must_replicate_options,
|
||||
must_replicate, schedule_replication, schedule_replication_delete,
|
||||
@@ -1067,11 +1069,42 @@ impl S3 for FS {
|
||||
}
|
||||
}
|
||||
|
||||
// check quota for copy operation
|
||||
if let Some(metadata_sys) = rustfs_ecstore::bucket::metadata_sys::GLOBAL_BucketMetadataSys.get() {
|
||||
let quota_checker = QuotaChecker::new(metadata_sys.clone());
|
||||
|
||||
match quota_checker
|
||||
.check_quota(&bucket, QuotaOperation::CopyObject, src_info.size as u64)
|
||||
.await
|
||||
{
|
||||
Ok(check_result) => {
|
||||
if !check_result.allowed {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidRequest,
|
||||
format!(
|
||||
"Bucket quota exceeded. Current usage: {} bytes, limit: {} bytes",
|
||||
check_result.current_usage,
|
||||
check_result.quota_limit.unwrap_or(0)
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Quota check failed for bucket {}: {}, allowing operation", bucket, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let oi = store
|
||||
.copy_object(&src_bucket, &src_key, &bucket, &key, &mut src_info, &src_opts, &dst_opts)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
// Update quota tracking after successful copy
|
||||
if rustfs_ecstore::bucket::metadata_sys::GLOBAL_BucketMetadataSys.get().is_some() {
|
||||
rustfs_ecstore::data_usage::increment_bucket_usage_memory(&bucket, oi.size as u64).await;
|
||||
}
|
||||
|
||||
// Invalidate cache for the destination object to prevent stale data
|
||||
let manager = get_concurrency_manager();
|
||||
let dest_bucket = bucket.clone();
|
||||
@@ -1440,6 +1473,9 @@ impl S3 for FS {
|
||||
}
|
||||
};
|
||||
|
||||
// Fast in-memory update for immediate quota consistency
|
||||
rustfs_ecstore::data_usage::decrement_bucket_usage_memory(&bucket, obj_info.size as u64).await;
|
||||
|
||||
// Invalidate cache for the deleted object
|
||||
let manager = get_concurrency_manager();
|
||||
let del_bucket = bucket.clone();
|
||||
@@ -1534,8 +1570,6 @@ impl S3 for FS {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
let has_lock_enable = BucketObjectLockSys::get(&bucket).await.is_some();
|
||||
|
||||
let version_cfg = BucketVersioningSys::get(&bucket).await.unwrap_or_default();
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
@@ -1548,6 +1582,7 @@ impl S3 for FS {
|
||||
|
||||
let mut object_to_delete = Vec::new();
|
||||
let mut object_to_delete_index = HashMap::new();
|
||||
let mut object_sizes = HashMap::new();
|
||||
for (idx, obj_id) in delete.objects.iter().enumerate() {
|
||||
// Per S3 API spec, "null" string means non-versioned object
|
||||
// Filter out "null" version_id to treat as unversioned
|
||||
@@ -1606,15 +1641,14 @@ impl S3 for FS {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let mut goi = ObjectInfo::default();
|
||||
let mut gerr = None;
|
||||
// Get object info to collect size for quota tracking
|
||||
let (goi, gerr) = match store.get_object_info(&bucket, &object.object_name, &opts).await {
|
||||
Ok(res) => (res, None),
|
||||
Err(e) => (ObjectInfo::default(), Some(e.to_string())),
|
||||
};
|
||||
|
||||
if replicate_deletes || object.version_id.is_some() && has_lock_enable {
|
||||
(goi, gerr) = match store.get_object_info(&bucket, &object.object_name, &opts).await {
|
||||
Ok(res) => (res, None),
|
||||
Err(e) => (ObjectInfo::default(), Some(e.to_string())),
|
||||
};
|
||||
}
|
||||
// Store object size for quota tracking
|
||||
object_sizes.insert(object.object_name.clone(), goi.size);
|
||||
|
||||
if is_dir_object(&object.object_name) && object.version_id.is_none() {
|
||||
object.version_id = Some(Uuid::nil());
|
||||
@@ -1716,6 +1750,10 @@ impl S3 for FS {
|
||||
dobjs[i].replication_state = Some(object_to_delete[i].replication_state());
|
||||
}
|
||||
delete_results[*didx].delete_object = Some(dobjs[i].clone());
|
||||
// Update quota tracking for successfully deleted objects
|
||||
if let Some(&size) = object_sizes.get(&obj.object_name) {
|
||||
rustfs_ecstore::data_usage::decrement_bucket_usage_memory(&bucket, size as u64).await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -3151,6 +3189,34 @@ impl S3 for FS {
|
||||
// Validate object key
|
||||
validate_object_key(&key, "PUT")?;
|
||||
|
||||
// check quota for put operation
|
||||
if let Some(size) = content_length
|
||||
&& let Some(metadata_sys) = rustfs_ecstore::bucket::metadata_sys::GLOBAL_BucketMetadataSys.get()
|
||||
{
|
||||
let quota_checker = QuotaChecker::new(metadata_sys.clone());
|
||||
|
||||
match quota_checker
|
||||
.check_quota(&bucket, QuotaOperation::PutObject, size as u64)
|
||||
.await
|
||||
{
|
||||
Ok(check_result) => {
|
||||
if !check_result.allowed {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidRequest,
|
||||
format!(
|
||||
"Bucket quota exceeded. Current usage: {} bytes, limit: {} bytes",
|
||||
check_result.current_usage,
|
||||
check_result.quota_limit.unwrap_or(0)
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Quota check failed for bucket {}: {}, allowing operation", bucket, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if if_match.is_some() || if_none_match.is_some() {
|
||||
let Some(store) = new_object_layer_fn() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
@@ -3429,6 +3495,9 @@ impl S3 for FS {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
// Fast in-memory update for immediate quota consistency
|
||||
rustfs_ecstore::data_usage::increment_bucket_usage_memory(&bucket, obj_info.size as u64).await;
|
||||
|
||||
// Invalidate cache for the written object to prevent stale data
|
||||
let manager = get_concurrency_manager();
|
||||
let put_bucket = bucket.clone();
|
||||
@@ -4356,6 +4425,38 @@ impl S3 for FS {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
// check quota after completing multipart upload
|
||||
if let Some(metadata_sys) = rustfs_ecstore::bucket::metadata_sys::GLOBAL_BucketMetadataSys.get() {
|
||||
let quota_checker = QuotaChecker::new(metadata_sys.clone());
|
||||
|
||||
match quota_checker
|
||||
.check_quota(&bucket, QuotaOperation::PutObject, obj_info.size as u64)
|
||||
.await
|
||||
{
|
||||
Ok(check_result) => {
|
||||
if !check_result.allowed {
|
||||
// Quota exceeded, delete the completed object
|
||||
let _ = store.delete_object(&bucket, &key, ObjectOptions::default()).await;
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidRequest,
|
||||
format!(
|
||||
"Bucket quota exceeded. Current usage: {} bytes, limit: {} bytes",
|
||||
check_result.current_usage,
|
||||
check_result.quota_limit.unwrap_or(0)
|
||||
),
|
||||
));
|
||||
}
|
||||
// Update quota tracking after successful multipart upload
|
||||
if rustfs_ecstore::bucket::metadata_sys::GLOBAL_BucketMetadataSys.get().is_some() {
|
||||
rustfs_ecstore::data_usage::increment_bucket_usage_memory(&bucket, obj_info.size as u64).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Quota check failed for bucket {}: {}, allowing operation", bucket, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate cache for the completed multipart object
|
||||
let manager = get_concurrency_manager();
|
||||
let mpu_bucket = bucket.clone();
|
||||
|
||||
Reference in New Issue
Block a user