fix(replication): enforce bucket replication switches (#5449)

* fix(replication): enforce bucket replication switches

* fix(replication): satisfy delete admission clippy lint

* fix(replication): restore MinIO tag filter behavior

---------

Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: cxymds <cxymds@gmail.com>
This commit is contained in:
唐小鸭
2026-08-01 23:03:57 +08:00
committed by GitHub
parent 56a7e3b707
commit c8016cbcdb
13 changed files with 294 additions and 174 deletions
+2 -2
View File
@@ -188,8 +188,8 @@ pub mod bucket {
get_global_replication_stats, init_background_replication, read_durable_mrf_backlog, replication_state_to_filemeta, get_global_replication_stats, init_background_replication, read_durable_mrf_backlog, replication_state_to_filemeta,
replication_status_to_filemeta, replication_statuses_map, replication_target_arns, resync_start_conflict_id, replication_status_to_filemeta, replication_statuses_map, replication_target_arns, resync_start_conflict_id,
should_remove_replication_target, should_schedule_delete_replication, should_use_existing_delete_replication_info, should_remove_replication_target, should_schedule_delete_replication, should_use_existing_delete_replication_info,
should_use_existing_delete_replication_source, validate_replication_config_target_arns, should_use_existing_delete_replication_source, unsupported_replication_config_field,
version_purge_status_to_filemeta, validate_replication_config_target_arns, version_purge_status_to_filemeta,
}; };
} }
+1 -1
View File
@@ -46,7 +46,7 @@ mod runtime_boundary;
pub use datatypes::ResyncStatusType; pub use datatypes::ResyncStatusType;
pub use replication_config_boundary::{ pub use replication_config_boundary::{
ObjectOpts, ReplicationConfigurationExt, ReplicationTargetValidationError, replication_target_arns, ObjectOpts, ReplicationConfigurationExt, ReplicationTargetValidationError, replication_target_arns,
should_remove_replication_target, validate_replication_config_target_arns, should_remove_replication_target, unsupported_replication_config_field, validate_replication_config_target_arns,
}; };
#[cfg(test)] #[cfg(test)]
pub(crate) use replication_filemeta_boundary::ReplicateTargetDecision; pub(crate) use replication_filemeta_boundary::ReplicateTargetDecision;
@@ -14,5 +14,5 @@
pub use rustfs_replication::{ pub use rustfs_replication::{
ObjectOpts, ReplicationConfigurationExt, ReplicationTargetValidationError, replication_target_arns, ObjectOpts, ReplicationConfigurationExt, ReplicationTargetValidationError, replication_target_arns,
should_remove_replication_target, validate_replication_config_target_arns, should_remove_replication_target, unsupported_replication_config_field, validate_replication_config_target_arns,
}; };
@@ -14,8 +14,11 @@
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use super::replication_error_boundary::Result;
use super::replication_filemeta_boundary::{ReplicateDecision, ReplicationStatusType, ReplicationType}; use super::replication_filemeta_boundary::{ReplicateDecision, ReplicationStatusType, ReplicationType};
use super::replication_object_config::{check_replicate_delete, get_must_replicate_options, must_replicate}; use super::replication_object_config::{
check_replicate_delete, check_replicate_delete_strict, get_must_replicate_options, must_replicate,
};
use super::replication_object_decision_boundary::MustReplicateOptions; use super::replication_object_decision_boundary::MustReplicateOptions;
use super::replication_pool::{schedule_replication, schedule_replication_delete}; use super::replication_pool::{schedule_replication, schedule_replication_delete};
use super::replication_queue_boundary::DeletedObjectReplicationInfo; use super::replication_queue_boundary::DeletedObjectReplicationInfo;
@@ -50,6 +53,16 @@ impl ReplicationObjectBridge {
check_replicate_delete(bucket, object, source, opts, get_error).await check_replicate_delete(bucket, object, source, opts, get_error).await
} }
pub async fn check_delete_strict(
bucket: &str,
object: &ObjectToDelete,
source: &ObjectInfo,
opts: &ObjectOptions,
get_error: Option<String>,
) -> Result<ReplicateDecision> {
check_replicate_delete_strict(bucket, object, source, opts, get_error).await
}
pub async fn schedule_object<S: ReplicationStorage>( pub async fn schedule_object<S: ReplicationStorage>(
object: ObjectInfo, object: ObjectInfo,
storage: Arc<S>, storage: Arc<S>,
@@ -169,9 +169,8 @@ pub(crate) async fn check_replicate_delete(
del_opts: &ObjectOptions, del_opts: &ObjectOptions,
gerr: Option<String>, gerr: Option<String>,
) -> ReplicateDecision { ) -> ReplicateDecision {
let rcfg = match get_replication_config(bucket).await { match check_replicate_delete_strict(bucket, dobj, oi, del_opts, gerr).await {
Ok(Some(config)) => config, Ok(decision) => decision,
Ok(None) => return ReplicateDecision::default(),
Err(err) => { Err(err) => {
error!( error!(
event = EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED, event = EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED,
@@ -182,16 +181,30 @@ pub(crate) async fn check_replicate_delete(
error = %err, error = %err,
"Failed to look up replication config for delete replication" "Failed to look up replication config for delete replication"
); );
return ReplicateDecision::default(); ReplicateDecision::default()
} }
}
}
pub(crate) async fn check_replicate_delete_strict(
bucket: &str,
dobj: &ObjectToDelete,
oi: &ObjectInfo,
del_opts: &ObjectOptions,
gerr: Option<String>,
) -> Result<ReplicateDecision> {
let rcfg = match get_replication_config(bucket).await {
Ok(Some(config)) => config,
Ok(None) => return Ok(ReplicateDecision::default()),
Err(err) => return Err(err),
}; };
if del_opts.replication_request { if del_opts.replication_request {
return ReplicateDecision::default(); return Ok(ReplicateDecision::default());
} }
if !del_opts.versioned { if !del_opts.versioned && !del_opts.version_suspended {
return ReplicateDecision::default(); return Ok(ReplicateDecision::default());
} }
let replication_delete = object_to_delete_for_replication(dobj); let replication_delete = object_to_delete_for_replication(dobj);
@@ -209,7 +222,7 @@ pub(crate) async fn check_replicate_delete(
let mut dsc = ReplicateDecision::new(); let mut dsc = ReplicateDecision::new();
if tgt_arns.is_empty() { if tgt_arns.is_empty() {
return dsc; return Ok(dsc);
} }
for tgt_arn in tgt_arns { for tgt_arn in tgt_arns {
@@ -239,7 +252,7 @@ pub(crate) async fn check_replicate_delete(
dsc.set(tgt_dsc); dsc.set(tgt_dsc);
} }
dsc Ok(dsc)
} }
pub(crate) async fn must_replicate(bucket: &str, object: &str, mopts: MustReplicateOptions) -> ReplicateDecision { pub(crate) async fn must_replicate(bucket: &str, object: &str, mopts: MustReplicateOptions) -> ReplicateDecision {
@@ -1151,60 +1151,6 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
dobj.delete_object.version_id dobj.delete_object.version_id
}; };
let _rcfg = match get_replication_config(&bucket).await {
Ok(Some(config)) => config,
Ok(None) => {
debug!(
event = EVENT_REPLICATION_DELETE_SKIPPED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket = %bucket,
reason = "replication_config_missing",
"Skipping replication delete because replication config is missing"
);
send_local_event(EventArgs {
event_name: EventName::ObjectReplicationNotTracked.to_string(),
bucket_name: bucket.clone(),
object: ObjectInfo {
bucket: bucket.clone(),
name: dobj.delete_object.object_name.clone(),
version_id,
delete_marker: dobj.delete_object.delete_marker,
..Default::default()
},
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
return;
}
Err(err) => {
debug!(
event = EVENT_REPLICATION_DELETE_SKIPPED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket = %bucket,
error = %err,
reason = "replication_config_lookup_failed",
"Skipping replication delete because replication config lookup failed"
);
send_local_event(EventArgs {
event_name: EventName::ObjectReplicationNotTracked.to_string(),
bucket_name: bucket.clone(),
object: ObjectInfo {
bucket: bucket.clone(),
name: dobj.delete_object.object_name.clone(),
version_id,
delete_marker: dobj.delete_object.delete_marker,
..Default::default()
},
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
return;
}
};
if dobj.delete_object.delete_marker if dobj.delete_object.delete_marker
&& let Some(delete_marker_version_id) = dobj.delete_object.delete_marker_version_id && let Some(delete_marker_version_id) = dobj.delete_object.delete_marker_version_id
{ {
+122 -6
View File
@@ -51,6 +51,28 @@ pub enum ReplicationTargetValidationError {
StaleTarget, StaleTarget,
} }
pub fn unsupported_replication_config_field(config: &ReplicationConfiguration) -> Option<&'static str> {
for rule in &config.rules {
if rule
.source_selection_criteria
.as_ref()
.is_some_and(|criteria| criteria.sse_kms_encrypted_objects.is_some())
{
return Some("SourceSelectionCriteria.SseKmsEncryptedObjects");
}
if rule.destination.encryption_configuration.is_some() {
return Some("Destination.EncryptionConfiguration");
}
if rule.destination.metrics.is_some() {
return Some("Destination.Metrics");
}
if rule.destination.replication_time.is_some() {
return Some("Destination.ReplicationTime");
}
}
None
}
pub fn active_replication_rule_destination_arns(config: &ReplicationConfiguration) -> HashSet<String> { pub fn active_replication_rule_destination_arns(config: &ReplicationConfiguration) -> HashSet<String> {
let mut arns = HashSet::new(); let mut arns = HashSet::new();
@@ -217,11 +239,6 @@ impl ReplicationConfigurationExt for ReplicationConfiguration {
} }
if obj.version_id.is_some() { if obj.version_id.is_some() {
if obj.delete_marker {
return rule.delete_marker_replication.clone().is_some_and(|d| {
d.status == Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED))
});
}
return rule return rule
.delete_replication .delete_replication
.clone() .clone()
@@ -305,7 +322,11 @@ impl ReplicationConfigurationExt for ReplicationConfiguration {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use s3s::dto::{DeleteMarkerReplication, Destination, ExistingObjectReplication, ReplicationRule}; use s3s::dto::{
DeleteMarkerReplication, DeleteReplication, Destination, EncryptionConfiguration, ExistingObjectReplication, Metrics,
MetricsStatus, ReplicationRule, ReplicationTime, ReplicationTimeStatus, ReplicationTimeValue, SourceSelectionCriteria,
SseKmsEncryptedObjects, SseKmsEncryptedObjectsStatus,
};
fn replication_rule(id: &str, arn: &str) -> ReplicationRule { fn replication_rule(id: &str, arn: &str) -> ReplicationRule {
ReplicationRule { ReplicationRule {
@@ -605,4 +626,99 @@ mod tests {
"highest-priority rule disables delete-marker replication, so the delete marker must not replicate" "highest-priority rule disables delete-marker replication, so the delete marker must not replicate"
); );
} }
#[test]
fn version_purge_uses_delete_replication_for_object_and_marker_versions() {
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
let mut rule = replication_rule("delete", arn);
rule.delete_marker_replication = Some(DeleteMarkerReplication {
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::DISABLED)),
});
rule.delete_replication = Some(DeleteReplication {
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
});
let mut config = ReplicationConfiguration {
role: String::new(),
rules: vec![rule],
};
let version_id = Some(Uuid::new_v4());
for delete_marker in [false, true] {
assert!(config.replicate(&ObjectOpts {
name: "object".to_string(),
version_id,
delete_marker,
op_type: ReplicationType::Delete,
..Default::default()
}));
}
assert!(!config.replicate(&ObjectOpts {
name: "object".to_string(),
delete_marker: true,
op_type: ReplicationType::Delete,
..Default::default()
}));
let rule = &mut config.rules[0];
rule.delete_marker_replication = Some(DeleteMarkerReplication {
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)),
});
rule.delete_replication = Some(DeleteReplication {
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::DISABLED),
});
for delete_marker in [false, true] {
assert!(!config.replicate(&ObjectOpts {
name: "object".to_string(),
version_id,
delete_marker,
op_type: ReplicationType::Delete,
..Default::default()
}));
}
assert!(config.replicate(&ObjectOpts {
name: "object".to_string(),
delete_marker: true,
op_type: ReplicationType::Delete,
..Default::default()
}));
}
#[test]
fn unsupported_replication_fields_are_reported_before_persistence() {
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
let mut config = ReplicationConfiguration {
role: String::new(),
rules: vec![replication_rule("unsupported", arn)],
};
config.rules[0].source_selection_criteria = Some(SourceSelectionCriteria {
replica_modifications: None,
sse_kms_encrypted_objects: Some(SseKmsEncryptedObjects {
status: SseKmsEncryptedObjectsStatus::from_static(SseKmsEncryptedObjectsStatus::ENABLED),
}),
});
assert_eq!(
unsupported_replication_config_field(&config),
Some("SourceSelectionCriteria.SseKmsEncryptedObjects")
);
config.rules[0].source_selection_criteria = None;
config.rules[0].destination.encryption_configuration = Some(EncryptionConfiguration::default());
assert_eq!(unsupported_replication_config_field(&config), Some("Destination.EncryptionConfiguration"));
config.rules[0].destination.encryption_configuration = None;
config.rules[0].destination.metrics = Some(Metrics {
event_threshold: None,
status: MetricsStatus::from_static(MetricsStatus::ENABLED),
});
assert_eq!(unsupported_replication_config_field(&config), Some("Destination.Metrics"));
config.rules[0].destination.metrics = None;
config.rules[0].destination.replication_time = Some(ReplicationTime {
status: ReplicationTimeStatus::from_static(ReplicationTimeStatus::ENABLED),
time: ReplicationTimeValue { minutes: Some(15) },
});
assert_eq!(unsupported_replication_config_field(&config), Some("Destination.ReplicationTime"));
}
} }
+2 -1
View File
@@ -30,7 +30,8 @@ pub mod tagging;
pub use config::{ pub use config::{
ObjectOpts, ReplicationConfigurationExt, ReplicationTargetValidationError, active_replication_rule_destination_arns, ObjectOpts, ReplicationConfigurationExt, ReplicationTargetValidationError, active_replication_rule_destination_arns,
replication_target_arns, should_remove_replication_target, validate_replication_config_target_arns, replication_target_arns, should_remove_replication_target, unsupported_replication_config_field,
validate_replication_config_target_arns,
}; };
pub use delete::{ pub use delete::{
DeletedObjectReplicationInfo, is_retryable_delete_replication_head_error, is_version_delete_replication, DeletedObjectReplicationInfo, is_retryable_delete_replication_head_error, is_version_delete_replication,
+7 -5
View File
@@ -322,9 +322,9 @@ mod tests {
use crate::storage_api::ObjectToDelete; use crate::storage_api::ObjectToDelete;
use crate::{ReplicationStatusType, ReplicationType, VersionPurgeStatusType, target_reset_header}; use crate::{ReplicationStatusType, ReplicationType, VersionPurgeStatusType, target_reset_header};
use s3s::dto::{ use s3s::dto::{
DeleteMarkerReplication, DeleteMarkerReplicationStatus, Destination, ExistingObjectReplication, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication, DeleteReplicationStatus, Destination,
ExistingObjectReplicationStatus, ReplicaModifications, ReplicaModificationsStatus, ReplicationConfiguration, ExistingObjectReplication, ExistingObjectReplicationStatus, ReplicaModifications, ReplicaModificationsStatus,
ReplicationRule, ReplicationRuleStatus, SourceSelectionCriteria, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus, SourceSelectionCriteria,
}; };
use std::collections::HashMap; use std::collections::HashMap;
use time::{Duration, OffsetDateTime}; use time::{Duration, OffsetDateTime};
@@ -433,7 +433,9 @@ mod tests {
delete_marker_replication: Some(DeleteMarkerReplication { delete_marker_replication: Some(DeleteMarkerReplication {
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)), status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)),
}), }),
delete_replication: None, delete_replication: Some(DeleteReplication {
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
}),
destination: Destination { destination: Destination {
bucket: arn.to_string(), bucket: arn.to_string(),
..Default::default() ..Default::default()
@@ -513,7 +515,7 @@ mod tests {
}; };
let state = delete_replication_state_from_config(&config, &source) let state = delete_replication_state_from_config(&config, &source)
.expect("delete-marker version purge should honor delete-marker replication rules"); .expect("delete-marker version purge should honor delete replication rules");
let pending = format!("{arn}=PENDING;"); let pending = format!("{arn}=PENDING;");
assert_eq!(state.version_purge_status_internal.as_deref(), Some(pending.as_str())); assert_eq!(state.version_purge_status_internal.as_deref(), Some(pending.as_str()));
+35
View File
@@ -179,6 +179,20 @@ impl RemoteTargetRequest {
return Err(s3_error!(InvalidRequest, "credentials.secretKey is required")); 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 { Ok(BucketTarget {
source_bucket: self.source_bucket, source_bucket: self.source_bucket,
endpoint: self.endpoint, endpoint: self.endpoint,
@@ -1204,6 +1218,27 @@ mod tests {
assert!(err.to_string().contains("credentials.secretKey is required")); 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] #[test]
fn remote_target_request_converts_to_bucket_target() { fn remote_target_request_converts_to_bucket_target() {
let target = serde_json::from_value::<RemoteTargetRequest>(valid_remote_target_request()) let target = serde_json::from_value::<RemoteTargetRequest>(valid_remote_target_request())
+32 -1
View File
@@ -35,7 +35,7 @@ use super::storage_api::bucket_usecase::bucket::{
policy_sys::PolicySys, policy_sys::PolicySys,
replication::{ replication::{
ReplicationTargetValidationError, replication_target_arns, should_remove_replication_target, ReplicationTargetValidationError, replication_target_arns, should_remove_replication_target,
validate_replication_config_target_arns, unsupported_replication_config_field, validate_replication_config_target_arns,
}, },
target::{BucketTargetType, BucketTargets}, target::{BucketTargetType, BucketTargets},
utils::serialize, utils::serialize,
@@ -556,6 +556,16 @@ fn validate_replication_config_targets(targets: &BucketTargets, config: &Replica
} }
} }
fn validate_replication_config_capabilities(config: &ReplicationConfiguration) -> S3Result<()> {
if let Some(field) = unsupported_replication_config_field(config) {
return Err(S3Error::with_message(
S3ErrorCode::InvalidRequest,
format!("replication field {field} is not supported by this RustFS version"),
));
}
Ok(())
}
async fn validate_bucket_replication_update(bucket: &str, config: &ReplicationConfiguration) -> S3Result<()> { async fn validate_bucket_replication_update(bucket: &str, config: &ReplicationConfiguration) -> S3Result<()> {
if !BucketVersioningSys::enabled(bucket).await { if !BucketVersioningSys::enabled(bucket).await {
return Err(s3_error!( return Err(s3_error!(
@@ -564,6 +574,8 @@ async fn validate_bucket_replication_update(bucket: &str, config: &ReplicationCo
)); ));
} }
validate_replication_config_capabilities(config)?;
let targets = metadata_sys::get_bucket_targets_config(bucket) let targets = metadata_sys::get_bucket_targets_config(bucket)
.await .await
.map_err(|err| match err { .map_err(|err| match err {
@@ -3075,6 +3087,25 @@ mod tests {
validate_replication_config_targets(&targets, &config).expect("disabled rules should not require live targets"); validate_replication_config_targets(&targets, &config).expect("disabled rules should not require live targets");
} }
#[test]
fn validate_replication_config_capabilities_names_unsupported_field() {
let mut rule = replication_rule_for_target("arn:rustfs:replication:us-east-1:target:bucket");
rule.destination.encryption_configuration = Some(s3s::dto::EncryptionConfiguration::default());
let config = ReplicationConfiguration {
role: String::new(),
rules: vec![rule],
};
let err = validate_replication_config_capabilities(&config)
.expect_err("destination encryption must be rejected until the execution path supports it");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
assert!(
err.to_string()
.contains("Destination.EncryptionConfiguration is not supported")
);
}
#[test] #[test]
fn remove_replication_targets_from_config_targets_only_removes_referenced_replication_targets() { fn remove_replication_targets_from_config_targets_only_removes_referenced_replication_targets() {
let removed_arn = "arn:rustfs:replication:us-east-1:removed:bucket"; let removed_arn = "arn:rustfs:replication:us-east-1:removed:bucket";
+50 -76
View File
@@ -44,8 +44,8 @@ use super::storage_api::object_usecase::bucket::{
REPLICATE_INCOMING_DELETE, ReplicationStatusType, VersionPurgeStatusType, check_replicate_delete, REPLICATE_INCOMING_DELETE, ReplicationStatusType, VersionPurgeStatusType, check_replicate_delete,
delete_replication_state_from_config, delete_replication_version_id, deleted_object_has_pending_replication_delete, delete_replication_state_from_config, delete_replication_version_id, deleted_object_has_pending_replication_delete,
must_replicate_object, schedule_object_replication, schedule_replication_delete, set_deleted_object_replication_state, must_replicate_object, schedule_object_replication, schedule_replication_delete, set_deleted_object_replication_state,
set_object_to_delete_version_purge_status, should_schedule_delete_replication, set_object_to_delete_version_purge_status, should_use_existing_delete_replication_info,
should_use_existing_delete_replication_info, should_use_existing_delete_replication_source, should_use_existing_delete_replication_source,
}, },
tagging::decode_tags, tagging::decode_tags,
validate_restore_request, validate_restore_request,
@@ -6705,7 +6705,7 @@ impl DefaultObjectUsecase {
// the same early, advisory rejection as before. // the same early, advisory rejection as before.
let store_ref = &store; let store_ref = &store;
let bucket_ref = bucket.as_str(); let bucket_ref = bucket.as_str();
let admitted_deletes: Vec<AdmittedDelete> = let admitted_deletes: Vec<Result<AdmittedDelete, ApiError>> =
futures::stream::iter(prepared_deletes.into_iter().map(|prepared| async move { futures::stream::iter(prepared_deletes.into_iter().map(|prepared| async move {
let PreparedDelete { let PreparedDelete {
idx, idx,
@@ -6730,7 +6730,7 @@ impl DefaultObjectUsecase {
&& let Some(block_reason) = check_object_lock_for_deletion(bucket_ref, &goi, bypass_governance).await && let Some(block_reason) = check_object_lock_for_deletion(bucket_ref, &goi, bypass_governance).await
{ {
let blocked_key = object.object_name.clone(); let blocked_key = object.object_name.clone();
return AdmittedDelete { return Ok(AdmittedDelete {
idx, idx,
object, object,
size: 0, size: 0,
@@ -6741,15 +6741,11 @@ impl DefaultObjectUsecase {
message: Some(block_reason.error_message()), message: Some(block_reason.error_message()),
version_id, version_id,
}), }),
}; });
} }
let size = goi.size; let size = goi.size;
if is_dir_object(&object.object_name) && object.version_id.is_none() {
object.version_id = Some(Uuid::nil());
}
if replicate_deletes { if replicate_deletes {
let dsc = check_replicate_delete( let dsc = check_replicate_delete(
bucket_ref, bucket_ref,
@@ -6762,7 +6758,8 @@ impl DefaultObjectUsecase {
&opts, &opts,
gerr.clone(), gerr.clone(),
) )
.await; .await
.map_err(ApiError::from)?;
if dsc.replicate_any() { if dsc.replicate_any() {
if object.version_id.is_some() { if object.version_id.is_some() {
set_object_to_delete_version_purge_status(&mut object, VersionPurgeStatusType::Pending); set_object_to_delete_version_purge_status(&mut object, VersionPurgeStatusType::Pending);
@@ -6774,18 +6771,23 @@ impl DefaultObjectUsecase {
} }
} }
if is_dir_object(&object.object_name) && object.version_id.is_none() {
object.version_id = Some(Uuid::nil());
}
let existing = (!skip_stat && gerr.is_none()).then_some(goi); let existing = (!skip_stat && gerr.is_none()).then_some(goi);
AdmittedDelete { Ok(AdmittedDelete {
idx, idx,
object, object,
size, size,
existing, existing,
blocked: None, blocked: None,
} })
})) }))
.buffered(DELETE_OBJECTS_PRE_STAT_CONCURRENCY) .buffered(DELETE_OBJECTS_PRE_STAT_CONCURRENCY)
.collect() .collect()
.await; .await;
let admitted_deletes: Vec<AdmittedDelete> = admitted_deletes.into_iter().collect::<Result<_, ApiError>>()?;
// Phase 3 (serial): apply outcomes in the original request order so // Phase 3 (serial): apply outcomes in the original request order so
// per-key success/failure reporting is unchanged. // per-key success/failure reporting is unchanged.
@@ -7103,6 +7105,28 @@ impl DefaultObjectUsecase {
} }
}; };
let delete_replication_state = if !replica && !force_delete && (opts.versioned || opts.version_suspended) {
let fallback_source;
let source = if let Some(source) = existing_object_info.as_ref() {
source
} else {
fallback_source = ObjectInfo {
name: key.clone(),
..Default::default()
};
&fallback_source
};
match metadata_sys::get_replication_config(&bucket).await {
Ok((config, _)) => {
delete_replication_state_from_config(&config, source, version_id_clone.as_ref().and(source.version_id), false)
}
Err(StorageError::ConfigNotFound) => None,
Err(err) => return Err(ApiError::from(err).into()),
}
} else {
None
};
let cache_adapter = self.object_data_cache(); let cache_adapter = self.object_data_cache();
// A force (delete_prefix) delete removes every object under `key` as a // A force (delete_prefix) delete removes every object under `key` as a
// prefix, so invalidating only the exact key would strand every cached // prefix, so invalidating only the exact key would strand every cached
@@ -7198,7 +7222,7 @@ impl DefaultObjectUsecase {
let schedule_delete_replication = if opts.replication_request && replica { let schedule_delete_replication = if opts.replication_request && replica {
should_schedule_replica_delete_replication(&bucket, replication_state_source, delete_replication_version_id).await should_schedule_replica_delete_replication(&bucket, replication_state_source, delete_replication_version_id).await
} else { } else {
should_schedule_delete_replication(&opts, deleted_object_source, deleted_delete_marker_version) delete_replication_state.is_some()
}; };
if schedule_delete_replication { if schedule_delete_replication {
@@ -7220,8 +7244,12 @@ impl DefaultObjectUsecase {
replication_state: None, replication_state: None,
..Default::default() ..Default::default()
}; };
set_deleted_object_replication_state(&mut deleted_object, &replication_state_source.replication_state()); if let Some(state) = delete_replication_state.as_ref() {
enrich_delete_replication_state_if_needed(&bucket, &mut deleted_object, replication_state_source).await; set_deleted_object_replication_state(&mut deleted_object, state);
} else {
set_deleted_object_replication_state(&mut deleted_object, &replication_state_source.replication_state());
enrich_delete_replication_state_if_needed(&bucket, &mut deleted_object, replication_state_source).await;
}
schedule_replication_delete(deleted_object, bucket.clone(), REPLICATE_INCOMING_DELETE.to_string()).await; schedule_replication_delete(deleted_object, bucket.clone(), REPLICATE_INCOMING_DELETE.to_string()).await;
} }
@@ -8364,9 +8392,10 @@ mod tests {
use super::*; use super::*;
use http::{Extensions, HeaderMap, HeaderName, HeaderValue, Method, Uri}; use http::{Extensions, HeaderMap, HeaderName, HeaderValue, Method, Uri};
use s3s::dto::{ use s3s::dto::{
Delete, DeleteMarkerReplication, DeleteMarkerReplicationStatus, Destination, ExistingObjectReplication, Delete, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication, DeleteReplicationStatus, Destination,
ExistingObjectReplicationStatus, ObjectIdentifier, ReplicaModifications, ReplicaModificationsStatus, ExistingObjectReplication, ExistingObjectReplicationStatus, ObjectIdentifier, ReplicaModifications,
ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus, RestoreRequest, SourceSelectionCriteria, ReplicaModificationsStatus, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus, RestoreRequest,
SourceSelectionCriteria,
}; };
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
@@ -13374,63 +13403,6 @@ mod tests {
assert!(!can_skip_delete_objects_pre_stat(false, false, &delete_marker_creating_opts(), false)); assert!(!can_skip_delete_objects_pre_stat(false, false, &delete_marker_creating_opts(), false));
} }
#[test]
fn should_schedule_delete_replication_skips_replica_requests() {
let opts = ObjectOptions {
replication_request: true,
version_id: Some(Uuid::new_v4().to_string()),
..Default::default()
};
let replication_source = ObjectInfo {
delete_marker: true,
replication_status: ReplicationStatusType::Completed,
..Default::default()
};
assert!(
!should_schedule_delete_replication(&opts, &replication_source, true),
"replica delete requests on target sites must not enqueue a second replication delete task"
);
}
#[test]
fn should_schedule_delete_replication_keeps_delete_marker_version_purge_from_source() {
let opts = ObjectOptions {
replication_request: false,
version_id: Some(Uuid::new_v4().to_string()),
..Default::default()
};
let replication_source = ObjectInfo {
delete_marker: true,
replication_status: ReplicationStatusType::Completed,
..Default::default()
};
assert!(
should_schedule_delete_replication(&opts, &replication_source, true),
"source-side delete-marker version purge still needs replication scheduling"
);
}
#[test]
fn should_schedule_delete_replication_keeps_object_version_purge_from_completed_source() {
let opts = ObjectOptions {
replication_request: false,
version_id: Some(Uuid::new_v4().to_string()),
..Default::default()
};
let replication_source = ObjectInfo {
delete_marker: false,
replication_status: ReplicationStatusType::Completed,
..Default::default()
};
assert!(
should_schedule_delete_replication(&opts, &replication_source, false),
"source-side object version purge must still enqueue delete replication after the original PUT completed"
);
}
#[tokio::test] #[tokio::test]
#[ignore = "requires isolated global object layer state"] #[ignore = "requires isolated global object layer state"]
async fn execute_get_object_attributes_returns_internal_error_when_store_uninitialized() { async fn execute_get_object_attributes_returns_internal_error_when_store_uninitialized() {
@@ -13695,7 +13667,9 @@ mod tests {
delete_marker_replication: Some(DeleteMarkerReplication { delete_marker_replication: Some(DeleteMarkerReplication {
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)), status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)),
}), }),
delete_replication: None, delete_replication: Some(DeleteReplication {
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
}),
destination: Destination { destination: Destination {
bucket: arn.clone(), bucket: arn.clone(),
..Default::default() ..Default::default()
+6 -17
View File
@@ -649,8 +649,8 @@ pub(crate) mod bucket {
oi: &crate::storage::storage_api::StorageObjectInfo, oi: &crate::storage::storage_api::StorageObjectInfo,
del_opts: &crate::storage::storage_api::StorageObjectOptions, del_opts: &crate::storage::storage_api::StorageObjectOptions,
gerr: Option<String>, gerr: Option<String>,
) -> ReplicateDecision { ) -> Result<ReplicateDecision, crate::storage::storage_api::StorageError> {
ReplicationObjectBridge::check_delete(bucket, dobj, oi, del_opts, gerr).await ReplicationObjectBridge::check_delete_strict(bucket, dobj, oi, del_opts, gerr).await
} }
pub(crate) fn delete_replication_version_id( pub(crate) fn delete_replication_version_id(
@@ -751,21 +751,6 @@ pub(crate) mod bucket {
replication_contracts::should_remove_replication_target(target_arn, is_replication_service, target_arns) replication_contracts::should_remove_replication_target(target_arn, is_replication_service, target_arns)
} }
pub(crate) fn should_schedule_delete_replication(
opts: &crate::storage::storage_api::StorageObjectOptions,
replication_source: &crate::storage::storage_api::StorageObjectInfo,
deleted_delete_marker_version: bool,
) -> bool {
replication_contracts::should_schedule_delete_replication(replication_contracts::ReplicationDeleteScheduleInput {
replication_request: opts.replication_request,
version_id_requested: opts.version_id.is_some(),
source_delete_marker: replication_source.delete_marker,
source_replication_status: &replication_source.replication_status,
source_version_purge_status: &replication_source.version_purge_status,
deleted_delete_marker_version,
})
}
pub(crate) fn should_use_existing_delete_replication_info( pub(crate) fn should_use_existing_delete_replication_info(
opts: &crate::storage::storage_api::StorageObjectOptions, opts: &crate::storage::storage_api::StorageObjectOptions,
) -> bool { ) -> bool {
@@ -790,6 +775,10 @@ pub(crate) mod bucket {
) -> Result<(), ReplicationTargetValidationError> { ) -> Result<(), ReplicationTargetValidationError> {
replication_contracts::validate_replication_config_target_arns(configured_arns, config) replication_contracts::validate_replication_config_target_arns(configured_arns, config)
} }
pub(crate) fn unsupported_replication_config_field(config: &s3s::dto::ReplicationConfiguration) -> Option<&'static str> {
replication_contracts::unsupported_replication_config_field(config)
}
} }
pub(crate) mod tagging { pub(crate) mod tagging {