// 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. //! Reliability tests driven by in-process fault injection (see `chaos.rs`): //! degraded reads/writes with an offline disk, bitrot read-through, and //! fresh-disk replacement heal after a SIGKILL restart. //! //! All tests use a single-node 4-disk topology (default erasure coding for //! 4 drives is 2 data + 2 parity) and verify object content via sha256 //! manifests recorded at write time. #[cfg(test)] mod tests { use crate::chaos::{DiskFaultHarness, VersionShardCensus, signed_admin_post}; use crate::common::init_logging; use aws_sdk_s3::Client; use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration}; use serial_test::serial; use sha2::{Digest, Sha256}; use std::collections::HashSet; use std::error::Error; use tokio::time::{Duration, Instant, interval, timeout}; use tracing::info; const GET_TIMEOUT: Duration = Duration::from_secs(60); const PUT_TIMEOUT: Duration = Duration::from_secs(60); fn sha256_hex(data: &[u8]) -> String { let digest = Sha256::digest(data); digest.iter().map(|byte| format!("{byte:02x}")).collect() } /// Deterministic pseudo-random payload so tests stay reproducible. fn payload(len: usize, seed: u8) -> Vec { (0..len) .map(|i| (i as u64).wrapping_mul(2654435761).wrapping_add(seed as u64) as u8) .collect() } async fn put_and_record( client: &Client, bucket: &str, key: &str, body: Vec, manifest: &mut Vec<(String, String)>, ) -> Result<(), Box> { let digest = sha256_hex(&body); timeout( PUT_TIMEOUT, client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from(body)) .send(), ) .await .map_err(|_| format!("PUT {key} timed out"))??; manifest.push((key.to_string(), digest)); Ok(()) } async fn multipart_put_and_record( client: &Client, bucket: &str, key: &str, parts: Vec>, manifest: &mut Vec<(String, String)>, ) -> Result<(), Box> { let full_body: Vec = parts.iter().flatten().copied().collect(); let digest = sha256_hex(&full_body); let create = client.create_multipart_upload().bucket(bucket).key(key).send().await?; let upload_id = create.upload_id().ok_or("missing upload id")?.to_string(); let mut completed_parts = Vec::with_capacity(parts.len()); for (index, part_body) in parts.into_iter().enumerate() { let part_number = (index + 1) as i32; let uploaded = timeout( PUT_TIMEOUT, client .upload_part() .bucket(bucket) .key(key) .upload_id(&upload_id) .part_number(part_number) .body(ByteStream::from(part_body)) .send(), ) .await .map_err(|_| format!("upload_part {part_number} for {key} timed out"))??; completed_parts.push( CompletedPart::builder() .part_number(part_number) .e_tag(uploaded.e_tag().ok_or("missing part etag")?) .build(), ); } client .complete_multipart_upload() .bucket(bucket) .key(key) .upload_id(&upload_id) .multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build()) .send() .await?; manifest.push((key.to_string(), digest)); Ok(()) } async fn verify_manifest( client: &Client, bucket: &str, manifest: &[(String, String)], phase: &str, ) -> Result<(), Box> { for (key, expected_sha256) in manifest { let response = timeout(GET_TIMEOUT, client.get_object().bucket(bucket).key(key).send()) .await .map_err(|_| format!("GET {key} timed out during {phase}"))? .map_err(|err| format!("GET {key} failed during {phase}: {err}"))?; let body = response .body .collect() .await .map_err(|err| format!("GET {key} body collect failed during {phase}: {err}"))? .into_bytes(); let actual_sha256 = sha256_hex(&body); if actual_sha256 != *expected_sha256 { return Err(format!( "sha256 mismatch for {key} during {phase}: expected {expected_sha256}, got {actual_sha256} ({} bytes)", body.len() ) .into()); } } info!("Verified {} objects during {}", manifest.len(), phase); Ok(()) } /// One disk goes offline at runtime: all previously written objects (from /// inline-small to multipart-sized) must stay readable with intact /// content, degraded writes must succeed, and everything must still /// verify after the disk returns. #[tokio::test] #[serial] async fn test_degraded_read_write_with_one_disk_offline() -> Result<(), Box> { init_logging(); info!("Reliability: degraded read/write with one of four disks offline"); let mut harness = DiskFaultHarness::new(4).await?; harness.start_server().await?; let client = harness.env.create_s3_client(); let bucket = "reliability-degraded-rw"; client.create_bucket().bucket(bucket).send().await?; let mut manifest: Vec<(String, String)> = Vec::new(); put_and_record(&client, bucket, "degraded/small.bin", payload(4 * 1024, 1), &mut manifest).await?; put_and_record(&client, bucket, "degraded/medium.bin", payload(1024 * 1024, 2), &mut manifest).await?; put_and_record(&client, bucket, "degraded/big.bin", payload(3 * 1024 * 1024, 3), &mut manifest).await?; multipart_put_and_record( &client, bucket, "degraded/multipart.bin", vec![payload(5 * 1024 * 1024, 4), payload(1024 * 1024, 5)], &mut manifest, ) .await?; verify_manifest(&client, bucket, &manifest, "baseline with all disks online").await?; harness.take_disk_offline(0)?; verify_manifest(&client, bucket, &manifest, "degraded read with disk0 offline").await?; // Degraded writes: 3 of 4 disks still satisfy the write quorum for // an EC 2+2 set. put_and_record( &client, bucket, "degraded/written-while-offline.bin", payload(1024 * 1024, 9), &mut manifest, ) .await?; verify_manifest(&client, bucket, &manifest, "read-back of degraded write").await?; harness.bring_disk_online(0)?; verify_manifest(&client, bucket, &manifest, "after disk0 came back online").await?; Ok(()) } /// Silent bitrot in a single erasure shard must never surface corrupted /// bytes to a reader: per-shard bitrot checksums reject the bad shard and /// the object is reconstructed from the remaining shards. #[tokio::test] #[serial] async fn test_bitrot_corrupted_shard_read_returns_correct_data() -> Result<(), Box> { init_logging(); info!("Reliability: GET must read through a bitrot-corrupted shard"); let mut harness = DiskFaultHarness::new(4).await?; harness.start_server().await?; let client = harness.env.create_s3_client(); let bucket = "reliability-bitrot"; client.create_bucket().bucket(bucket).send().await?; let key_a = "bitrot/object-a.bin"; let key_b = "bitrot/object-b.bin"; let mut manifest: Vec<(String, String)> = Vec::new(); // 2 MiB objects are far above the 128 KiB inline threshold, so every // disk holds a real part.1 shard file to corrupt. put_and_record(&client, bucket, key_a, payload(2 * 1024 * 1024, 21), &mut manifest).await?; put_and_record(&client, bucket, key_b, payload(2 * 1024 * 1024, 22), &mut manifest).await?; verify_manifest(&client, bucket, &manifest, "baseline before corruption").await?; // Corrupt two shards per object (the maximum an EC 2+2 set can lose) // on different disk pairs. Which disks hold data vs parity depends on // the per-object distribution, so corrupting a pair makes it very // likely that at least one data shard is hit; either way the read // must return intact content reconstructed from the clean shards. harness.corrupt_object_shard(0, bucket, key_a)?; harness.corrupt_object_shard(1, bucket, key_a)?; harness.corrupt_object_shard(2, bucket, key_b)?; harness.corrupt_object_shard(3, bucket, key_b)?; verify_manifest(&client, bucket, &manifest, "first read after shard corruption").await?; // A second pass ensures repeated reads stay correct as well. verify_manifest(&client, bucket, &manifest, "second read after shard corruption").await?; Ok(()) } /// Fresh-disk replacement: SIGKILL the server, swap one disk for an empty /// directory, restart with the same volumes/port, trigger an admin deep /// heal, and require the replaced disk to be rebuilt and all content to /// verify against the sha256 manifest. #[tokio::test] #[serial] async fn test_fresh_disk_replacement_heals_after_sigkill_restart() -> Result<(), Box> { init_logging(); info!("Reliability: fresh-disk replacement heals after SIGKILL restart"); let mut harness = DiskFaultHarness::new(4).await?; harness.start_server().await?; let client = harness.env.create_s3_client(); let bucket = "reliability-fresh-disk"; client.create_bucket().bucket(bucket).send().await?; let mut manifest: Vec<(String, String)> = Vec::new(); put_and_record(&client, bucket, "heal/tiny.bin", payload(4 * 1024, 31), &mut manifest).await?; put_and_record(&client, bucket, "heal/small.bin", payload(256 * 1024, 32), &mut manifest).await?; put_and_record(&client, bucket, "heal/medium.bin", payload(1024 * 1024, 33), &mut manifest).await?; put_and_record(&client, bucket, "heal/nested/large.bin", payload(2 * 1024 * 1024, 34), &mut manifest).await?; verify_manifest(&client, bucket, &manifest, "baseline before disk replacement").await?; let manifest_keys = manifest.iter().map(|(key, _)| key.clone()).collect::>(); let target_manifest: Vec<(String, VersionShardCensus)> = manifest_keys .iter() .map(|key| { let census = harness.census_object_version(0, bucket, key, None)?; if !census.is_complete() { return Err(format!("disk 0 has incomplete physical census for {key}: {census:?}").into()); } Ok((key.clone(), census)) }) .collect::>>()?; harness.kill_server(); harness.replace_disk_with_empty(0)?; harness.restart_server().await?; let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#; let heal_url = format!("{}/rustfs/admin/v3/heal/{}?forceStart=true", harness.env.url, bucket); signed_admin_post(&heal_url, Some(heal_body), &harness.env.access_key, &harness.env.secret_key).await?; let client = harness.env.create_s3_client(); let mut remaining: HashSet = manifest_keys.iter().cloned().collect(); let heal_timeout_secs = std::env::var("RUSTFS_RELIABILITY_HEAL_TIMEOUT_SECS") .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(120); let deadline = Instant::now() + Duration::from_secs(heal_timeout_secs); let mut retry = interval(Duration::from_secs(1)); loop { remaining.retain(|key| { let expected = target_manifest .iter() .find(|(manifest_key, _)| manifest_key == key) .map(|(_, manifest)| manifest) .expect("every key has a physical manifest"); harness .census_object_version(0, bucket, key, None) .map(|census| !census.matches_manifest(expected)) .unwrap_or(true) }); if remaining.is_empty() { verify_manifest(&client, bucket, &manifest, "after fresh-disk heal completed").await?; return Ok(()); } if Instant::now() >= deadline { break; } retry.tick().await; } Err(format!("fresh-disk heal did not rebuild {remaining:?} on the replaced disk within {heal_timeout_secs}s").into()) } #[tokio::test] #[serial] async fn test_versioned_shard_census_selects_each_version_data_dir() -> Result<(), Box> { init_logging(); info!("Reliability: physical shard census selects the requested object version"); let mut harness = DiskFaultHarness::new(4).await?; harness.start_server().await?; let client = harness.env.create_s3_client(); let bucket = "reliability-versioned-census"; let key = "versions/large.bin"; client.create_bucket().bucket(bucket).send().await?; client .put_bucket_versioning() .bucket(bucket) .versioning_configuration( VersioningConfiguration::builder() .status(BucketVersioningStatus::Enabled) .build(), ) .send() .await?; let first_inline = client .put_object() .bucket(bucket) .key("versions/inline.bin") .body(ByteStream::from(payload(8 * 1024, 40))) .send() .await?; let first_inline_version = first_inline .version_id() .ok_or("first inline PUT did not return a version ID")?; let second_inline = client .put_object() .bucket(bucket) .key("versions/inline.bin") .body(ByteStream::from(payload(8 * 1024, 41))) .send() .await?; let second_inline_version = second_inline .version_id() .ok_or("second inline PUT did not return a version ID")?; let first = client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from(payload(128 * 1024, 41))) .send() .await?; let first_version = first.version_id().ok_or("first PUT did not return a version ID")?; let second = client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from(payload(3 * 1024 * 1024, 42))) .send() .await?; let second_version = second.version_id().ok_or("second PUT did not return a version ID")?; let delete = client.delete_object().bucket(bucket).key(key).send().await?; let delete_version = delete.version_id().ok_or("delete marker did not return a version ID")?; let first_inline_census = harness.census_object_version(0, bucket, "versions/inline.bin", Some(first_inline_version))?; let second_inline_census = harness.census_object_version(0, bucket, "versions/inline.bin", Some(second_inline_version))?; let first_census = harness.census_object_version(0, bucket, key, Some(first_version))?; let first_other_disk_census = harness.census_object_version(1, bucket, key, Some(first_version))?; let second_census = harness.census_object_version(0, bucket, key, Some(second_version))?; let delete_census = harness.census_object_version(0, bucket, key, Some(delete_version))?; assert!( first_inline_census.is_complete() && second_inline_census.is_complete(), "inline version physical census is incomplete: first={first_inline_census:?} second={second_inline_census:?}" ); assert!( first_inline_census.present_part_fingerprints.is_empty() && second_inline_census.present_part_fingerprints.is_empty(), "inline versions must not select external shard files: first={first_inline_census:?} second={second_inline_census:?}" ); assert!( first_inline_census.inline_data_fingerprint.is_some() && second_inline_census.inline_data_fingerprint.is_some(), "inline versions must fingerprint payload bytes stored in xl.meta" ); assert_ne!( first_inline_census.inline_data_fingerprint, second_inline_census.inline_data_fingerprint, "same-size inline versions with different payloads must retain distinct xl.meta fingerprints" ); assert!( first_census.is_complete(), "first version physical census is incomplete: {first_census:?}" ); assert!( second_census.is_complete(), "second version physical census is incomplete: {second_census:?}" ); assert!( first_other_disk_census.is_complete(), "first version physical census on the second disk is incomplete: {first_other_disk_census:?}" ); assert_ne!( first_census.erasure_index, first_other_disk_census.erasure_index, "physical census must preserve each disk's erasure index" ); assert_ne!( first_census.data_dir, second_census.data_dir, "distinct object versions must select distinct physical data directories" ); assert_eq!( first_census.expected_part_numbers, second_census.expected_part_numbers, "same single-part shape should expose the same part numbers" ); let first_part = first_census .present_part_fingerprints .values() .next() .ok_or("first version did not expose a physical part fingerprint")?; let second_part = second_census .present_part_fingerprints .values() .next() .ok_or("second version did not expose a physical part fingerprint")?; assert_ne!( first_part.size, second_part.size, "different shard lengths must retain their physical sizes" ); assert_ne!( first_part.sha256, second_part.sha256, "different shard contents must retain their physical hashes" ); assert!( delete_census.is_complete(), "delete marker physical census is incomplete: {delete_census:?}" ); assert!( delete_census.expected_part_numbers.is_empty(), "delete marker must not declare object shards: {delete_census:?}" ); assert!( delete_census.present_part_fingerprints.is_empty(), "delete marker must not select stale object shards: {delete_census:?}" ); Ok(()) } }