mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-26 05:56:50 +00:00
fix(replication): fan out single-bucket rules to all targets (#2701)
This commit is contained in:
@@ -255,6 +255,60 @@ async fn put_bucket_replication(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn put_bucket_replication_rules(
|
||||||
|
env: &RustFSTestEnvironment,
|
||||||
|
bucket: &str,
|
||||||
|
target_arns: &[&str],
|
||||||
|
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
let mut rules = String::new();
|
||||||
|
for (idx, target_arn) in target_arns.iter().enumerate() {
|
||||||
|
rules.push_str(&format!(
|
||||||
|
r#"
|
||||||
|
<Rule>
|
||||||
|
<ID>rule-{}</ID>
|
||||||
|
<Priority>{}</Priority>
|
||||||
|
<Status>Enabled</Status>
|
||||||
|
<DeleteMarkerReplication>
|
||||||
|
<Status>Enabled</Status>
|
||||||
|
</DeleteMarkerReplication>
|
||||||
|
<ExistingObjectReplication>
|
||||||
|
<Status>Enabled</Status>
|
||||||
|
</ExistingObjectReplication>
|
||||||
|
<Destination>
|
||||||
|
<Bucket>{}</Bucket>
|
||||||
|
</Destination>
|
||||||
|
</Rule>"#,
|
||||||
|
idx + 1,
|
||||||
|
idx + 1,
|
||||||
|
target_arn
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = format!(
|
||||||
|
r#"<ReplicationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
|
<Role></Role>{rules}
|
||||||
|
</ReplicationConfiguration>"#
|
||||||
|
);
|
||||||
|
let url = format!("{}/{bucket}?replication", env.url);
|
||||||
|
let response = signed_request(
|
||||||
|
http::Method::PUT,
|
||||||
|
&url,
|
||||||
|
&env.access_key,
|
||||||
|
&env.secret_key,
|
||||||
|
Some(body.into_bytes()),
|
||||||
|
Some("application/xml"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if response.status() != StatusCode::OK {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("put bucket replication with multiple rules failed: {status} {body}").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_bucket_replication(
|
async fn delete_bucket_replication(
|
||||||
env: &RustFSTestEnvironment,
|
env: &RustFSTestEnvironment,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
@@ -415,6 +469,33 @@ async fn admin_attach_policy_to_group(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn wait_for_replicated_object(
|
||||||
|
client: &aws_sdk_s3::Client,
|
||||||
|
bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
expected_body: &str,
|
||||||
|
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match client.get_object().bucket(bucket).key(key).send().await {
|
||||||
|
Ok(output) => {
|
||||||
|
let body = output.body.collect().await?.into_bytes();
|
||||||
|
let body = String::from_utf8(body.to_vec())?;
|
||||||
|
if body == expected_body {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
return Err(format!("replicated object body mismatch: expected {expected_body}, got {body}").into());
|
||||||
|
}
|
||||||
|
Err(_err) if tokio::time::Instant::now() < deadline => {
|
||||||
|
sleep(Duration::from_secs(1)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_replication_check(
|
async fn run_replication_check(
|
||||||
env: &RustFSTestEnvironment,
|
env: &RustFSTestEnvironment,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
@@ -1518,6 +1599,55 @@ async fn test_delete_bucket_replication_removes_remote_target() -> Result<(), Bo
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_single_bucket_replication_fans_out_to_multiple_targets() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||||
|
source_env.start_rustfs_server(vec![]).await?;
|
||||||
|
|
||||||
|
let mut target_env_a = RustFSTestEnvironment::new().await?;
|
||||||
|
target_env_a.start_rustfs_server_without_cleanup(vec![]).await?;
|
||||||
|
|
||||||
|
let mut target_env_b = RustFSTestEnvironment::new().await?;
|
||||||
|
target_env_b.start_rustfs_server_without_cleanup(vec![]).await?;
|
||||||
|
|
||||||
|
let source_bucket = "replication-fanout-src";
|
||||||
|
let target_bucket_a = "replication-fanout-dst-a";
|
||||||
|
let target_bucket_b = "replication-fanout-dst-b";
|
||||||
|
let object_key = "fanout.txt";
|
||||||
|
let body = "payload-fanout";
|
||||||
|
|
||||||
|
let source_client = source_env.create_s3_client();
|
||||||
|
let target_client_a = target_env_a.create_s3_client();
|
||||||
|
let target_client_b = target_env_b.create_s3_client();
|
||||||
|
|
||||||
|
source_client.create_bucket().bucket(source_bucket).send().await?;
|
||||||
|
target_client_a.create_bucket().bucket(target_bucket_a).send().await?;
|
||||||
|
target_client_b.create_bucket().bucket(target_bucket_b).send().await?;
|
||||||
|
enable_bucket_versioning(&source_env, source_bucket).await?;
|
||||||
|
enable_bucket_versioning(&target_env_a, target_bucket_a).await?;
|
||||||
|
enable_bucket_versioning(&target_env_b, target_bucket_b).await?;
|
||||||
|
|
||||||
|
let target_arn_a = set_replication_target(&source_env, source_bucket, &target_env_a, target_bucket_a).await?;
|
||||||
|
let target_arn_b = set_replication_target(&source_env, source_bucket, &target_env_b, target_bucket_b).await?;
|
||||||
|
put_bucket_replication_rules(&source_env, source_bucket, &[target_arn_a.as_str(), target_arn_b.as_str()]).await?;
|
||||||
|
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(object_key)
|
||||||
|
.body(ByteStream::from(body.as_bytes().to_vec()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
wait_for_replicated_object(&target_client_a, target_bucket_a, object_key, body).await?;
|
||||||
|
wait_for_replicated_object(&target_client_b, target_bucket_b, object_key, body).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
|||||||
@@ -224,19 +224,98 @@ impl ReplicationConfigurationExt for ReplicationConfiguration {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.role.is_empty() {
|
if !rule.destination.bucket.is_empty() && !targets_map.contains(&rule.destination.bucket) {
|
||||||
arns.push(self.role.clone()); // Use the legacy RoleArn when present
|
|
||||||
return arns;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !targets_map.contains(&rule.destination.bucket) {
|
|
||||||
targets_map.insert(rule.destination.bucket.clone());
|
targets_map.insert(rule.destination.bucket.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if targets_map.is_empty() && !self.role.is_empty() {
|
||||||
|
arns.push(self.role.clone());
|
||||||
|
return arns;
|
||||||
|
}
|
||||||
|
|
||||||
for arn in targets_map {
|
for arn in targets_map {
|
||||||
arns.push(arn);
|
arns.push(arn);
|
||||||
}
|
}
|
||||||
arns
|
arns
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use s3s::dto::{DeleteMarkerReplication, Destination, ExistingObjectReplication, ReplicationRule};
|
||||||
|
|
||||||
|
fn replication_rule(id: &str, arn: &str) -> ReplicationRule {
|
||||||
|
ReplicationRule {
|
||||||
|
delete_marker_replication: Some(DeleteMarkerReplication::default()),
|
||||||
|
delete_replication: None,
|
||||||
|
destination: Destination {
|
||||||
|
bucket: arn.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
existing_object_replication: Some(ExistingObjectReplication {
|
||||||
|
status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED),
|
||||||
|
}),
|
||||||
|
filter: None,
|
||||||
|
id: Some(id.to_string()),
|
||||||
|
prefix: Some(String::new()),
|
||||||
|
priority: Some(1),
|
||||||
|
source_selection_criteria: None,
|
||||||
|
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_target_arns_keeps_multiple_destinations_when_role_is_present() {
|
||||||
|
let config = ReplicationConfiguration {
|
||||||
|
role: "arn:legacy:target".to_string(),
|
||||||
|
rules: vec![
|
||||||
|
replication_rule("rule-1", "arn:target:a"),
|
||||||
|
replication_rule("rule-2", "arn:target:b"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let arns = config.filter_target_arns(&ObjectOpts {
|
||||||
|
name: "object".to_string(),
|
||||||
|
op_type: ReplicationType::Object,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(arns.len(), 2);
|
||||||
|
assert!(arns.iter().any(|arn| arn == "arn:target:a"));
|
||||||
|
assert!(arns.iter().any(|arn| arn == "arn:target:b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_target_arns_falls_back_to_role_when_destination_is_empty() {
|
||||||
|
let config = ReplicationConfiguration {
|
||||||
|
role: "arn:legacy:target".to_string(),
|
||||||
|
rules: vec![ReplicationRule {
|
||||||
|
delete_marker_replication: Some(DeleteMarkerReplication::default()),
|
||||||
|
delete_replication: None,
|
||||||
|
destination: Destination {
|
||||||
|
bucket: String::new(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
existing_object_replication: Some(ExistingObjectReplication {
|
||||||
|
status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED),
|
||||||
|
}),
|
||||||
|
filter: None,
|
||||||
|
id: Some("rule-1".to_string()),
|
||||||
|
prefix: Some(String::new()),
|
||||||
|
priority: Some(1),
|
||||||
|
source_selection_criteria: None,
|
||||||
|
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let arns = config.filter_target_arns(&ObjectOpts {
|
||||||
|
name: "object".to_string(),
|
||||||
|
op_type: ReplicationType::Object,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(arns, vec!["arn:legacy:target".to_string()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user