diff --git a/crates/e2e_test/src/replication_extension_test.rs b/crates/e2e_test/src/replication_extension_test.rs index 2734295f3..68c8f5b4b 100644 --- a/crates/e2e_test/src/replication_extension_test.rs +++ b/crates/e2e_test/src/replication_extension_test.rs @@ -4235,37 +4235,49 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR "tag rule with disabled delete-marker replication created a marker: {tagged_state:?}" ); - set_bucket_versioning(&source_env, source_bucket, BucketVersioningStatus::Suspended).await?; - set_bucket_versioning(&target_env_a, target_bucket_a, BucketVersioningStatus::Suspended).await?; - let null_put = source_client + // AWS S3 and MinIO both reject suspending versioning on a bucket that + // carries a replication configuration (InvalidBucketState): suspension + // would mint null versions that versioned replication can never converge. + let suspend_err = source_client + .put_bucket_versioning() + .bucket(source_bucket) + .versioning_configuration( + VersioningConfiguration::builder() + .status(BucketVersioningStatus::Suspended) + .build(), + ) + .send() + .await + .expect_err("suspending versioning on a replication source must be rejected"); + assert_eq!( + suspend_err.as_service_error().and_then(|error| error.code()), + Some("InvalidBucketState"), + "suspension on a replication source must fail with InvalidBucketState: {suspend_err:?}" + ); + + // The rejected suspension must leave the versioning + replication state + // fully intact: a fresh matched PUT still replicates with a real version. + let post_reject_put = source_client .put_object() .bucket(source_bucket) - .key("prefix/null.txt") - .body(ByteStream::from_static(b"null version")) + .key("prefix/after-rejected-suspend.txt") + .body(ByteStream::from_static(b"still replicating")) .send() .await?; - assert!(null_put.version_id().is_none(), "suspended source PUT must create a null version"); - wait_for_replication_state(&target_client_a, target_bucket_a, "null version did not replicate", |state| { - state - .iter() - .any(|entry| entry.key == "prefix/null.txt" && entry.version_id == "null" && !entry.delete_marker) - }) - .await?; - let null_delete = source_client - .delete_object() - .bucket(source_bucket) - .key("prefix/null.txt") - .send() - .await?; - assert!( - null_delete.version_id().is_none(), - "suspended source DELETE must create a null delete marker" - ); - wait_for_replication_state(&target_client_a, target_bucket_a, "null delete marker did not replicate", |state| { - state - .iter() - .any(|entry| entry.key == "prefix/null.txt" && entry.version_id == "null" && entry.delete_marker) - }) + let post_reject_version_id = post_reject_put + .version_id() + .ok_or("PUT after rejected suspension omitted version ID")? + .to_string(); + wait_for_replication_state( + &target_client_a, + target_bucket_a, + "replication stopped after rejected versioning suspension", + |state| { + state + .iter() + .any(|entry| entry.key == "prefix/after-rejected-suspend.txt" && entry.version_id == post_reject_version_id) + }, + ) .await?; Ok(()) diff --git a/rustfs/src/app/bucket_usecase.rs b/rustfs/src/app/bucket_usecase.rs index 8f045dbfe..ec8f07414 100644 --- a/rustfs/src/app/bucket_usecase.rs +++ b/rustfs/src/app/bucket_usecase.rs @@ -738,6 +738,22 @@ async fn validate_bucket_versioning_update(bucket: &str, config: &VersioningConf Err(StorageError::ConfigNotFound) => {} Err(err) => return Err(ApiError::from(err).into()), } + // AWS S3 and MinIO both refuse to suspend versioning while a replication + // configuration exists: suspension would start minting null versions that + // the replication engine (versioned by contract) can never converge. + if config.suspended() { + match metadata_sys::get_replication_config(bucket).await { + Ok(_) => { + return Err(S3Error::with_message( + S3ErrorCode::InvalidBucketState, + "A replication configuration is present on this bucket, bucket wide versioning cannot be suspended." + .to_string(), + )); + } + Err(StorageError::ConfigNotFound) => {} + Err(err) => return Err(ApiError::from(err).into()), + } + } Ok(()) }