mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-02 03:19:19 +00:00
1255 lines
49 KiB
Rust
1255 lines
49 KiB
Rust
// 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.
|
|
|
|
use crate::admin::auth::validate_admin_request;
|
|
use crate::admin::handlers::site_replication::site_replication_peer_deployment_id_for_endpoint;
|
|
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
|
use crate::admin::runtime_sources::{
|
|
AppContext, app_context_from_req, current_notification_system_for_context, current_replication_stats_handle_for_context,
|
|
current_runtime_port, object_store_from_req,
|
|
};
|
|
use crate::admin::storage_api::bucket::metadata::BUCKET_TARGETS_FILE;
|
|
use crate::admin::storage_api::bucket::metadata_sys;
|
|
use crate::admin::storage_api::bucket::metadata_sys::get_replication_config;
|
|
use crate::admin::storage_api::bucket::replication::{BucketStats, ReplicationStatusType};
|
|
use crate::admin::storage_api::bucket::target::{BucketTarget, BucketTargetType, Credentials as TargetCredentials, LatencyStat};
|
|
use crate::admin::storage_api::bucket::target_sys::{BucketTargetError, BucketTargetSys};
|
|
use crate::admin::storage_api::contract::bucket::{BucketOperations, BucketOptions};
|
|
use crate::admin::storage_api::contract::list::ListOperations as _;
|
|
use crate::admin::storage_api::error::StorageError;
|
|
use crate::admin::storage_api::runtime::PeerRestClient;
|
|
use crate::admin::utils::read_compatible_admin_body;
|
|
use crate::auth::{check_key_valid, get_session_token};
|
|
use crate::error::ApiError;
|
|
use crate::server::{ADMIN_PREFIX, RemoteAddr};
|
|
use crate::storage::storage_api::lock_bucket_targets_metadata;
|
|
use http::{HeaderMap, HeaderValue, Uri};
|
|
use hyper::{Method, StatusCode};
|
|
use matchit::Params;
|
|
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
|
|
use rustfs_credentials::Credentials;
|
|
use rustfs_policy::policy::action::{Action, AdminAction};
|
|
use s3s::header::CONTENT_TYPE;
|
|
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use time::OffsetDateTime;
|
|
use tracing::{debug, error, info, warn};
|
|
use url::Host;
|
|
|
|
fn extract_query_params(uri: &Uri) -> HashMap<String, String> {
|
|
let mut params = HashMap::new();
|
|
|
|
if let Some(query) = uri.query() {
|
|
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
|
|
params.insert(key.into_owned(), value.into_owned());
|
|
}
|
|
}
|
|
|
|
params
|
|
}
|
|
|
|
fn map_bucket_target_error(err: BucketTargetError) -> S3Error {
|
|
match err {
|
|
BucketTargetError::BucketRemoteTargetNotFound { .. }
|
|
| BucketTargetError::BucketRemoteArnTypeInvalid { .. }
|
|
| BucketTargetError::BucketRemoteAlreadyExists { .. }
|
|
| BucketTargetError::BucketRemoteArnInvalid { .. }
|
|
| BucketTargetError::RemoteTargetConnectionErr { .. }
|
|
| BucketTargetError::BucketReplicationSourceNotVersioned { .. }
|
|
| BucketTargetError::BucketRemoteTargetNotVersioned { .. }
|
|
| BucketTargetError::BucketRemoteRemoveDisallowed { .. } => {
|
|
S3Error::with_message(S3ErrorCode::InvalidRequest, err.to_string())
|
|
}
|
|
BucketTargetError::Io(io_err) => S3Error::with_message(S3ErrorCode::InternalError, io_err.to_string()),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct RemoteTargetCredentialsRequest {
|
|
#[serde(rename = "accessKey")]
|
|
access_key: String,
|
|
#[serde(rename = "secretKey")]
|
|
secret_key: String,
|
|
session_token: Option<String>,
|
|
expiration: Option<chrono::DateTime<chrono::Utc>>,
|
|
}
|
|
|
|
impl From<RemoteTargetCredentialsRequest> for TargetCredentials {
|
|
fn from(value: RemoteTargetCredentialsRequest) -> Self {
|
|
Self {
|
|
access_key: value.access_key,
|
|
secret_key: value.secret_key,
|
|
session_token: value.session_token,
|
|
expiration: value.expiration,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct RemoteTargetRequest {
|
|
#[serde(rename = "sourcebucket", default)]
|
|
source_bucket: String,
|
|
endpoint: String,
|
|
credentials: RemoteTargetCredentialsRequest,
|
|
#[serde(rename = "targetbucket")]
|
|
target_bucket: String,
|
|
#[serde(default)]
|
|
secure: bool,
|
|
#[serde(default)]
|
|
path: String,
|
|
#[serde(default)]
|
|
api: String,
|
|
#[serde(default)]
|
|
arn: String,
|
|
#[serde(rename = "type")]
|
|
target_type: BucketTargetType,
|
|
#[serde(default)]
|
|
region: String,
|
|
#[serde(alias = "bandwidth", default)]
|
|
bandwidth_limit: i64,
|
|
#[serde(rename = "replicationSync", default)]
|
|
replication_sync: bool,
|
|
#[serde(default)]
|
|
storage_class: String,
|
|
#[serde(rename = "skipTlsVerify", default)]
|
|
skip_tls_verify: bool,
|
|
#[serde(rename = "caCertPem", default)]
|
|
ca_cert_pem: String,
|
|
#[serde(rename = "healthCheckDuration", default)]
|
|
health_check_duration: u64,
|
|
#[serde(rename = "disableProxy", default)]
|
|
disable_proxy: bool,
|
|
#[serde(rename = "resetBeforeDate", with = "time::serde::rfc3339::option", default)]
|
|
reset_before_date: Option<OffsetDateTime>,
|
|
#[serde(default)]
|
|
reset_id: String,
|
|
#[serde(rename = "totalDowntime", default)]
|
|
total_downtime: u64,
|
|
#[serde(rename = "lastOnline", with = "time::serde::rfc3339::option", default)]
|
|
last_online: Option<OffsetDateTime>,
|
|
#[serde(rename = "isOnline", default)]
|
|
online: bool,
|
|
#[serde(default)]
|
|
latency: LatencyStat,
|
|
#[serde(default)]
|
|
deployment_id: String,
|
|
#[serde(default)]
|
|
edge: bool,
|
|
#[serde(rename = "edgeSyncBeforeExpiry", default)]
|
|
edge_sync_before_expiry: bool,
|
|
#[serde(rename = "offlineCount", default)]
|
|
offline_count: u64,
|
|
}
|
|
|
|
impl RemoteTargetRequest {
|
|
fn into_bucket_target(self) -> S3Result<BucketTarget> {
|
|
if self.endpoint.trim().is_empty() {
|
|
return Err(s3_error!(InvalidRequest, "endpoint is required"));
|
|
}
|
|
|
|
if self.target_bucket.trim().is_empty() {
|
|
return Err(s3_error!(InvalidRequest, "targetbucket is required"));
|
|
}
|
|
|
|
if !self.target_type.is_valid() {
|
|
return Err(s3_error!(InvalidRequest, "type is invalid"));
|
|
}
|
|
|
|
if self.credentials.access_key.trim().is_empty() {
|
|
return Err(s3_error!(InvalidRequest, "credentials.accessKey is required"));
|
|
}
|
|
|
|
if self.credentials.secret_key.trim().is_empty() {
|
|
return Err(s3_error!(InvalidRequest, "credentials.secretKey is required"));
|
|
}
|
|
|
|
for (unsupported, configured) in [
|
|
("disableProxy", self.disable_proxy),
|
|
("healthCheckDuration", self.health_check_duration != 0),
|
|
("edge", self.edge),
|
|
("edgeSyncBeforeExpiry", self.edge_sync_before_expiry),
|
|
] {
|
|
if configured {
|
|
return Err(s3_error!(
|
|
InvalidRequest,
|
|
"remote target field {unsupported} is not supported by this RustFS version"
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(BucketTarget {
|
|
source_bucket: self.source_bucket,
|
|
endpoint: self.endpoint,
|
|
credentials: Some(self.credentials.into()),
|
|
target_bucket: self.target_bucket,
|
|
secure: self.secure,
|
|
path: self.path,
|
|
api: self.api,
|
|
arn: self.arn,
|
|
target_type: self.target_type,
|
|
region: self.region,
|
|
bandwidth_limit: self.bandwidth_limit,
|
|
replication_sync: self.replication_sync,
|
|
storage_class: self.storage_class,
|
|
skip_tls_verify: self.skip_tls_verify,
|
|
ca_cert_pem: self.ca_cert_pem,
|
|
health_check_duration: Duration::from_secs(self.health_check_duration),
|
|
disable_proxy: self.disable_proxy,
|
|
reset_before_date: self.reset_before_date,
|
|
reset_id: self.reset_id,
|
|
total_downtime: Duration::from_secs(self.total_downtime),
|
|
last_online: self.last_online,
|
|
online: self.online,
|
|
latency: self.latency,
|
|
deployment_id: self.deployment_id,
|
|
edge: self.edge,
|
|
edge_sync_before_expiry: self.edge_sync_before_expiry,
|
|
offline_count: self.offline_count,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn validate_remote_target_tls_settings(remote_target: &BucketTarget) -> S3Result<()> {
|
|
let has_custom_ca = !remote_target.ca_cert_pem.trim().is_empty();
|
|
|
|
if !remote_target.secure && remote_target.skip_tls_verify {
|
|
return Err(s3_error!(InvalidRequest, "skipTlsVerify requires an HTTPS remote target"));
|
|
}
|
|
|
|
if !remote_target.secure && has_custom_ca {
|
|
return Err(s3_error!(InvalidRequest, "caCertPem requires an HTTPS remote target"));
|
|
}
|
|
|
|
if remote_target.skip_tls_verify && has_custom_ca {
|
|
return Err(s3_error!(InvalidRequest, "skipTlsVerify and caCertPem cannot be enabled together"));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn register_replication_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
|
|
r.insert(
|
|
Method::GET,
|
|
format!("{}{}", ADMIN_PREFIX, "/v3/list-remote-targets").as_str(),
|
|
AdminOperation(&ListRemoteTargetHandler {}),
|
|
)?;
|
|
|
|
r.insert(
|
|
Method::GET,
|
|
format!("{}{}", ADMIN_PREFIX, "/v3/replicationmetrics").as_str(),
|
|
AdminOperation(&GetReplicationMetricsHandler {}),
|
|
)?;
|
|
|
|
r.insert(
|
|
Method::PUT,
|
|
format!("{}{}", ADMIN_PREFIX, "/v3/set-remote-target").as_str(),
|
|
AdminOperation(&SetRemoteTargetHandler {}),
|
|
)?;
|
|
|
|
r.insert(
|
|
Method::DELETE,
|
|
format!("{}{}", ADMIN_PREFIX, "/v3/remove-remote-target").as_str(),
|
|
AdminOperation(&RemoveRemoteTargetHandler {}),
|
|
)?;
|
|
|
|
r.insert(
|
|
Method::POST,
|
|
format!("{}{}", ADMIN_PREFIX, "/v3/replication/diff").as_str(),
|
|
AdminOperation(&ReplicationDiffHandler {}),
|
|
)?;
|
|
|
|
r.insert(
|
|
Method::GET,
|
|
format!("{}{}", ADMIN_PREFIX, "/v3/replication/mrf").as_str(),
|
|
AdminOperation(&ReplicationMrfHandler {}),
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn validate_replication_admin_request(req: &S3Request<Body>, action: AdminAction) -> S3Result<Credentials> {
|
|
let Some(input_cred) = req.credentials.as_ref() else {
|
|
return Err(s3_error!(InvalidRequest, "get cred failed"));
|
|
};
|
|
|
|
let (cred, owner) =
|
|
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
|
|
|
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
|
validate_admin_request(&req.headers, &cred, owner, false, vec![Action::AdminAction(action)], remote_addr).await?;
|
|
|
|
Ok(cred)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn is_local_host(_host: String) -> bool {
|
|
false
|
|
}
|
|
|
|
pub(crate) async fn cluster_replication_stats(bucket: &str, context: Option<Arc<AppContext>>) -> BucketStats {
|
|
let Some(stats) = current_replication_stats_handle_for_context(context.clone()) else {
|
|
return BucketStats::default();
|
|
};
|
|
|
|
let local = stats.get_latest_replication_stats(bucket).await;
|
|
let Some(notification_system) = current_notification_system_for_context(context.as_deref()) else {
|
|
return local;
|
|
};
|
|
|
|
let (peers, expected_node_count) = unique_replication_peers(¬ification_system.peer_clients);
|
|
let peer_results = futures_util::future::join_all(peers.into_iter().map(|peer| peer.get_bucket_stats(bucket))).await;
|
|
let mut snapshots = Vec::with_capacity(peer_results.len().saturating_add(1));
|
|
snapshots.push(local);
|
|
snapshots.extend(peer_results.into_iter().filter_map(Result::ok));
|
|
|
|
stats
|
|
.aggregate_bucket_replication_stats(bucket, snapshots, expected_node_count)
|
|
.await
|
|
}
|
|
|
|
fn unique_replication_peers(peer_clients: &[Option<PeerRestClient>]) -> (Vec<&PeerRestClient>, u32) {
|
|
let mut seen_grid_hosts = HashSet::new();
|
|
let peers: Vec<_> = peer_clients
|
|
.iter()
|
|
.filter_map(|peer| peer.as_ref())
|
|
.filter(|peer| seen_grid_hosts.insert(peer.grid_host.clone()))
|
|
.collect();
|
|
let unavailable_slots = peer_clients.iter().filter(|peer| peer.is_none()).count();
|
|
let expected_node_count = u32::try_from(peers.len().saturating_add(unavailable_slots).saturating_add(1)).unwrap_or(u32::MAX);
|
|
(peers, expected_node_count)
|
|
}
|
|
|
|
//awscurl --service s3 --region us-east-1 --access_key rustfsadmin --secret_key rustfsadmin "http://:9000/rustfs/admin/v3/replicationmetrics?bucket=1"
|
|
pub struct GetReplicationMetricsHandler {}
|
|
|
|
#[async_trait::async_trait]
|
|
impl Operation for GetReplicationMetricsHandler {
|
|
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
|
validate_replication_admin_request(&req, AdminAction::GetReplicationMetricsAction).await?;
|
|
|
|
let queries = extract_query_params(&req.uri);
|
|
|
|
let Some(bucket) = queries.get("bucket") else {
|
|
return Err(s3_error!(InvalidRequest, "bucket is required"));
|
|
};
|
|
|
|
if bucket.is_empty() {
|
|
return Err(s3_error!(InvalidRequest, "bucket is required"));
|
|
}
|
|
|
|
let Some(store) = object_store_from_req(&req) else {
|
|
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
|
};
|
|
|
|
store
|
|
.get_bucket_info(bucket, &BucketOptions::default())
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
if let Err(err) = get_replication_config(bucket).await {
|
|
if err == StorageError::ConfigNotFound {
|
|
info!("replication configuration not found for bucket '{}'", bucket);
|
|
return Err(S3Error::with_message(
|
|
S3ErrorCode::ReplicationConfigurationNotFoundError,
|
|
"replication not found".to_string(),
|
|
));
|
|
}
|
|
error!("get_replication_config unexpected error: {:?}", err);
|
|
return Err(ApiError::from(err).into());
|
|
}
|
|
|
|
let bucket_stats = cluster_replication_stats(bucket, app_context_from_req(&req)).await;
|
|
|
|
let data = serde_json::to_vec(&bucket_stats.replication_stats)
|
|
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "serialize failed"))?;
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
|
Ok(S3Response::with_headers((StatusCode::OK, Body::from(data)), headers))
|
|
}
|
|
}
|
|
|
|
pub struct SetRemoteTargetHandler {}
|
|
|
|
#[async_trait::async_trait]
|
|
impl Operation for SetRemoteTargetHandler {
|
|
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
|
let cred = validate_replication_admin_request(&req, AdminAction::SetBucketTargetAction).await?;
|
|
|
|
let queries = extract_query_params(&req.uri);
|
|
|
|
let Some(bucket) = queries.get("bucket") else {
|
|
return Err(s3_error!(InvalidRequest, "bucket is required"));
|
|
};
|
|
|
|
let update = queries.get("update").is_some_and(|v| v == "true");
|
|
|
|
warn!("set remote target, bucket: {}, update: {}", bucket, update);
|
|
|
|
if bucket.is_empty() {
|
|
return Err(s3_error!(InvalidRequest, "bucket is required"));
|
|
}
|
|
|
|
let Some(store) = object_store_from_req(&req) else {
|
|
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
|
};
|
|
|
|
store
|
|
.get_bucket_info(bucket, &BucketOptions::default())
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
let body =
|
|
match read_compatible_admin_body(req.input, MAX_ADMIN_REQUEST_BODY_SIZE, req.uri.path(), &cred.secret_key).await {
|
|
Ok(body) => body,
|
|
Err(e) => {
|
|
warn!("get body failed, e: {:?}", e);
|
|
return Err(e);
|
|
}
|
|
};
|
|
|
|
let mut remote_target = serde_json::from_slice::<RemoteTargetRequest>(&body)
|
|
.map_err(|e| {
|
|
error!("Failed to parse remote target request body: {}", e);
|
|
S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid remote target request: {e}"))
|
|
})?
|
|
.into_bucket_target()?;
|
|
validate_remote_target_tls_settings(&remote_target)?;
|
|
|
|
let Ok(target_url) = remote_target.url() else {
|
|
return Err(s3_error!(InvalidRequest, "invalid target url"));
|
|
};
|
|
|
|
let same_target = rustfs_utils::net::is_local_host(
|
|
target_url.host().unwrap_or(Host::Domain("localhost")),
|
|
target_url.port().unwrap_or(80),
|
|
current_runtime_port(),
|
|
)
|
|
.unwrap_or_default();
|
|
|
|
if same_target && bucket == &remote_target.target_bucket {
|
|
return Err(S3Error::with_message(S3ErrorCode::IncorrectEndpoint, "Same target".to_string()));
|
|
}
|
|
|
|
remote_target.source_bucket = bucket.clone();
|
|
let site_endpoint = if remote_target.endpoint.starts_with("http://") || remote_target.endpoint.starts_with("https://") {
|
|
remote_target.endpoint.clone()
|
|
} else if remote_target.secure {
|
|
format!("https://{}", remote_target.endpoint)
|
|
} else {
|
|
format!("http://{}", remote_target.endpoint)
|
|
};
|
|
if let Some(deployment_id) = site_replication_peer_deployment_id_for_endpoint(&site_endpoint).await {
|
|
remote_target.deployment_id = deployment_id;
|
|
}
|
|
|
|
let bucket_target_sys = BucketTargetSys::get();
|
|
|
|
if !update {
|
|
let (arn, exist) = bucket_target_sys
|
|
.get_remote_arn(bucket, Some(&remote_target), remote_target.deployment_id.as_str())
|
|
.await;
|
|
remote_target.arn = arn.clone();
|
|
if exist && !arn.is_empty() {
|
|
let arn_str = serde_json::to_string(&arn).unwrap_or_default();
|
|
|
|
warn!("return exists, arn: {}", arn_str);
|
|
// MinIO-compatible clients encrypt the request payload for this endpoint,
|
|
// but they parse the success response directly as plain JSON string ARN.
|
|
return Ok(S3Response::new((StatusCode::OK, Body::from(arn_str))));
|
|
}
|
|
}
|
|
|
|
if remote_target.arn.is_empty() {
|
|
return Err(S3Error::with_message(S3ErrorCode::InvalidRequest, "ARN is empty".to_string()));
|
|
}
|
|
let _targets_guard = lock_bucket_targets_metadata(bucket).await;
|
|
|
|
if update {
|
|
let Some(mut target) = bucket_target_sys
|
|
.get_remote_bucket_target_by_arn(bucket, &remote_target.arn)
|
|
.await
|
|
else {
|
|
return Err(S3Error::with_message(S3ErrorCode::InvalidRequest, "Target not found".to_string()));
|
|
};
|
|
|
|
target.credentials = remote_target.credentials;
|
|
target.endpoint = remote_target.endpoint;
|
|
target.secure = remote_target.secure;
|
|
target.target_bucket = remote_target.target_bucket;
|
|
|
|
target.path = remote_target.path;
|
|
target.replication_sync = remote_target.replication_sync;
|
|
target.bandwidth_limit = remote_target.bandwidth_limit;
|
|
target.skip_tls_verify = remote_target.skip_tls_verify;
|
|
target.ca_cert_pem = remote_target.ca_cert_pem;
|
|
target.health_check_duration = remote_target.health_check_duration;
|
|
|
|
warn!(
|
|
bucket = %bucket,
|
|
arn = %target.arn,
|
|
endpoint = %target.endpoint,
|
|
secure = target.secure,
|
|
skip_tls_verify = target.skip_tls_verify,
|
|
has_custom_ca = !target.ca_cert_pem.trim().is_empty(),
|
|
"update remote target"
|
|
);
|
|
remote_target = target;
|
|
}
|
|
|
|
let arn = remote_target.arn.clone();
|
|
|
|
let targets = bucket_target_sys
|
|
.set_target(bucket, &remote_target, update)
|
|
.await
|
|
.map_err(map_bucket_target_error)?;
|
|
let json_targets = serde_json::to_vec(&targets).map_err(|e| {
|
|
error!("Serialization error: {}", e);
|
|
S3Error::with_message(S3ErrorCode::InternalError, "Failed to serialize targets".to_string())
|
|
})?;
|
|
|
|
metadata_sys::update(bucket, BUCKET_TARGETS_FILE, json_targets)
|
|
.await
|
|
.map_err(|e| {
|
|
error!("Failed to update bucket targets: {}", e);
|
|
S3Error::with_message(S3ErrorCode::InternalError, format!("Failed to update bucket targets: {e}"))
|
|
})?;
|
|
bucket_target_sys.update_all_targets(bucket, Some(&targets)).await;
|
|
|
|
let arn_str = serde_json::to_string(&arn).unwrap_or_default();
|
|
|
|
// MinIO-compatible clients encrypt the request payload for this endpoint,
|
|
// but they parse the success response directly as plain JSON string ARN.
|
|
Ok(S3Response::new((StatusCode::OK, Body::from(arn_str))))
|
|
}
|
|
}
|
|
|
|
pub struct ListRemoteTargetHandler {}
|
|
|
|
#[async_trait::async_trait]
|
|
impl Operation for ListRemoteTargetHandler {
|
|
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
|
validate_replication_admin_request(&req, AdminAction::GetBucketTargetAction).await?;
|
|
|
|
let queries = extract_query_params(&req.uri);
|
|
|
|
if let Some(bucket) = queries.get("bucket") {
|
|
if bucket.is_empty() {
|
|
error!("bucket parameter is empty");
|
|
return Err(s3_error!(InvalidRequest, "bucket is required"));
|
|
}
|
|
|
|
let Some(store) = object_store_from_req(&req) else {
|
|
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not initialized".to_string()));
|
|
};
|
|
|
|
store
|
|
.get_bucket_info(bucket, &BucketOptions::default())
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
let sys = BucketTargetSys::get();
|
|
let targets = sys.list_targets(bucket, "").await;
|
|
|
|
let targets: Vec<_> = targets.iter().map(|target| target.redacted_credentials()).collect();
|
|
let json_targets = serde_json::to_vec(&targets).map_err(|e| {
|
|
error!("Serialization error: {}", e);
|
|
S3Error::with_message(S3ErrorCode::InternalError, "Failed to serialize targets".to_string())
|
|
})?;
|
|
|
|
let mut header = HeaderMap::new();
|
|
header.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
|
|
|
return Ok(S3Response::with_headers((StatusCode::OK, Body::from(json_targets)), header));
|
|
}
|
|
|
|
let targets: Vec<BucketTarget> = Vec::new();
|
|
|
|
let json_targets = serde_json::to_vec(&targets).map_err(|e| {
|
|
error!("Serialization error: {}", e);
|
|
S3Error::with_message(S3ErrorCode::InternalError, "Failed to serialize targets".to_string())
|
|
})?;
|
|
|
|
let mut header = HeaderMap::new();
|
|
header.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
|
|
|
Ok(S3Response::with_headers((StatusCode::OK, Body::from(json_targets)), header))
|
|
}
|
|
}
|
|
|
|
pub struct RemoveRemoteTargetHandler {}
|
|
|
|
#[async_trait::async_trait]
|
|
impl Operation for RemoveRemoteTargetHandler {
|
|
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
|
validate_replication_admin_request(&req, AdminAction::SetBucketTargetAction).await?;
|
|
|
|
debug!("remove remote target called");
|
|
let queries = extract_query_params(&req.uri);
|
|
let Some(bucket) = queries.get("bucket") else {
|
|
return Err(s3_error!(InvalidRequest, "bucket is required"));
|
|
};
|
|
if bucket.is_empty() {
|
|
return Err(s3_error!(InvalidRequest, "bucket is required"));
|
|
}
|
|
|
|
let Some(arn_str) = queries.get("arn") else {
|
|
return Err(s3_error!(InvalidRequest, "arn is required"));
|
|
};
|
|
if arn_str.is_empty() {
|
|
return Err(s3_error!(InvalidRequest, "arn is required"));
|
|
};
|
|
|
|
let Some(store) = object_store_from_req(&req) else {
|
|
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not initialized".to_string()));
|
|
};
|
|
|
|
store
|
|
.get_bucket_info(bucket, &BucketOptions::default())
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
let sys = BucketTargetSys::get();
|
|
let _targets_guard = lock_bucket_targets_metadata(bucket).await;
|
|
|
|
let targets = sys.remove_target(bucket, arn_str).await.map_err(map_bucket_target_error)?;
|
|
|
|
let json_targets = serde_json::to_vec(&targets).map_err(|e| {
|
|
error!("Serialization error: {}", e);
|
|
S3Error::with_message(S3ErrorCode::InternalError, "Failed to serialize targets".to_string())
|
|
})?;
|
|
|
|
metadata_sys::update(bucket, BUCKET_TARGETS_FILE, json_targets)
|
|
.await
|
|
.map_err(|e| {
|
|
error!("Failed to update bucket targets: {}", e);
|
|
S3Error::with_message(S3ErrorCode::InternalError, format!("Failed to update bucket targets: {e}"))
|
|
})?;
|
|
sys.update_all_targets(bucket, Some(&targets)).await;
|
|
|
|
Ok(S3Response::new((StatusCode::NO_CONTENT, Body::from("".to_string()))))
|
|
}
|
|
}
|
|
|
|
/// Upper bound on the number of object versions scanned per `POST
|
|
/// /v3/replication/diff` request. RustFS has no persisted per-object
|
|
/// replication-diff index, so the diff is computed by scanning object versions
|
|
/// on demand. Cap the work so a single admin call cannot walk an arbitrarily
|
|
/// large bucket. When the scan is truncated, `is_truncated` is set on the
|
|
/// response so clients know the diff is partial.
|
|
const REPLICATION_DIFF_MAX_SCAN: usize = 10_000;
|
|
|
|
/// Number of object versions requested per `list_object_versions` page while
|
|
/// computing a replication diff.
|
|
const REPLICATION_DIFF_PAGE_SIZE: i32 = 1_000;
|
|
|
|
/// A single object version whose replication is not yet complete, reported by
|
|
/// `POST /v3/replication/diff`. Field names mirror MinIO's `madmin.DiffInfo`
|
|
/// so MinIO-compatible admin clients can parse the response.
|
|
#[derive(Debug, Serialize)]
|
|
struct ReplicationDiffEntry {
|
|
#[serde(rename = "Object")]
|
|
object: String,
|
|
#[serde(rename = "VersionID", skip_serializing_if = "Option::is_none")]
|
|
version_id: Option<String>,
|
|
#[serde(rename = "Size")]
|
|
size: i64,
|
|
#[serde(rename = "IsDeleteMarker")]
|
|
is_delete_marker: bool,
|
|
#[serde(rename = "ReplicationStatus")]
|
|
replication_status: String,
|
|
#[serde(rename = "LastModified", skip_serializing_if = "Option::is_none")]
|
|
last_modified: Option<String>,
|
|
}
|
|
|
|
/// Response body for `POST /v3/replication/diff`.
|
|
///
|
|
/// `entries` lists object versions with a `PENDING` or `FAILED` replication
|
|
/// status. `is_truncated` indicates the on-demand scan hit
|
|
/// [`REPLICATION_DIFF_MAX_SCAN`] before reaching the end of the bucket, so the
|
|
/// diff is partial and should be re-run with a narrower prefix.
|
|
#[derive(Debug, Serialize)]
|
|
struct ReplicationDiffResponse {
|
|
#[serde(rename = "Entries")]
|
|
entries: Vec<ReplicationDiffEntry>,
|
|
#[serde(rename = "IsTruncated")]
|
|
is_truncated: bool,
|
|
#[serde(rename = "ScannedVersions")]
|
|
scanned_versions: usize,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct ReplicationDiffRequest {
|
|
#[serde(default)]
|
|
prefix: String,
|
|
}
|
|
|
|
/// `POST /v3/replication/diff`
|
|
///
|
|
/// Computes, on demand, the set of object versions in a bucket whose replication
|
|
/// is still `PENDING` or has `FAILED`. RustFS stores the replication status on
|
|
/// each object version (`x-amz-replication-status`) but has no pre-built diff
|
|
/// index, so this handler scans object versions (bounded by
|
|
/// [`REPLICATION_DIFF_MAX_SCAN`]) and returns the not-yet-replicated versions.
|
|
///
|
|
/// The bucket must exist and have a replication configuration, matching MinIO's
|
|
/// behavior of returning `ReplicationConfigurationNotFoundError` otherwise.
|
|
pub struct ReplicationDiffHandler {}
|
|
|
|
#[async_trait::async_trait]
|
|
impl Operation for ReplicationDiffHandler {
|
|
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
|
let cred = validate_replication_admin_request(&req, AdminAction::ReplicationDiff).await?;
|
|
|
|
let queries = extract_query_params(&req.uri);
|
|
let Some(bucket) = queries.get("bucket").filter(|b| !b.is_empty()).cloned() else {
|
|
return Err(s3_error!(InvalidRequest, "bucket is required"));
|
|
};
|
|
|
|
let Some(store) = object_store_from_req(&req) else {
|
|
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
|
};
|
|
|
|
store
|
|
.get_bucket_info(&bucket, &BucketOptions::default())
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
// A replication diff is only meaningful for a bucket that is configured
|
|
// for replication; mirror MinIO's not-found semantics otherwise.
|
|
if let Err(err) = get_replication_config(&bucket).await {
|
|
if err == StorageError::ConfigNotFound {
|
|
return Err(S3Error::with_message(
|
|
S3ErrorCode::ReplicationConfigurationNotFoundError,
|
|
"replication configuration not found".to_string(),
|
|
));
|
|
}
|
|
return Err(ApiError::from(err).into());
|
|
}
|
|
|
|
// Optional prefix can be supplied either as a query parameter (MinIO
|
|
// clients) or, for RustFS clients, as a small JSON body.
|
|
let mut prefix = queries.get("prefix").cloned().unwrap_or_default();
|
|
let body = read_compatible_admin_body(req.input, MAX_ADMIN_REQUEST_BODY_SIZE, req.uri.path(), &cred.secret_key)
|
|
.await
|
|
.unwrap_or_default();
|
|
if prefix.is_empty() && !body.trim_ascii().is_empty() {
|
|
match serde_json::from_slice::<ReplicationDiffRequest>(&body) {
|
|
Ok(parsed) => prefix = parsed.prefix,
|
|
Err(e) => return Err(s3_error!(InvalidRequest, "invalid replication diff request body: {e}")),
|
|
}
|
|
}
|
|
|
|
let mut entries: Vec<ReplicationDiffEntry> = Vec::new();
|
|
let mut scanned_versions: usize = 0;
|
|
let mut marker: Option<String> = None;
|
|
let mut version_marker: Option<String> = None;
|
|
let mut is_truncated = false;
|
|
|
|
'scan: loop {
|
|
let listing = store
|
|
.clone()
|
|
.list_object_versions(&bucket, &prefix, marker.clone(), version_marker.clone(), None, REPLICATION_DIFF_PAGE_SIZE)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
for object in &listing.objects {
|
|
scanned_versions += 1;
|
|
|
|
if matches!(object.replication_status, ReplicationStatusType::Pending | ReplicationStatusType::Failed) {
|
|
entries.push(ReplicationDiffEntry {
|
|
object: object.name.clone(),
|
|
version_id: object.version_id.map(|v| v.to_string()),
|
|
size: object.size,
|
|
is_delete_marker: object.delete_marker,
|
|
replication_status: object.replication_status.as_str().to_string(),
|
|
last_modified: object
|
|
.mod_time
|
|
.and_then(|t| t.format(&time::format_description::well_known::Rfc3339).ok()),
|
|
});
|
|
}
|
|
|
|
if scanned_versions >= REPLICATION_DIFF_MAX_SCAN {
|
|
// We stopped early; the diff is partial.
|
|
is_truncated =
|
|
listing.is_truncated || listing.next_marker.is_some() || listing.next_version_idmarker.is_some();
|
|
break 'scan;
|
|
}
|
|
}
|
|
|
|
if !listing.is_truncated {
|
|
break;
|
|
}
|
|
marker = listing.next_marker;
|
|
version_marker = listing.next_version_idmarker;
|
|
if marker.is_none() && version_marker.is_none() {
|
|
break;
|
|
}
|
|
}
|
|
|
|
debug!(
|
|
bucket = %bucket,
|
|
prefix = %prefix,
|
|
scanned = scanned_versions,
|
|
pending_or_failed = entries.len(),
|
|
truncated = is_truncated,
|
|
"computed replication diff"
|
|
);
|
|
|
|
let response = ReplicationDiffResponse {
|
|
entries,
|
|
is_truncated,
|
|
scanned_versions,
|
|
};
|
|
let data = serde_json::to_vec(&response)
|
|
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize failed: {e}")))?;
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
|
Ok(S3Response::with_headers((StatusCode::OK, Body::from(data)), headers))
|
|
}
|
|
}
|
|
|
|
/// Failed-replication totals for one remote target (ARN), summarised from the
|
|
/// runtime replication statistics.
|
|
#[derive(Debug, Serialize)]
|
|
struct MrfTargetBacklog {
|
|
#[serde(rename = "ARN")]
|
|
arn: String,
|
|
#[serde(rename = "FailedCount")]
|
|
failed_count: i64,
|
|
#[serde(rename = "FailedSize")]
|
|
failed_size: i64,
|
|
#[serde(rename = "ObservationScope")]
|
|
observation_scope: &'static str,
|
|
}
|
|
|
|
/// Response body for `GET /v3/replication/mrf`.
|
|
///
|
|
/// Runtime failed/queued totals and the durable MRF recovery backlog are kept
|
|
/// separate because persisted MRF entries do not contain a target ARN.
|
|
#[derive(Debug, Serialize)]
|
|
struct MrfResponse {
|
|
#[serde(rename = "Bucket")]
|
|
bucket: String,
|
|
#[serde(rename = "Targets")]
|
|
targets: Vec<MrfTargetBacklog>,
|
|
#[serde(rename = "TotalFailedCount")]
|
|
total_failed_count: i64,
|
|
#[serde(rename = "TotalFailedSize")]
|
|
total_failed_size: i64,
|
|
#[serde(rename = "QueuedCount")]
|
|
queued_count: i64,
|
|
#[serde(rename = "QueuedSize")]
|
|
queued_size: i64,
|
|
#[serde(rename = "PerObjectEntriesAvailable")]
|
|
per_object_entries_available: bool,
|
|
#[serde(rename = "RuntimeStatsAvailable")]
|
|
runtime_stats_available: bool,
|
|
#[serde(rename = "ClusterComplete")]
|
|
cluster_complete: bool,
|
|
#[serde(rename = "ObservedNodeCount")]
|
|
observed_node_count: u32,
|
|
#[serde(rename = "ExpectedNodeCount")]
|
|
expected_node_count: u32,
|
|
#[serde(rename = "DurableBacklogAvailable")]
|
|
durable_backlog_available: bool,
|
|
#[serde(rename = "DurableCount")]
|
|
durable_count: i64,
|
|
#[serde(rename = "DurableSize")]
|
|
durable_size: i64,
|
|
#[serde(rename = "PerTargetDurableEntriesAvailable")]
|
|
per_target_durable_entries_available: bool,
|
|
}
|
|
|
|
fn build_mrf_response(
|
|
bucket: String,
|
|
bucket_stats: &BucketStats,
|
|
durable: crate::admin::storage_api::replication::DurableMrfBacklog,
|
|
) -> MrfResponse {
|
|
let observation_scope = if bucket_stats.replication_stats.cluster_complete {
|
|
"cluster_aggregated"
|
|
} else {
|
|
"partial_cluster"
|
|
};
|
|
let mut targets: Vec<MrfTargetBacklog> = Vec::with_capacity(bucket_stats.replication_stats.stats.len());
|
|
let mut total_failed_count: i64 = 0;
|
|
let mut total_failed_size: i64 = 0;
|
|
for (arn, stat) in &bucket_stats.replication_stats.stats {
|
|
total_failed_count = total_failed_count.saturating_add(stat.failed.count);
|
|
total_failed_size = total_failed_size.saturating_add(stat.failed.size);
|
|
targets.push(MrfTargetBacklog {
|
|
arn: arn.clone(),
|
|
failed_count: stat.failed.count,
|
|
failed_size: stat.failed.size,
|
|
observation_scope,
|
|
});
|
|
}
|
|
targets.sort_by(|a, b| a.arn.cmp(&b.arn));
|
|
|
|
let queued = &bucket_stats.replication_stats.q_stat.curr;
|
|
let (durable_count, durable_size) = if durable.available {
|
|
durable
|
|
.entries
|
|
.iter()
|
|
.filter(|entry| entry.bucket == bucket)
|
|
.fold((0i64, 0i64), |(count, size), entry| {
|
|
(count.saturating_add(1), size.saturating_add(entry.size))
|
|
})
|
|
} else {
|
|
(0, 0)
|
|
};
|
|
|
|
MrfResponse {
|
|
bucket,
|
|
targets,
|
|
total_failed_count,
|
|
total_failed_size,
|
|
queued_count: queued.count,
|
|
queued_size: queued.bytes,
|
|
per_object_entries_available: false,
|
|
runtime_stats_available: bucket_stats.replication_stats.provider_available,
|
|
cluster_complete: bucket_stats.replication_stats.cluster_complete,
|
|
observed_node_count: bucket_stats.replication_stats.observed_node_count,
|
|
expected_node_count: bucket_stats.replication_stats.expected_node_count,
|
|
durable_backlog_available: durable.available,
|
|
durable_count,
|
|
durable_size,
|
|
per_target_durable_entries_available: false,
|
|
}
|
|
}
|
|
|
|
/// `GET /v3/replication/mrf`
|
|
///
|
|
/// Reports the failed-replication backlog (MinIO's MRF concept) for a bucket.
|
|
///
|
|
/// Compatibility note: MinIO returns a stream of individual MRF entries. RustFS
|
|
/// deliberately returns aggregate runtime and durable counters instead.
|
|
/// `PerObjectEntriesAvailable` and `PerTargetDurableEntriesAvailable` remain
|
|
/// false until an enumerable API and target-bearing durable format exist.
|
|
pub struct ReplicationMrfHandler {}
|
|
|
|
#[async_trait::async_trait]
|
|
impl Operation for ReplicationMrfHandler {
|
|
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
|
validate_replication_admin_request(&req, AdminAction::GetReplicationMetricsAction).await?;
|
|
|
|
let queries = extract_query_params(&req.uri);
|
|
let Some(bucket) = queries.get("bucket").filter(|b| !b.is_empty()).cloned() else {
|
|
return Err(s3_error!(InvalidRequest, "bucket is required"));
|
|
};
|
|
|
|
let Some(store) = object_store_from_req(&req) else {
|
|
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
|
};
|
|
|
|
store
|
|
.get_bucket_info(&bucket, &BucketOptions::default())
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
if let Err(err) = get_replication_config(&bucket).await {
|
|
if err == StorageError::ConfigNotFound {
|
|
return Err(S3Error::with_message(
|
|
S3ErrorCode::ReplicationConfigurationNotFoundError,
|
|
"replication configuration not found".to_string(),
|
|
));
|
|
}
|
|
return Err(ApiError::from(err).into());
|
|
}
|
|
|
|
let durable = crate::admin::storage_api::replication::read_durable_mrf_backlog(store).await;
|
|
let bucket_stats = cluster_replication_stats(&bucket, app_context_from_req(&req)).await;
|
|
let response = build_mrf_response(bucket, &bucket_stats, durable);
|
|
|
|
let data = serde_json::to_vec(&response)
|
|
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize failed: {e}")))?;
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
|
Ok(S3Response::with_headers((StatusCode::OK, Body::from(data)), headers))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
RemoteTargetRequest, build_mrf_response, extract_query_params, unique_replication_peers,
|
|
validate_remote_target_tls_settings,
|
|
};
|
|
use crate::admin::storage_api::bucket::target::BucketTarget;
|
|
use crate::admin::storage_api::replication::{BucketStats, DurableMrfBacklog, MrfOpKind, MrfReplicateEntry};
|
|
use http::Uri;
|
|
|
|
fn valid_remote_target_request() -> serde_json::Value {
|
|
serde_json::json!({
|
|
"endpoint": "192.168.1.10:9000",
|
|
"credentials": {
|
|
"accessKey": "access",
|
|
"secretKey": "secret"
|
|
},
|
|
"targetbucket": "target",
|
|
"secure": true,
|
|
"type": "replication"
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn cluster_peer_plan_deduplicates_nodes_and_counts_unavailable_slots() {
|
|
let peer = crate::admin::storage_api::runtime::PeerRestClient::new(
|
|
rustfs_utils::XHost::try_from("127.0.0.1:9000".to_string()).expect("peer host should parse"),
|
|
"node-a.example.com:9001".to_string(),
|
|
);
|
|
let slots = vec![Some(peer.clone()), Some(peer), None];
|
|
|
|
let (peers, expected_node_count) = unique_replication_peers(&slots);
|
|
|
|
assert_eq!(peers.len(), 1);
|
|
assert_eq!(peers[0].grid_host, "node-a.example.com:9001");
|
|
assert_eq!(expected_node_count, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn mrf_response_keeps_runtime_and_durable_truth_separate() {
|
|
let mut stats = BucketStats::default();
|
|
stats.replication_stats.provider_available = true;
|
|
stats.replication_stats.cluster_complete = false;
|
|
stats.replication_stats.observed_node_count = 2;
|
|
stats.replication_stats.expected_node_count = 3;
|
|
let target = stats.replication_stats.stats.entry("arn-a".to_string()).or_default();
|
|
target.failed.count = 3;
|
|
target.failed.size = 900;
|
|
stats
|
|
.replication_stats
|
|
.q_stat
|
|
.curr
|
|
.now_count
|
|
.store(4, std::sync::atomic::Ordering::Relaxed);
|
|
stats
|
|
.replication_stats
|
|
.q_stat
|
|
.curr
|
|
.now_bytes
|
|
.store(1200, std::sync::atomic::Ordering::Relaxed);
|
|
stats.replication_stats.q_stat = stats.replication_stats.q_stat.snapshot();
|
|
let durable = DurableMrfBacklog {
|
|
available: true,
|
|
entries: vec![
|
|
MrfReplicateEntry {
|
|
bucket: "bucket-a".to_string(),
|
|
object: "object-a".to_string(),
|
|
version_id: None,
|
|
retry_count: 0,
|
|
size: 250,
|
|
op: MrfOpKind::Object,
|
|
delete_marker_version_id: None,
|
|
delete_marker: false,
|
|
delete_marker_mtime: None,
|
|
},
|
|
MrfReplicateEntry {
|
|
bucket: "other-bucket".to_string(),
|
|
object: "object-b".to_string(),
|
|
version_id: None,
|
|
retry_count: 0,
|
|
size: 999,
|
|
op: MrfOpKind::Object,
|
|
delete_marker_version_id: None,
|
|
delete_marker: false,
|
|
delete_marker_mtime: None,
|
|
},
|
|
],
|
|
};
|
|
|
|
let response = build_mrf_response("bucket-a".to_string(), &stats, durable);
|
|
let json = serde_json::to_value(response).expect("MRF response should serialize");
|
|
|
|
assert_eq!(json["TotalFailedCount"], 3);
|
|
assert_eq!(json["TotalFailedSize"], 900);
|
|
assert_eq!(json["QueuedCount"], 4);
|
|
assert_eq!(json["QueuedSize"], 1200);
|
|
assert_eq!(json["DurableCount"], 1);
|
|
assert_eq!(json["DurableSize"], 250);
|
|
assert_eq!(json["RuntimeStatsAvailable"], true);
|
|
assert_eq!(json["ClusterComplete"], false);
|
|
assert_eq!(json["Targets"][0]["ObservationScope"], "partial_cluster");
|
|
assert_eq!(json["PerObjectEntriesAvailable"], false);
|
|
assert_eq!(json["PerTargetDurableEntriesAvailable"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn mrf_response_distinguishes_unavailable_sources_from_valid_zero() {
|
|
let unavailable = build_mrf_response("bucket-a".to_string(), &BucketStats::default(), DurableMrfBacklog::default());
|
|
let unavailable_json = serde_json::to_value(unavailable).expect("unavailable response should serialize");
|
|
assert_eq!(unavailable_json["RuntimeStatsAvailable"], false);
|
|
assert_eq!(unavailable_json["DurableBacklogAvailable"], false);
|
|
|
|
let mut valid_empty_stats = BucketStats::default();
|
|
valid_empty_stats.replication_stats.provider_available = true;
|
|
valid_empty_stats.replication_stats.cluster_complete = true;
|
|
valid_empty_stats.replication_stats.observed_node_count = 1;
|
|
valid_empty_stats.replication_stats.expected_node_count = 1;
|
|
let valid_empty = build_mrf_response(
|
|
"bucket-a".to_string(),
|
|
&valid_empty_stats,
|
|
DurableMrfBacklog {
|
|
available: true,
|
|
entries: Vec::new(),
|
|
},
|
|
);
|
|
let valid_empty_json = serde_json::to_value(valid_empty).expect("valid empty response should serialize");
|
|
assert_eq!(valid_empty_json["RuntimeStatsAvailable"], true);
|
|
assert_eq!(valid_empty_json["DurableBacklogAvailable"], true);
|
|
assert_eq!(valid_empty_json["TotalFailedCount"], 0);
|
|
assert_eq!(valid_empty_json["DurableCount"], 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_query_params_decodes_percent_encoded_values() {
|
|
let uri: Uri = "/rustfs/admin/v3/list-remote-targets?bucket=foo%2Fbar&flag=a+b"
|
|
.parse()
|
|
.expect("uri should parse");
|
|
let params = extract_query_params(&uri);
|
|
|
|
assert_eq!(params.get("bucket"), Some(&"foo/bar".to_string()));
|
|
assert_eq!(params.get("flag"), Some(&"a b".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_remote_target_tls_settings_rejects_insecure_tls_for_http_targets() {
|
|
let err = validate_remote_target_tls_settings(&BucketTarget {
|
|
secure: false,
|
|
skip_tls_verify: true,
|
|
..Default::default()
|
|
})
|
|
.expect_err("HTTP targets must reject skipTlsVerify");
|
|
|
|
assert!(err.to_string().contains("skipTlsVerify requires an HTTPS remote target"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_remote_target_tls_settings_rejects_custom_ca_for_http_targets() {
|
|
let err = validate_remote_target_tls_settings(&BucketTarget {
|
|
secure: false,
|
|
ca_cert_pem: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n".to_string(),
|
|
..Default::default()
|
|
})
|
|
.expect_err("HTTP targets must reject custom CA PEM");
|
|
|
|
assert!(err.to_string().contains("caCertPem requires an HTTPS remote target"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_remote_target_tls_settings_rejects_insecure_and_custom_ca_combination() {
|
|
let err = validate_remote_target_tls_settings(&BucketTarget {
|
|
secure: true,
|
|
skip_tls_verify: true,
|
|
ca_cert_pem: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n".to_string(),
|
|
..Default::default()
|
|
})
|
|
.expect_err("custom CA and insecure TLS must be mutually exclusive");
|
|
|
|
assert!(
|
|
err.to_string()
|
|
.contains("skipTlsVerify and caCertPem cannot be enabled together")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_remote_target_tls_settings_allows_https_insecure_without_custom_ca() {
|
|
validate_remote_target_tls_settings(&BucketTarget {
|
|
secure: true,
|
|
skip_tls_verify: true,
|
|
..Default::default()
|
|
})
|
|
.expect("HTTPS targets should allow skipTlsVerify when no custom CA is configured");
|
|
}
|
|
|
|
#[test]
|
|
fn remote_target_request_rejects_unknown_fields() {
|
|
let mut request = valid_remote_target_request();
|
|
request["unexpected"] = serde_json::json!(true);
|
|
|
|
let err = serde_json::from_value::<RemoteTargetRequest>(request)
|
|
.expect_err("remote target request should reject unknown fields");
|
|
|
|
assert!(err.to_string().contains("unknown field"));
|
|
}
|
|
|
|
#[test]
|
|
fn remote_target_request_rejects_missing_credentials() {
|
|
let mut request = valid_remote_target_request();
|
|
request
|
|
.as_object_mut()
|
|
.expect("request should be an object")
|
|
.remove("credentials");
|
|
|
|
let err =
|
|
serde_json::from_value::<RemoteTargetRequest>(request).expect_err("remote target request should require credentials");
|
|
|
|
assert!(err.to_string().contains("missing field"));
|
|
}
|
|
|
|
#[test]
|
|
fn remote_target_request_rejects_empty_secret_key() {
|
|
let mut request = valid_remote_target_request();
|
|
request["credentials"]["secretKey"] = serde_json::json!("");
|
|
let request: RemoteTargetRequest =
|
|
serde_json::from_value(request).expect("request should deserialize before semantic validation");
|
|
|
|
let err = match request.into_bucket_target() {
|
|
Ok(_) => panic!("empty secret key should fail semantic validation"),
|
|
Err(err) => err,
|
|
};
|
|
|
|
assert!(err.to_string().contains("credentials.secretKey is required"));
|
|
}
|
|
|
|
#[test]
|
|
fn remote_target_request_rejects_unimplemented_fields() {
|
|
for (field, value) in [
|
|
("disableProxy", serde_json::json!(true)),
|
|
("healthCheckDuration", serde_json::json!(5)),
|
|
("edge", serde_json::json!(true)),
|
|
("edgeSyncBeforeExpiry", serde_json::json!(true)),
|
|
] {
|
|
let mut request = valid_remote_target_request();
|
|
request[field] = value;
|
|
let request: RemoteTargetRequest =
|
|
serde_json::from_value(request).expect("unsupported field should still deserialize");
|
|
let err = request
|
|
.into_bucket_target()
|
|
.expect_err("unimplemented remote target fields must not be persisted");
|
|
|
|
assert!(err.to_string().contains(field));
|
|
assert!(err.to_string().contains("not supported by this RustFS version"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn remote_target_request_converts_to_bucket_target() {
|
|
let target = serde_json::from_value::<RemoteTargetRequest>(valid_remote_target_request())
|
|
.expect("request should deserialize")
|
|
.into_bucket_target()
|
|
.expect("request should pass semantic validation");
|
|
|
|
assert_eq!(target.endpoint, "192.168.1.10:9000");
|
|
assert_eq!(target.target_bucket, "target");
|
|
assert!(target.secure);
|
|
assert_eq!(target.credentials.expect("credentials should be present").access_key, "access");
|
|
}
|
|
}
|