refactor: Reimplement bucket replication system with enhanced architecture (#590)

* feat:refactor replication

* use aws sdk for replication client

* refactor/replication

* merge main

* fix lifecycle test
This commit is contained in:
weisd
2025-09-26 14:27:53 +08:00
committed by GitHub
parent 9b029d18b2
commit 90f21a9102
91 changed files with 10532 additions and 4917 deletions
File diff suppressed because it is too large Load Diff
+159 -9
View File
@@ -16,6 +16,10 @@ use super::{quota::BucketQuota, target::BucketTargets};
use super::object_lock::ObjectLockApi;
use super::versioning::VersioningApi;
use crate::bucket::utils::deserialize;
use crate::config::com::{read_config, save_config};
use crate::error::{Error, Result};
use crate::new_object_layer_fn;
use byteorder::{BigEndian, ByteOrder, LittleEndian};
use rmp_serde::Serializer as rmpSerializer;
use rustfs_policy::policy::BucketPolicy;
@@ -30,12 +34,6 @@ use std::sync::Arc;
use time::OffsetDateTime;
use tracing::error;
use crate::bucket::target::BucketTarget;
use crate::bucket::utils::deserialize;
use crate::config::com::{read_config, save_config};
use crate::error::{Error, Result};
use crate::new_object_layer_fn;
use crate::disk::BUCKET_META_PREFIX;
use crate::store::ECStore;
@@ -322,7 +320,9 @@ impl BucketMetadata {
LittleEndian::write_u16(&mut buf[2..4], BUCKET_METADATA_VERSION);
let data = self.marshal_msg()?;
let data = self
.marshal_msg()
.map_err(|e| Error::other(format!("save bucket metadata failed: {e}")))?;
buf.extend_from_slice(&data);
@@ -362,8 +362,8 @@ impl BucketMetadata {
}
//let temp = self.bucket_targets_config_json.clone();
if !self.bucket_targets_config_json.is_empty() {
let arr: Vec<BucketTarget> = serde_json::from_slice(&self.bucket_targets_config_json)?;
self.bucket_target_config = Some(BucketTargets { targets: arr });
let bucket_targets: BucketTargets = serde_json::from_slice(&self.bucket_targets_config_json)?;
self.bucket_target_config = Some(bucket_targets);
} else {
self.bucket_target_config = Some(BucketTargets::default())
}
@@ -451,4 +451,154 @@ mod test {
assert_eq!(bm.name, new.name);
}
#[tokio::test]
async fn marshal_msg_complete_example() {
// Create a complete BucketMetadata with various configurations
let mut bm = BucketMetadata::new("test-bucket");
// Set creation time to current time
bm.created = OffsetDateTime::now_utc();
bm.lock_enabled = true;
// Add policy configuration
let policy_json = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*"}]}"#;
bm.policy_config_json = policy_json.as_bytes().to_vec();
bm.policy_config_updated_at = OffsetDateTime::now_utc();
// Add lifecycle configuration
let lifecycle_xml = r#"<LifecycleConfiguration><Rule><ID>rule1</ID><Status>Enabled</Status><Expiration><Days>30</Days></Expiration></Rule></LifecycleConfiguration>"#;
bm.lifecycle_config_xml = lifecycle_xml.as_bytes().to_vec();
bm.lifecycle_config_updated_at = OffsetDateTime::now_utc();
// Add versioning configuration
let versioning_xml = r#"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>"#;
bm.versioning_config_xml = versioning_xml.as_bytes().to_vec();
bm.versioning_config_updated_at = OffsetDateTime::now_utc();
// Add encryption configuration
let encryption_xml = r#"<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>"#;
bm.encryption_config_xml = encryption_xml.as_bytes().to_vec();
bm.encryption_config_updated_at = OffsetDateTime::now_utc();
// Add tagging configuration
let tagging_xml = r#"<Tagging><TagSet><Tag><Key>Environment</Key><Value>Test</Value></Tag><Tag><Key>Owner</Key><Value>RustFS</Value></Tag></TagSet></Tagging>"#;
bm.tagging_config_xml = tagging_xml.as_bytes().to_vec();
bm.tagging_config_updated_at = OffsetDateTime::now_utc();
// Add quota configuration
let quota_json = r#"{"quota":1073741824,"quotaType":"hard"}"#; // 1GB quota
bm.quota_config_json = quota_json.as_bytes().to_vec();
bm.quota_config_updated_at = OffsetDateTime::now_utc();
// Add object lock configuration
let object_lock_xml = r#"<ObjectLockConfiguration><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>GOVERNANCE</Mode><Days>7</Days></DefaultRetention></Rule></ObjectLockConfiguration>"#;
bm.object_lock_config_xml = object_lock_xml.as_bytes().to_vec();
bm.object_lock_config_updated_at = OffsetDateTime::now_utc();
// Add notification configuration
let notification_xml = r#"<NotificationConfiguration><CloudWatchConfiguration><Id>notification1</Id><Event>s3:ObjectCreated:*</Event><CloudWatchConfiguration><LogGroupName>test-log-group</LogGroupName></CloudWatchConfiguration></CloudWatchConfiguration></NotificationConfiguration>"#;
bm.notification_config_xml = notification_xml.as_bytes().to_vec();
bm.notification_config_updated_at = OffsetDateTime::now_utc();
// Add replication configuration
let replication_xml = r#"<ReplicationConfiguration><Role>arn:aws:iam::123456789012:role/replication-role</Role><Rule><ID>rule1</ID><Status>Enabled</Status><Prefix>documents/</Prefix><Destination><Bucket>arn:aws:s3:::destination-bucket</Bucket></Destination></Rule></ReplicationConfiguration>"#;
bm.replication_config_xml = replication_xml.as_bytes().to_vec();
bm.replication_config_updated_at = OffsetDateTime::now_utc();
// Add bucket targets configuration
let bucket_targets_json = r#"[{"endpoint":"http://target1.example.com","credentials":{"accessKey":"key1","secretKey":"secret1"},"targetBucket":"target-bucket-1","region":"us-east-1"},{"endpoint":"http://target2.example.com","credentials":{"accessKey":"key2","secretKey":"secret2"},"targetBucket":"target-bucket-2","region":"us-west-2"}]"#;
bm.bucket_targets_config_json = bucket_targets_json.as_bytes().to_vec();
bm.bucket_targets_config_updated_at = OffsetDateTime::now_utc();
// Add bucket targets meta configuration
let bucket_targets_meta_json = r#"{"replicationId":"repl-123","syncMode":"async","bandwidth":"100MB"}"#;
bm.bucket_targets_config_meta_json = bucket_targets_meta_json.as_bytes().to_vec();
bm.bucket_targets_config_meta_updated_at = OffsetDateTime::now_utc();
// Test serialization
let buf = bm.marshal_msg().unwrap();
assert!(!buf.is_empty(), "Serialized buffer should not be empty");
// Test deserialization
let deserialized_bm = BucketMetadata::unmarshal(&buf).unwrap();
// Verify all fields are correctly serialized and deserialized
assert_eq!(bm.name, deserialized_bm.name);
assert_eq!(bm.created.unix_timestamp(), deserialized_bm.created.unix_timestamp());
assert_eq!(bm.lock_enabled, deserialized_bm.lock_enabled);
// Verify configuration data
assert_eq!(bm.policy_config_json, deserialized_bm.policy_config_json);
assert_eq!(bm.lifecycle_config_xml, deserialized_bm.lifecycle_config_xml);
assert_eq!(bm.versioning_config_xml, deserialized_bm.versioning_config_xml);
assert_eq!(bm.encryption_config_xml, deserialized_bm.encryption_config_xml);
assert_eq!(bm.tagging_config_xml, deserialized_bm.tagging_config_xml);
assert_eq!(bm.quota_config_json, deserialized_bm.quota_config_json);
assert_eq!(bm.object_lock_config_xml, deserialized_bm.object_lock_config_xml);
assert_eq!(bm.notification_config_xml, deserialized_bm.notification_config_xml);
assert_eq!(bm.replication_config_xml, deserialized_bm.replication_config_xml);
assert_eq!(bm.bucket_targets_config_json, deserialized_bm.bucket_targets_config_json);
assert_eq!(bm.bucket_targets_config_meta_json, deserialized_bm.bucket_targets_config_meta_json);
// Verify timestamps (comparing unix timestamps to avoid precision issues)
assert_eq!(
bm.policy_config_updated_at.unix_timestamp(),
deserialized_bm.policy_config_updated_at.unix_timestamp()
);
assert_eq!(
bm.lifecycle_config_updated_at.unix_timestamp(),
deserialized_bm.lifecycle_config_updated_at.unix_timestamp()
);
assert_eq!(
bm.versioning_config_updated_at.unix_timestamp(),
deserialized_bm.versioning_config_updated_at.unix_timestamp()
);
assert_eq!(
bm.encryption_config_updated_at.unix_timestamp(),
deserialized_bm.encryption_config_updated_at.unix_timestamp()
);
assert_eq!(
bm.tagging_config_updated_at.unix_timestamp(),
deserialized_bm.tagging_config_updated_at.unix_timestamp()
);
assert_eq!(
bm.quota_config_updated_at.unix_timestamp(),
deserialized_bm.quota_config_updated_at.unix_timestamp()
);
assert_eq!(
bm.object_lock_config_updated_at.unix_timestamp(),
deserialized_bm.object_lock_config_updated_at.unix_timestamp()
);
assert_eq!(
bm.notification_config_updated_at.unix_timestamp(),
deserialized_bm.notification_config_updated_at.unix_timestamp()
);
assert_eq!(
bm.replication_config_updated_at.unix_timestamp(),
deserialized_bm.replication_config_updated_at.unix_timestamp()
);
assert_eq!(
bm.bucket_targets_config_updated_at.unix_timestamp(),
deserialized_bm.bucket_targets_config_updated_at.unix_timestamp()
);
assert_eq!(
bm.bucket_targets_config_meta_updated_at.unix_timestamp(),
deserialized_bm.bucket_targets_config_meta_updated_at.unix_timestamp()
);
// Test that the serialized data contains expected content
let buf_str = String::from_utf8_lossy(&buf);
assert!(buf_str.contains("test-bucket"), "Serialized data should contain bucket name");
// Verify the buffer size is reasonable (should be larger due to all the config data)
assert!(buf.len() > 1000, "Buffer should be substantial in size due to all configurations");
println!("✅ Complete BucketMetadata serialization test passed");
println!(" - Bucket name: {}", deserialized_bm.name);
println!(" - Lock enabled: {}", deserialized_bm.lock_enabled);
println!(" - Policy config size: {} bytes", deserialized_bm.policy_config_json.len());
println!(" - Lifecycle config size: {} bytes", deserialized_bm.lifecycle_config_xml.len());
println!(" - Serialized buffer size: {} bytes", buf.len());
}
}
+8 -5
View File
@@ -12,19 +12,20 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::StorageAPI;
use crate::StorageAPI as _;
use crate::bucket::bucket_target_sys::BucketTargetSys;
use crate::bucket::metadata::{BUCKET_LIFECYCLE_CONFIG, load_bucket_metadata_parse};
use crate::bucket::utils::{deserialize, is_meta_bucketname};
use crate::cmd::bucket_targets;
use crate::error::{Error, Result, is_err_bucket_not_found};
use crate::global::{GLOBAL_Endpoints, is_dist_erasure, is_erasure, new_object_layer_fn};
use crate::store::ECStore;
use futures::future::join_all;
use rustfs_common::heal_channel::HealOpts;
use rustfs_policy::policy::BucketPolicy;
use s3s::dto::ReplicationConfiguration;
use s3s::dto::{
BucketLifecycleConfiguration, NotificationConfiguration, ObjectLockConfiguration, ReplicationConfiguration,
ServerSideEncryptionConfiguration, Tagging, VersioningConfiguration,
BucketLifecycleConfiguration, NotificationConfiguration, ObjectLockConfiguration, ServerSideEncryptionConfiguration, Tagging,
VersioningConfiguration,
};
use std::collections::HashSet;
use std::sync::OnceLock;
@@ -261,7 +262,8 @@ impl BucketMetadataSys {
if let Some(bucket) = buckets.get(idx) {
let x = Arc::new(res);
mp.insert(bucket.clone(), x.clone());
bucket_targets::init_bucket_targets(bucket, x.clone()).await;
// TODO:EventNotifier,BucketTargetSys
BucketTargetSys::get().set(bucket, &x).await;
}
}
Err(e) => {
@@ -348,6 +350,7 @@ impl BucketMetadataSys {
if !is_erasure().await && !is_dist_erasure().await && is_err_bucket_not_found(&err) {
BucketMetadata::new(bucket)
} else {
error!("load bucket metadata failed: {}", err);
return Err(err);
}
}
+1
View File
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
pub mod bucket_target_sys;
pub mod error;
pub mod lifecycle;
pub mod metadata;
@@ -0,0 +1,233 @@
// 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 super::ReplicationRuleExt as _;
use crate::bucket::tagging::decode_tags_to_map;
use rustfs_filemeta::ReplicationType;
use s3s::dto::DeleteMarkerReplicationStatus;
use s3s::dto::DeleteReplicationStatus;
use s3s::dto::Destination;
use s3s::dto::{ExistingObjectReplicationStatus, ReplicationConfiguration, ReplicationRuleStatus, ReplicationRules};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ObjectOpts {
pub name: String,
pub user_tags: String,
pub version_id: Option<Uuid>,
pub delete_marker: bool,
pub ssec: bool,
pub op_type: ReplicationType,
pub replica: bool,
pub existing_object: bool,
pub target_arn: String,
}
pub trait ReplicationConfigurationExt {
fn replicate(&self, opts: &ObjectOpts) -> bool;
fn has_existing_object_replication(&self, arn: &str) -> (bool, bool);
fn filter_actionable_rules(&self, obj: &ObjectOpts) -> ReplicationRules;
fn get_destination(&self) -> Destination;
fn has_active_rules(&self, prefix: &str, recursive: bool) -> bool;
fn filter_target_arns(&self, obj: &ObjectOpts) -> Vec<String>;
}
impl ReplicationConfigurationExt for ReplicationConfiguration {
/// 检查是否有现有对象复制规则
fn has_existing_object_replication(&self, arn: &str) -> (bool, bool) {
let mut has_arn = false;
for rule in &self.rules {
if rule.destination.bucket == arn || self.role == arn {
if !has_arn {
has_arn = true;
}
if let Some(status) = &rule.existing_object_replication {
if status.status == ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED) {
return (true, true);
}
}
}
}
(has_arn, false)
}
fn filter_actionable_rules(&self, obj: &ObjectOpts) -> ReplicationRules {
if obj.name.is_empty() && obj.op_type != ReplicationType::Resync && obj.op_type != ReplicationType::All {
return vec![];
}
let mut rules = ReplicationRules::default();
for rule in &self.rules {
if rule.status == ReplicationRuleStatus::from_static(ReplicationRuleStatus::DISABLED) {
continue;
}
if !obj.target_arn.is_empty() && rule.destination.bucket != obj.target_arn && self.role != obj.target_arn {
continue;
}
if obj.op_type == ReplicationType::Resync || obj.op_type == ReplicationType::All {
rules.push(rule.clone());
continue;
}
if let Some(status) = &rule.existing_object_replication {
if obj.existing_object
&& status.status == ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::DISABLED)
{
continue;
}
}
if !obj.name.starts_with(rule.prefix()) {
continue;
}
if let Some(filter) = &rule.filter {
let object_tags = decode_tags_to_map(&obj.user_tags);
if filter.test_tags(&object_tags) {
rules.push(rule.clone());
}
}
}
rules.sort_by(|a, b| {
if a.destination == b.destination {
a.priority.cmp(&b.priority)
} else {
std::cmp::Ordering::Equal
}
});
rules
}
/// 获取目标配置
fn get_destination(&self) -> Destination {
if !self.rules.is_empty() {
self.rules[0].destination.clone()
} else {
Destination {
account: None,
bucket: "".to_string(),
encryption_configuration: None,
metrics: None,
replication_time: None,
access_control_translation: None,
storage_class: None,
}
}
}
/// 判断对象是否应该被复制
fn replicate(&self, obj: &ObjectOpts) -> bool {
let rules = self.filter_actionable_rules(obj);
for rule in rules.iter() {
if rule.status == ReplicationRuleStatus::from_static(ReplicationRuleStatus::DISABLED) {
continue;
}
if let Some(status) = &rule.existing_object_replication {
if obj.existing_object
&& status.status == ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::DISABLED)
{
return false;
}
}
if obj.op_type == ReplicationType::Delete {
if obj.version_id.is_some() {
return rule
.delete_replication
.clone()
.is_some_and(|d| d.status == DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED));
} else {
return rule.delete_marker_replication.clone().is_some_and(|d| {
d.status == Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED))
});
}
}
// 常规对象/元数据复制
return rule.metadata_replicate(obj);
}
false
}
/// 检查是否有活跃的规则
/// 可选择性地提供前缀
/// 如果recursive为true,函数还会在前缀下的任何级别有活跃规则时返回true
/// 如果没有指定前缀,recursive实际上为true
fn has_active_rules(&self, prefix: &str, recursive: bool) -> bool {
if self.rules.is_empty() {
return false;
}
for rule in &self.rules {
if rule.status == ReplicationRuleStatus::from_static(ReplicationRuleStatus::DISABLED) {
continue;
}
if let Some(filter) = &rule.filter {
if let Some(filter_prefix) = &filter.prefix {
if !prefix.is_empty() && !filter_prefix.is_empty() {
// 传入的前缀必须在规则前缀中
if !recursive && !prefix.starts_with(filter_prefix) {
continue;
}
}
// 如果是递归的,我们可以跳过这个规则,如果它不匹配测试前缀或前缀下的级别不匹配
if recursive && !rule.prefix().starts_with(prefix) && !prefix.starts_with(rule.prefix()) {
continue;
}
}
}
return true;
}
false
}
/// 过滤目标ARN,返回配置中不同目标ARN的切片
fn filter_target_arns(&self, obj: &ObjectOpts) -> Vec<String> {
let mut arns = Vec::new();
let mut targets_map: HashSet<String> = HashSet::new();
let rules = self.filter_actionable_rules(obj);
for rule in rules {
if rule.status == ReplicationRuleStatus::from_static(ReplicationRuleStatus::DISABLED) {
continue;
}
if !self.role.is_empty() {
arns.push(self.role.clone()); // 如果存在,使用传统的RoleArn
return arns;
}
if !targets_map.contains(&rule.destination.bucket) {
targets_map.insert(rule.destination.bucket.clone());
}
}
for arn in targets_map {
arns.push(arn);
}
arns
}
}
@@ -12,30 +12,36 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// Replication status type for x-amz-replication-status header
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StatusType {
Pending,
Completed,
CompletedLegacy,
Failed,
Replica,
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ResyncStatusType {
#[default]
NoResync,
ResyncPending,
ResyncCanceled,
ResyncStarted,
ResyncCompleted,
ResyncFailed,
}
impl StatusType {
// Converts the enum variant to its string representation
pub fn as_str(&self) -> &'static str {
match self {
StatusType::Pending => "PENDING",
StatusType::Completed => "COMPLETED",
StatusType::CompletedLegacy => "COMPLETE",
StatusType::Failed => "FAILED",
StatusType::Replica => "REPLICA",
}
}
// Checks if the status is empty (not set)
pub fn is_empty(&self) -> bool {
matches!(self, StatusType::Pending) // Adjust this as needed
impl ResyncStatusType {
pub fn is_valid(&self) -> bool {
*self != ResyncStatusType::NoResync
}
}
impl fmt::Display for ResyncStatusType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
ResyncStatusType::ResyncStarted => "Ongoing",
ResyncStatusType::ResyncCompleted => "Completed",
ResyncStatusType::ResyncFailed => "Failed",
ResyncStatusType::ResyncPending => "Pending",
ResyncStatusType::ResyncCanceled => "Canceled",
ResyncStatusType::NoResync => "",
};
write!(f, "{s}")
}
}
@@ -12,4 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
mod config;
pub mod datatypes;
mod replication_pool;
mod replication_resyncer;
mod replication_state;
mod replication_type;
mod rule;
pub use config::*;
pub use datatypes::*;
pub use replication_pool::*;
pub use replication_resyncer::*;
pub use replication_type::*;
pub use rule::*;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,470 @@
// 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::error::{Error, Result};
use crate::store_api::ObjectInfo;
use regex::Regex;
use rustfs_filemeta::VersionPurgeStatusType;
use rustfs_filemeta::{ReplicatedInfos, ReplicationType};
use rustfs_filemeta::{ReplicationState, ReplicationStatusType};
use rustfs_utils::http::RESERVED_METADATA_PREFIX_LOWER;
use rustfs_utils::http::RUSTFS_REPLICATION_RESET_STATUS;
use serde::{Deserialize, Serialize};
use std::any::Any;
use std::collections::HashMap;
use std::fmt;
use time::OffsetDateTime;
use uuid::Uuid;
pub const REPLICATION_RESET: &str = "replication-reset";
pub const REPLICATION_STATUS: &str = "replication-status";
// ReplicateQueued - replication being queued trail
pub const REPLICATE_QUEUED: &str = "replicate:queue";
// ReplicateExisting - audit trail for existing objects replication
pub const REPLICATE_EXISTING: &str = "replicate:existing";
// ReplicateExistingDelete - audit trail for delete replication triggered for existing delete markers
pub const REPLICATE_EXISTING_DELETE: &str = "replicate:existing:delete";
// ReplicateMRF - audit trail for replication from Most Recent Failures (MRF) queue
pub const REPLICATE_MRF: &str = "replicate:mrf";
// ReplicateIncoming - audit trail of inline replication
pub const REPLICATE_INCOMING: &str = "replicate:incoming";
// ReplicateIncomingDelete - audit trail of inline replication of deletes.
pub const REPLICATE_INCOMING_DELETE: &str = "replicate:incoming:delete";
// ReplicateHeal - audit trail for healing of failed/pending replications
pub const REPLICATE_HEAL: &str = "replicate:heal";
// ReplicateHealDelete - audit trail of healing of failed/pending delete replications.
pub const REPLICATE_HEAL_DELETE: &str = "replicate:heal:delete";
#[derive(Serialize, Deserialize, Debug)]
pub struct MrfReplicateEntry {
#[serde(rename = "bucket")]
pub bucket: String,
#[serde(rename = "object")]
pub object: String,
#[serde(skip_serializing, skip_deserializing)]
pub version_id: Option<Uuid>,
#[serde(rename = "retryCount")]
pub retry_count: i32,
#[serde(skip_serializing, skip_deserializing)]
pub size: i64,
}
pub trait ReplicationWorkerOperation: Any + Send + Sync {
fn to_mrf_entry(&self) -> MrfReplicateEntry;
fn as_any(&self) -> &dyn Any;
fn get_bucket(&self) -> &str;
fn get_object(&self) -> &str;
fn get_size(&self) -> i64;
fn is_delete_marker(&self) -> bool;
fn get_op_type(&self) -> ReplicationType;
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReplicateTargetDecision {
pub replicate: bool,
pub synchronous: bool,
pub arn: String,
pub id: String,
}
impl ReplicateTargetDecision {
pub fn new(arn: String, replicate: bool, sync: bool) -> Self {
Self {
replicate,
synchronous: sync,
arn,
id: String::new(),
}
}
}
impl fmt::Display for ReplicateTargetDecision {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{};{};{};{}", self.replicate, self.synchronous, self.arn, self.id)
}
}
/// ReplicateDecision represents replication decision for each target
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplicateDecision {
pub targets_map: HashMap<String, ReplicateTargetDecision>,
}
impl ReplicateDecision {
pub fn new() -> Self {
Self {
targets_map: HashMap::new(),
}
}
/// Returns true if at least one target qualifies for replication
pub fn replicate_any(&self) -> bool {
self.targets_map.values().any(|t| t.replicate)
}
/// Returns true if at least one target qualifies for synchronous replication
pub fn is_synchronous(&self) -> bool {
self.targets_map.values().any(|t| t.synchronous)
}
/// Updates ReplicateDecision with target's replication decision
pub fn set(&mut self, target: ReplicateTargetDecision) {
self.targets_map.insert(target.arn.clone(), target);
}
/// Returns a stringified representation of internal replication status with all targets marked as `PENDING`
pub fn pending_status(&self) -> Option<String> {
let mut result = String::new();
for target in self.targets_map.values() {
if target.replicate {
result.push_str(&format!("{}={};", target.arn, ReplicationStatusType::Pending.as_str()));
}
}
if result.is_empty() { None } else { Some(result) }
}
}
impl fmt::Display for ReplicateDecision {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut result = String::new();
for (key, value) in &self.targets_map {
result.push_str(&format!("{key}={value},"));
}
write!(f, "{}", result.trim_end_matches(','))
}
}
impl Default for ReplicateDecision {
fn default() -> Self {
Self::new()
}
}
// parse k-v pairs of target ARN to stringified ReplicateTargetDecision delimited by ',' into a
// ReplicateDecision struct
pub fn parse_replicate_decision(_bucket: &str, s: &str) -> Result<ReplicateDecision> {
let mut decision = ReplicateDecision::new();
if s.is_empty() {
return Ok(decision);
}
for p in s.split(',') {
if p.is_empty() {
continue;
}
let slc = p.split('=').collect::<Vec<&str>>();
if slc.len() != 2 {
return Err(Error::other(format!("invalid replicate decision format: {s}")));
}
let tgt_str = slc[1].trim_matches('"');
let tgt = tgt_str.split(';').collect::<Vec<&str>>();
if tgt.len() != 4 {
return Err(Error::other(format!("invalid replicate decision format: {s}")));
}
let tgt = ReplicateTargetDecision {
replicate: tgt[0] == "true",
synchronous: tgt[1] == "true",
arn: tgt[2].to_string(),
id: tgt[3].to_string(),
};
decision.targets_map.insert(slc[0].to_string(), tgt);
}
Ok(decision)
// r = ReplicateDecision{
// targetsMap: make(map[string]replicateTargetDecision),
// }
// if len(s) == 0 {
// return
// }
// for _, p := range strings.Split(s, ",") {
// if p == "" {
// continue
// }
// slc := strings.Split(p, "=")
// if len(slc) != 2 {
// return r, errInvalidReplicateDecisionFormat
// }
// tgtStr := strings.TrimSuffix(strings.TrimPrefix(slc[1], `"`), `"`)
// tgt := strings.Split(tgtStr, ";")
// if len(tgt) != 4 {
// return r, errInvalidReplicateDecisionFormat
// }
// r.targetsMap[slc[0]] = replicateTargetDecision{Replicate: tgt[0] == "true", Synchronous: tgt[1] == "true", Arn: tgt[2], ID: tgt[3]}
// }
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ResyncTargetDecision {
pub replicate: bool,
pub reset_id: String,
pub reset_before_date: Option<OffsetDateTime>,
}
pub fn target_reset_header(arn: &str) -> String {
format!("{RESERVED_METADATA_PREFIX_LOWER}{REPLICATION_RESET}-{arn}")
}
impl ResyncTargetDecision {
pub fn resync_target(
oi: &ObjectInfo,
arn: &str,
reset_id: &str,
reset_before_date: Option<OffsetDateTime>,
status: ReplicationStatusType,
) -> Self {
let rs = oi
.user_defined
.get(target_reset_header(arn).as_str())
.or(oi.user_defined.get(RUSTFS_REPLICATION_RESET_STATUS))
.map(|s| s.to_string());
let mut dec = Self::default();
let mod_time = oi.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH);
if rs.is_none() {
let reset_before_date = reset_before_date.unwrap_or(OffsetDateTime::UNIX_EPOCH);
if !reset_id.is_empty() && mod_time < reset_before_date {
dec.replicate = true;
return dec;
}
dec.replicate = status == ReplicationStatusType::Empty;
return dec;
}
if reset_id.is_empty() || reset_before_date.is_none() {
return dec;
}
let rs = rs.unwrap();
let reset_before_date = reset_before_date.unwrap();
let parts: Vec<&str> = rs.splitn(2, ';').collect();
if parts.len() != 2 {
return dec;
}
let new_reset = parts[0] == reset_id;
if !new_reset && status == ReplicationStatusType::Completed {
return dec;
}
dec.replicate = new_reset && mod_time < reset_before_date;
dec
}
}
/// ResyncDecision is a struct representing a map with target's individual resync decisions
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResyncDecision {
pub targets: HashMap<String, ResyncTargetDecision>,
}
impl ResyncDecision {
pub fn new() -> Self {
Self { targets: HashMap::new() }
}
/// Returns true if no targets with resync decision present
pub fn is_empty(&self) -> bool {
self.targets.is_empty()
}
pub fn must_resync(&self) -> bool {
self.targets.values().any(|v| v.replicate)
}
pub fn must_resync_target(&self, tgt_arn: &str) -> bool {
self.targets.get(tgt_arn).map(|v| v.replicate).unwrap_or(false)
}
}
impl Default for ResyncDecision {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplicateObjectInfo {
pub name: String,
pub size: i64,
pub actual_size: i64,
pub bucket: String,
pub version_id: Option<Uuid>,
pub etag: Option<String>,
pub mod_time: Option<OffsetDateTime>,
pub replication_status: ReplicationStatusType,
pub replication_status_internal: Option<String>,
pub delete_marker: bool,
pub version_purge_status_internal: Option<String>,
pub version_purge_status: VersionPurgeStatusType,
pub replication_state: Option<ReplicationState>,
pub op_type: ReplicationType,
pub event_type: String,
pub dsc: ReplicateDecision,
pub existing_obj_resync: ResyncDecision,
pub target_statuses: HashMap<String, ReplicationStatusType>,
pub target_purge_statuses: HashMap<String, VersionPurgeStatusType>,
pub replication_timestamp: Option<OffsetDateTime>,
pub ssec: bool,
pub user_tags: String,
pub checksum: Vec<u8>,
pub retry_count: u32,
}
impl ReplicationWorkerOperation for ReplicateObjectInfo {
fn as_any(&self) -> &dyn Any {
self
}
fn to_mrf_entry(&self) -> MrfReplicateEntry {
MrfReplicateEntry {
bucket: self.bucket.clone(),
object: self.name.clone(),
version_id: self.version_id,
retry_count: self.retry_count as i32,
size: self.size,
}
}
fn get_bucket(&self) -> &str {
&self.bucket
}
fn get_object(&self) -> &str {
&self.name
}
fn get_size(&self) -> i64 {
self.size
}
fn is_delete_marker(&self) -> bool {
self.delete_marker
}
fn get_op_type(&self) -> ReplicationType {
self.op_type
}
}
lazy_static::lazy_static! {
static ref REPL_STATUS_REGEX: Regex = Regex::new(r"([^=].*?)=([^,].*?);").unwrap();
}
impl ReplicateObjectInfo {
/// Returns replication status of a target
pub fn target_replication_status(&self, arn: &str) -> ReplicationStatusType {
let binding = self.replication_status_internal.clone().unwrap_or_default();
let captures = REPL_STATUS_REGEX.captures_iter(&binding);
for cap in captures {
if cap.len() == 3 && &cap[1] == arn {
return ReplicationStatusType::from(&cap[2]);
}
}
ReplicationStatusType::default()
}
/// Returns the relevant info needed by MRF
pub fn to_mrf_entry(&self) -> MrfReplicateEntry {
MrfReplicateEntry {
bucket: self.bucket.clone(),
object: self.name.clone(),
version_id: self.version_id,
retry_count: self.retry_count as i32,
size: self.size,
}
}
}
// constructs a replication status map from string representation
pub fn replication_statuses_map(s: &str) -> HashMap<String, ReplicationStatusType> {
let mut targets = HashMap::new();
let rep_stat_matches = REPL_STATUS_REGEX.captures_iter(s).map(|c| c.extract());
for (_, [arn, status]) in rep_stat_matches {
if arn.is_empty() {
continue;
}
let status = ReplicationStatusType::from(status);
targets.insert(arn.to_string(), status);
}
targets
}
// constructs a version purge status map from string representation
pub fn version_purge_statuses_map(s: &str) -> HashMap<String, VersionPurgeStatusType> {
let mut targets = HashMap::new();
let purge_status_matches = REPL_STATUS_REGEX.captures_iter(s).map(|c| c.extract());
for (_, [arn, status]) in purge_status_matches {
if arn.is_empty() {
continue;
}
let status = VersionPurgeStatusType::from(status);
targets.insert(arn.to_string(), status);
}
targets
}
pub fn get_replication_state(rinfos: &ReplicatedInfos, prev_state: &ReplicationState, _vid: Option<String>) -> ReplicationState {
let reset_status_map: Vec<(String, String)> = rinfos
.targets
.iter()
.filter(|v| !v.resync_timestamp.is_empty())
.map(|t| (target_reset_header(t.arn.as_str()), t.resync_timestamp.clone()))
.collect();
let repl_statuses = rinfos.replication_status_internal();
let vpurge_statuses = rinfos.version_purge_status_internal();
let mut reset_statuses_map = prev_state.reset_statuses_map.clone();
for (key, value) in reset_status_map {
reset_statuses_map.insert(key, value);
}
ReplicationState {
replicate_decision_str: prev_state.replicate_decision_str.clone(),
reset_statuses_map,
replica_timestamp: prev_state.replica_timestamp,
replica_status: prev_state.replica_status.clone(),
targets: replication_statuses_map(&repl_statuses.clone().unwrap_or_default()),
replication_status_internal: repl_statuses,
replication_timestamp: rinfos.replication_timestamp,
purge_targets: version_purge_statuses_map(&vpurge_statuses.clone().unwrap_or_default()),
version_purge_status_internal: vpurge_statuses,
..Default::default()
}
}
@@ -0,0 +1,51 @@
// 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 s3s::dto::ReplicaModificationsStatus;
use s3s::dto::ReplicationRule;
use super::ObjectOpts;
pub trait ReplicationRuleExt {
fn prefix(&self) -> &str;
fn metadata_replicate(&self, obj: &ObjectOpts) -> bool;
}
impl ReplicationRuleExt for ReplicationRule {
fn prefix(&self) -> &str {
if let Some(filter) = &self.filter {
if let Some(prefix) = &filter.prefix {
prefix
} else if let Some(and) = &filter.and {
and.prefix.as_deref().unwrap_or("")
} else {
""
}
} else {
""
}
}
fn metadata_replicate(&self, obj: &ObjectOpts) -> bool {
if !obj.replica {
return true;
}
self.source_selection_criteria.as_ref().is_some_and(|s| {
s.replica_modifications
.clone()
.is_some_and(|r| r.status == ReplicaModificationsStatus::from_static(ReplicaModificationsStatus::ENABLED))
})
}
}
+16
View File
@@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::HashMap;
use s3s::dto::Tag;
use url::form_urlencoded;
@@ -34,6 +36,20 @@ pub fn decode_tags(tags: &str) -> Vec<Tag> {
list
}
pub fn decode_tags_to_map(tags: &str) -> HashMap<String, String> {
let mut list = HashMap::new();
for (k, v) in form_urlencoded::parse(tags.as_bytes()) {
if k.is_empty() || v.is_empty() {
continue;
}
list.insert(k.to_string(), v.to_string());
}
list
}
pub fn encode_tags(tags: Vec<Tag>) -> String {
let mut encoded = form_urlencoded::Serializer::new(String::new());
+66
View File
@@ -0,0 +1,66 @@
// 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 super::BucketTargetType;
use std::fmt::Display;
use std::str::FromStr;
pub struct ARN {
pub arn_type: BucketTargetType,
pub id: String,
pub region: String,
pub bucket: String,
}
impl ARN {
pub fn new(arn_type: BucketTargetType, id: String, region: String, bucket: String) -> Self {
Self {
arn_type,
id,
region,
bucket,
}
}
pub fn is_empty(&self) -> bool {
self.arn_type.is_valid()
}
}
impl Display for ARN {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "arn:rustfs:{}:{}:{}:{}", self.arn_type, self.region, self.id, self.bucket)
}
}
impl FromStr for ARN {
type Err = std::io::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if !s.starts_with("arn:rustfs:") {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid ARN format"));
}
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 6 {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid ARN format"));
}
Ok(ARN {
arn_type: BucketTargetType::from_str(parts[2]).unwrap_or_default(),
id: parts[3].to_string(),
region: parts[4].to_string(),
bucket: parts[5].to_string(),
})
}
}
@@ -0,0 +1,800 @@
// 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::error::{Error, Result};
use rmp_serde::Serializer as rmpSerializer;
use serde::{Deserialize, Serialize};
use std::{
fmt::{self, Display},
str::FromStr,
time::Duration,
};
use time::OffsetDateTime;
use url::Url;
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct Credentials {
#[serde(rename = "accessKey")]
pub access_key: String,
#[serde(rename = "secretKey")]
pub secret_key: String,
pub session_token: Option<String>,
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub enum ServiceType {
#[default]
Replication,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct LatencyStat {
#[serde(with = "duration_milliseconds")]
pub curr: Duration, // Current latency
#[serde(with = "duration_milliseconds")]
pub avg: Duration, // Average latency
#[serde(with = "duration_milliseconds")]
pub max: Duration, // Maximum latency
}
mod duration_milliseconds {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_u64(duration.as_millis() as u64)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let millis = u64::deserialize(deserializer)?;
Ok(Duration::from_millis(millis))
}
}
mod duration_seconds {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_u64(duration.as_secs())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let secs = u64::deserialize(deserializer)?;
Ok(Duration::from_secs(secs))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub enum BucketTargetType {
#[default]
None,
#[serde(rename = "replication")]
ReplicationService,
#[serde(rename = "ilm")]
IlmService,
}
impl BucketTargetType {
pub fn is_valid(&self) -> bool {
match self {
BucketTargetType::None => false,
BucketTargetType::ReplicationService | BucketTargetType::IlmService => true,
}
}
}
impl FromStr for BucketTargetType {
type Err = std::io::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"replication" => Ok(BucketTargetType::ReplicationService),
"ilm" => Ok(BucketTargetType::IlmService),
_ => Ok(BucketTargetType::None),
}
}
}
impl fmt::Display for BucketTargetType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BucketTargetType::None => write!(f, ""),
BucketTargetType::ReplicationService => write!(f, "replication"),
BucketTargetType::IlmService => write!(f, "ilm"),
}
}
}
// Define BucketTarget structure
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct BucketTarget {
#[serde(rename = "sourcebucket", default)]
pub source_bucket: String,
#[serde(default)]
pub endpoint: String,
#[serde(default)]
pub credentials: Option<Credentials>,
#[serde(rename = "targetbucket", default)]
pub target_bucket: String,
#[serde(default)]
pub secure: bool,
#[serde(default)]
pub path: String,
#[serde(default)]
pub api: String,
#[serde(default)]
pub arn: String,
#[serde(rename = "type", default)]
pub target_type: BucketTargetType,
#[serde(default)]
pub region: String,
#[serde(alias = "bandwidth", default)]
pub bandwidth_limit: i64,
#[serde(rename = "replicationSync", default)]
pub replication_sync: bool,
#[serde(default)]
pub storage_class: String,
#[serde(rename = "healthCheckDuration", with = "duration_seconds", default)]
pub health_check_duration: Duration,
#[serde(rename = "disableProxy", default)]
pub disable_proxy: bool,
#[serde(rename = "resetBeforeDate", with = "time::serde::rfc3339::option", default)]
pub reset_before_date: Option<OffsetDateTime>,
#[serde(default)]
pub reset_id: String,
#[serde(rename = "totalDowntime", with = "duration_seconds", default)]
pub total_downtime: Duration,
#[serde(rename = "lastOnline", with = "time::serde::rfc3339::option", default)]
pub last_online: Option<OffsetDateTime>,
#[serde(rename = "isOnline", default)]
pub online: bool,
#[serde(default)]
pub latency: LatencyStat,
#[serde(default)]
pub deployment_id: String,
#[serde(default)]
pub edge: bool,
#[serde(rename = "edgeSyncBeforeExpiry", default)]
pub edge_sync_before_expiry: bool,
#[serde(rename = "offlineCount", default)]
pub offline_count: u64,
}
impl BucketTarget {
pub fn is_empty(self) -> bool {
self.target_bucket.is_empty() && self.endpoint.is_empty() && self.arn.is_empty()
}
pub fn url(&self) -> Result<Url> {
let scheme = if self.secure { "https" } else { "http" };
Url::parse(&format!("{}://{}", scheme, self.endpoint)).map_err(Error::other)
}
}
impl Display for BucketTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} ", self.endpoint)?;
write!(f, "{}", self.target_bucket.clone())?;
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct BucketTargets {
pub targets: Vec<BucketTarget>,
}
impl BucketTargets {
pub fn marshal_msg(&self) -> Result<Vec<u8>> {
let mut buf = Vec::new();
self.serialize(&mut rmpSerializer::new(&mut buf).with_struct_map())?;
Ok(buf)
}
pub fn unmarshal(buf: &[u8]) -> Result<Self> {
let t: BucketTargets = rmp_serde::from_slice(buf)?;
Ok(t)
}
pub fn is_empty(&self) -> bool {
if self.targets.is_empty() {
return true;
}
for target in &self.targets {
if !target.clone().is_empty() {
return false;
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json;
use std::time::Duration;
use time::OffsetDateTime;
#[test]
fn test_bucket_target_json_deserialize() {
let json = r#"
{
"sourcebucket": "source-bucket-name",
"endpoint": "s3.amazonaws.com",
"credentials": {
"accessKey": "test-access-key",
"secretKey": "test-secret-key",
"session_token": "test-session-token",
"expiration": "2024-12-31T23:59:59Z"
},
"targetbucket": "target-bucket-name",
"secure": true,
"path": "/api/v1",
"api": "s3v4",
"arn": "arn:aws:s3:::target-bucket-name",
"type": "replication",
"region": "us-east-1",
"bandwidth_limit": 1000000,
"replicationSync": true,
"storage_class": "STANDARD",
"healthCheckDuration": 30,
"disableProxy": false,
"resetBeforeDate": null,
"reset_id": "reset-123",
"totalDowntime": 3600,
"last_online": null,
"isOnline": true,
"latency": {
"curr": 100,
"avg": 150,
"max": 300
},
"deployment_id": "deployment-456",
"edge": false,
"edgeSyncBeforeExpiry": true,
"offlineCount": 5
}
"#;
let result: std::result::Result<BucketTarget, _> = serde_json::from_str(json);
assert!(result.is_ok(), "Failed to deserialize BucketTarget: {:?}", result.err());
let target = result.unwrap();
// Verify basic fields
assert_eq!(target.source_bucket, "source-bucket-name");
assert_eq!(target.endpoint, "s3.amazonaws.com");
assert_eq!(target.target_bucket, "target-bucket-name");
assert!(target.secure);
assert_eq!(target.path, "/api/v1");
assert_eq!(target.api, "s3v4");
assert_eq!(target.arn, "arn:aws:s3:::target-bucket-name");
assert_eq!(target.target_type, BucketTargetType::ReplicationService);
assert_eq!(target.region, "us-east-1");
assert_eq!(target.bandwidth_limit, 1000000);
assert!(target.replication_sync);
assert_eq!(target.storage_class, "STANDARD");
assert_eq!(target.health_check_duration, Duration::from_secs(30));
assert!(!target.disable_proxy);
assert_eq!(target.reset_id, "reset-123");
assert_eq!(target.total_downtime, Duration::from_secs(3600));
assert!(target.online);
assert_eq!(target.deployment_id, "deployment-456");
assert!(!target.edge);
assert!(target.edge_sync_before_expiry);
assert_eq!(target.offline_count, 5);
// Verify credentials
assert!(target.credentials.is_some());
let credentials = target.credentials.unwrap();
assert_eq!(credentials.access_key, "test-access-key");
assert_eq!(credentials.secret_key, "test-secret-key");
assert_eq!(credentials.session_token, Some("test-session-token".to_string()));
assert!(credentials.expiration.is_some());
// Verify latency statistics
assert_eq!(target.latency.curr, Duration::from_millis(100));
assert_eq!(target.latency.avg, Duration::from_millis(150));
assert_eq!(target.latency.max, Duration::from_millis(300));
// Verify time fields
assert!(target.reset_before_date.is_none());
assert!(target.last_online.is_none());
}
#[test]
fn test_bucket_target_json_serialize_deserialize_roundtrip() {
let original = BucketTarget {
source_bucket: "test-source".to_string(),
endpoint: "rustfs.example.com".to_string(),
credentials: Some(Credentials {
access_key: "rustfsaccess".to_string(),
secret_key: "rustfssecret".to_string(),
session_token: None,
expiration: None,
}),
target_bucket: "test-target".to_string(),
secure: false,
path: "/".to_string(),
api: "s3v4".to_string(),
arn: "arn:rustfs:s3:::test-target".to_string(),
target_type: BucketTargetType::ReplicationService,
region: "us-west-2".to_string(),
bandwidth_limit: 500000,
replication_sync: false,
storage_class: "REDUCED_REDUNDANCY".to_string(),
health_check_duration: Duration::from_secs(60),
disable_proxy: true,
reset_before_date: Some(OffsetDateTime::now_utc()),
reset_id: "reset-456".to_string(),
total_downtime: Duration::from_secs(1800),
last_online: Some(OffsetDateTime::now_utc()),
online: false,
latency: LatencyStat {
curr: Duration::from_millis(250),
avg: Duration::from_millis(200),
max: Duration::from_millis(500),
},
deployment_id: "deploy-789".to_string(),
edge: true,
edge_sync_before_expiry: false,
offline_count: 10,
};
// Serialize to JSON
let json = serde_json::to_string(&original).expect("Failed to serialize to JSON");
// Deserialize from JSON
let deserialized: BucketTarget = serde_json::from_str(&json).expect("Failed to deserialize from JSON");
// Verify key fields are equal
assert_eq!(original.source_bucket, deserialized.source_bucket);
assert_eq!(original.endpoint, deserialized.endpoint);
assert_eq!(original.target_bucket, deserialized.target_bucket);
assert_eq!(original.secure, deserialized.secure);
assert_eq!(original.target_type, deserialized.target_type);
assert_eq!(original.region, deserialized.region);
assert_eq!(original.bandwidth_limit, deserialized.bandwidth_limit);
assert_eq!(original.replication_sync, deserialized.replication_sync);
assert_eq!(original.health_check_duration, deserialized.health_check_duration);
assert_eq!(original.online, deserialized.online);
assert_eq!(original.edge, deserialized.edge);
assert_eq!(original.offline_count, deserialized.offline_count);
}
#[test]
fn test_bucket_target_type_json_deserialize() {
// Test BucketTargetType JSON deserialization
let replication_json = r#""replication""#;
let ilm_json = r#""ilm""#;
let replication_type: BucketTargetType =
serde_json::from_str(replication_json).expect("Failed to deserialize replication type");
let ilm_type: BucketTargetType = serde_json::from_str(ilm_json).expect("Failed to deserialize ilm type");
assert_eq!(replication_type, BucketTargetType::ReplicationService);
assert_eq!(ilm_type, BucketTargetType::IlmService);
// Verify type validity
assert!(replication_type.is_valid());
assert!(ilm_type.is_valid());
assert!(!BucketTargetType::None.is_valid());
}
#[test]
fn test_credentials_json_deserialize() {
let json = r#"
{
"accessKey": "AKIAIOSFODNN7EXAMPLE",
"secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"session_token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT",
"expiration": "2024-12-31T23:59:59Z"
}
"#;
let credentials: Credentials = serde_json::from_str(json).expect("Failed to deserialize credentials");
assert_eq!(credentials.access_key, "AKIAIOSFODNN7EXAMPLE");
assert_eq!(credentials.secret_key, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
assert_eq!(
credentials.session_token,
Some("AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT".to_string())
);
assert!(credentials.expiration.is_some());
}
#[test]
fn test_latency_stat_json_deserialize() {
let json = r#"
{
"curr": 50,
"avg": 75,
"max": 200
}
"#;
let latency: LatencyStat = serde_json::from_str(json).expect("Failed to deserialize latency stat");
assert_eq!(latency.curr, Duration::from_millis(50));
assert_eq!(latency.avg, Duration::from_millis(75));
assert_eq!(latency.max, Duration::from_millis(200));
}
#[test]
fn test_bucket_targets_json_deserialize() {
let json = r#"
{
"targets": [
{
"sourcebucket": "bucket1",
"endpoint": "s3.amazonaws.com",
"targetbucket": "target1",
"secure": true,
"path": "/",
"api": "s3v4",
"arn": "arn:aws:s3:::target1",
"type": "replication",
"region": "us-east-1",
"bandwidth_limit": 0,
"replicationSync": false,
"storage_class": "",
"healthCheckDuration": 0,
"disableProxy": false,
"resetBeforeDate": null,
"reset_id": "",
"totalDowntime": 0,
"lastOnline": null,
"isOnline": false,
"latency": {
"curr": 0,
"avg": 0,
"max": 0
},
"deployment_id": "",
"edge": false,
"edgeSyncBeforeExpiry": false,
"offlineCount": 0
}
]
}
"#;
let targets: BucketTargets = serde_json::from_str(json).expect("Failed to deserialize bucket targets");
assert_eq!(targets.targets.len(), 1);
assert_eq!(targets.targets[0].source_bucket, "bucket1");
assert_eq!(targets.targets[0].endpoint, "s3.amazonaws.com");
assert_eq!(targets.targets[0].target_bucket, "target1");
assert!(!targets.is_empty());
}
#[test]
fn test_user_provided_json_deserialize() {
// Test the specific JSON provided by the user with missing required fields added
let json = r#"
{
"sourcebucket": "mc-test-bucket-22139",
"endpoint": "localhost:8000",
"credentials": {
"accessKey": "rustfsadmin",
"secretKey": "rustfsadmin",
"expiration": "0001-01-01T00:00:00Z"
},
"targetbucket": "test",
"secure": false,
"path": "auto",
"api": "s3v4",
"type": "replication",
"replicationSync": false,
"healthCheckDuration": 60,
"disableProxy": false,
"resetBeforeDate": "0001-01-01T00:00:00Z",
"totalDowntime": 0,
"lastOnline": "0001-01-01T00:00:00Z",
"isOnline": false,
"latency": {
"curr": 0,
"avg": 0,
"max": 0
},
"deployment_id": "",
"edge": false,
"edgeSyncBeforeExpiry": false,
"offlineCount": 0,
"bandwidth": 107374182400
}
"#;
let target: BucketTarget = serde_json::from_str(json).expect("Failed to deserialize user provided JSON to BucketTarget");
// Verify the deserialized values match the original JSON
assert_eq!(target.source_bucket, "mc-test-bucket-22139");
assert_eq!(target.endpoint, "localhost:8000");
assert_eq!(target.target_bucket, "test");
assert!(!target.secure);
assert_eq!(target.path, "auto");
assert_eq!(target.api, "s3v4");
assert_eq!(target.target_type, BucketTargetType::ReplicationService);
assert!(!target.replication_sync);
assert_eq!(target.health_check_duration, Duration::from_secs(60));
assert!(!target.disable_proxy);
assert!(!target.online);
assert!(!target.edge);
assert!(!target.edge_sync_before_expiry);
assert_eq!(target.bandwidth_limit, 107374182400); // bandwidth field mapped to bandwidth_limit
// Verify credentials
assert!(target.credentials.is_some());
let credentials = target.credentials.unwrap();
assert_eq!(credentials.access_key, "rustfsadmin");
assert_eq!(credentials.secret_key, "rustfsadmin");
// Verify latency statistics
assert_eq!(target.latency.curr, Duration::from_millis(0));
assert_eq!(target.latency.avg, Duration::from_millis(0));
assert_eq!(target.latency.max, Duration::from_millis(0));
// Verify time fields parsing (should handle "0001-01-01T00:00:00Z" as None due to being the zero time)
assert!(target.reset_before_date.is_some());
assert!(target.last_online.is_some());
println!("✅ User provided JSON successfully deserialized to BucketTarget");
}
#[test]
fn test_user_provided_json_as_bucket_targets() {
// Test wrapping the user JSON in BucketTargets structure
let json = r#"
{
"targets": [
{
"sourcebucket": "mc-test-bucket-22139",
"endpoint": "localhost:8000",
"credentials": {
"accessKey": "rustfsadmin",
"secretKey": "rustfsadmin",
"expiration": "0001-01-01T00:00:00Z"
},
"targetbucket": "test",
"secure": false,
"path": "auto",
"api": "s3v4",
"arn": "",
"type": "replication",
"region": "",
"replicationSync": false,
"storage_class": "",
"healthCheckDuration": 60,
"disableProxy": false,
"resetBeforeDate": "0001-01-01T00:00:00Z",
"reset_id": "",
"totalDowntime": 0,
"lastOnline": "0001-01-01T00:00:00Z",
"isOnline": false,
"latency": {
"curr": 0,
"avg": 0,
"max": 0
},
"deployment_id": "",
"edge": false,
"edgeSyncBeforeExpiry": false,
"offlineCount": 0,
"bandwidth": 107374182400
}
]
}
"#;
let bucket_targets: BucketTargets =
serde_json::from_str(json).expect("Failed to deserialize user provided JSON to BucketTargets");
assert_eq!(bucket_targets.targets.len(), 1);
assert!(!bucket_targets.is_empty());
let target = &bucket_targets.targets[0];
assert_eq!(target.source_bucket, "mc-test-bucket-22139");
assert_eq!(target.endpoint, "localhost:8000");
assert_eq!(target.target_bucket, "test");
assert_eq!(target.bandwidth_limit, 107374182400);
println!("✅ User provided JSON successfully deserialized to BucketTargets");
}
#[test]
fn test_bucket_target_minimal_json_with_defaults() {
// Test that BucketTarget can be deserialized with minimal JSON using defaults
let minimal_json = r#"
{
"sourcebucket": "test-source",
"endpoint": "localhost:9000",
"targetbucket": "test-target"
}
"#;
let target: BucketTarget =
serde_json::from_str(minimal_json).expect("Failed to deserialize minimal JSON to BucketTarget");
// Verify required fields
assert_eq!(target.source_bucket, "test-source");
assert_eq!(target.endpoint, "localhost:9000");
assert_eq!(target.target_bucket, "test-target");
// Verify default values
assert!(!target.secure); // bool default is false
assert_eq!(target.path, ""); // String default is empty
assert_eq!(target.api, ""); // String default is empty
assert_eq!(target.arn, ""); // String default is empty
assert_eq!(target.target_type, BucketTargetType::None); // enum default
assert_eq!(target.region, ""); // String default is empty
assert_eq!(target.bandwidth_limit, 0); // i64 default is 0
assert!(!target.replication_sync); // bool default is false
assert_eq!(target.storage_class, ""); // String default is empty
assert_eq!(target.health_check_duration, Duration::from_secs(0)); // Duration default
assert!(!target.disable_proxy); // bool default is false
assert!(target.reset_before_date.is_none()); // Option default is None
assert_eq!(target.reset_id, ""); // String default is empty
assert_eq!(target.total_downtime, Duration::from_secs(0)); // Duration default
assert!(target.last_online.is_none()); // Option default is None
assert!(!target.online); // bool default is false
assert_eq!(target.latency.curr, Duration::from_millis(0)); // LatencyStat default
assert_eq!(target.latency.avg, Duration::from_millis(0));
assert_eq!(target.latency.max, Duration::from_millis(0));
assert_eq!(target.deployment_id, ""); // String default is empty
assert!(!target.edge); // bool default is false
assert!(!target.edge_sync_before_expiry); // bool default is false
assert_eq!(target.offline_count, 0); // u64 default is 0
assert!(target.credentials.is_none()); // Option default is None
println!("✅ Minimal JSON with defaults successfully deserialized to BucketTarget");
}
#[test]
fn test_bucket_target_empty_json_with_defaults() {
// Test that BucketTarget can be deserialized with completely empty JSON using all defaults
let empty_json = r#"{}"#;
let target: BucketTarget = serde_json::from_str(empty_json).expect("Failed to deserialize empty JSON to BucketTarget");
// Verify all fields use default values
assert_eq!(target.source_bucket, "");
assert_eq!(target.endpoint, "");
assert_eq!(target.target_bucket, "");
assert!(!target.secure);
assert_eq!(target.path, "");
assert_eq!(target.api, "");
assert_eq!(target.arn, "");
assert_eq!(target.target_type, BucketTargetType::None);
assert_eq!(target.region, "");
assert_eq!(target.bandwidth_limit, 0);
assert!(!target.replication_sync);
assert_eq!(target.storage_class, "");
assert_eq!(target.health_check_duration, Duration::from_secs(0));
assert!(!target.disable_proxy);
assert!(target.reset_before_date.is_none());
assert_eq!(target.reset_id, "");
assert_eq!(target.total_downtime, Duration::from_secs(0));
assert!(target.last_online.is_none());
assert!(!target.online);
assert_eq!(target.latency.curr, Duration::from_millis(0));
assert_eq!(target.latency.avg, Duration::from_millis(0));
assert_eq!(target.latency.max, Duration::from_millis(0));
assert_eq!(target.deployment_id, "");
assert!(!target.edge);
assert!(!target.edge_sync_before_expiry);
assert_eq!(target.offline_count, 0);
assert!(target.credentials.is_none());
println!("✅ Empty JSON with all defaults successfully deserialized to BucketTarget");
}
#[test]
fn test_original_user_json_with_defaults() {
// Test the original user JSON without extra required fields
let json = r#"
{
"sourcebucket": "mc-test-bucket-22139",
"endpoint": "localhost:8000",
"credentials": {
"accessKey": "rustfsadmin",
"secretKey": "rustfsadmin",
"expiration": "0001-01-01T00:00:00Z"
},
"targetbucket": "test",
"secure": false,
"path": "auto",
"api": "s3v4",
"type": "replication",
"replicationSync": false,
"healthCheckDuration": 60,
"disableProxy": false,
"resetBeforeDate": "0001-01-01T00:00:00Z",
"totalDowntime": 0,
"lastOnline": "0001-01-01T00:00:00Z",
"isOnline": false,
"latency": {
"curr": 0,
"avg": 0,
"max": 0
},
"edge": false,
"edgeSyncBeforeExpiry": false,
"bandwidth": 107374182400
}
"#;
let target: BucketTarget = serde_json::from_str(json).expect("Failed to deserialize original user JSON to BucketTarget");
// Verify the deserialized values
assert_eq!(target.source_bucket, "mc-test-bucket-22139");
assert_eq!(target.endpoint, "localhost:8000");
assert_eq!(target.target_bucket, "test");
assert!(!target.secure);
assert_eq!(target.path, "auto");
assert_eq!(target.api, "s3v4");
assert_eq!(target.target_type, BucketTargetType::ReplicationService);
assert!(!target.replication_sync);
assert_eq!(target.health_check_duration, Duration::from_secs(60));
assert!(!target.disable_proxy);
assert!(!target.online);
assert!(!target.edge);
assert!(!target.edge_sync_before_expiry);
assert_eq!(target.bandwidth_limit, 107374182400);
// Fields not specified should use defaults
assert_eq!(target.arn, ""); // default empty string
assert_eq!(target.region, ""); // default empty string
assert_eq!(target.storage_class, ""); // default empty string
assert_eq!(target.reset_id, ""); // default empty string
assert_eq!(target.deployment_id, ""); // default empty string
assert_eq!(target.offline_count, 0); // default u64
// Verify credentials
assert!(target.credentials.is_some());
let credentials = target.credentials.unwrap();
assert_eq!(credentials.access_key, "rustfsadmin");
assert_eq!(credentials.secret_key, "rustfsadmin");
println!("✅ Original user JSON with defaults successfully deserialized to BucketTarget");
}
}
+4 -120
View File
@@ -12,124 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::error::Result;
use rmp_serde::Serializer as rmpSerializer;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
mod arn;
mod bucket_target;
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct Credentials {
#[serde(rename = "accessKey")]
pub access_key: String,
#[serde(rename = "secretKey")]
pub secret_key: String,
pub session_token: Option<String>,
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub enum ServiceType {
#[default]
Replication,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct LatencyStat {
curr: u64, // current latency
avg: u64, // average latency
max: u64, // maximum latency
}
// Define BucketTarget struct
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct BucketTarget {
#[serde(rename = "sourcebucket")]
pub source_bucket: String,
pub endpoint: String,
pub credentials: Option<Credentials>,
#[serde(rename = "targetbucket")]
pub target_bucket: String,
secure: bool,
pub path: Option<String>,
api: Option<String>,
pub arn: Option<String>,
#[serde(rename = "type")]
pub type_: Option<String>,
pub region: Option<String>,
bandwidth_limit: Option<i64>,
#[serde(rename = "replicationSync")]
replication_sync: bool,
storage_class: Option<String>,
#[serde(rename = "healthCheckDuration")]
health_check_duration: u64,
#[serde(rename = "disableProxy")]
disable_proxy: bool,
#[serde(rename = "resetBeforeDate")]
reset_before_date: String,
reset_id: Option<String>,
#[serde(rename = "totalDowntime")]
total_downtime: u64,
last_online: Option<OffsetDateTime>,
#[serde(rename = "isOnline")]
online: bool,
latency: Option<LatencyStat>,
deployment_id: Option<String>,
edge: bool,
#[serde(rename = "edgeSyncBeforeExpiry")]
edge_sync_before_expiry: bool,
}
impl BucketTarget {
pub fn is_empty(self) -> bool {
//self.target_bucket.is_empty() && self.endpoint.is_empty() && self.arn.is_empty()
self.target_bucket.is_empty() && self.endpoint.is_empty() && self.arn.is_none()
}
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct BucketTargets {
pub targets: Vec<BucketTarget>,
}
impl BucketTargets {
pub fn marshal_msg(&self) -> Result<Vec<u8>> {
let mut buf = Vec::new();
self.serialize(&mut rmpSerializer::new(&mut buf).with_struct_map())?;
Ok(buf)
}
pub fn unmarshal(buf: &[u8]) -> Result<Self> {
let t: BucketTargets = rmp_serde::from_slice(buf)?;
Ok(t)
}
pub fn is_empty(&self) -> bool {
if self.targets.is_empty() {
return true;
}
for target in &self.targets {
if !target.clone().is_empty() {
return false;
}
}
true
}
}
pub use arn::*;
pub use bucket_target::*;