mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-07 12:35:54 +00:00
feat(tier): inspect legacy transition state safely
This commit is contained in:
@@ -37,6 +37,16 @@ pub mod bucket {
|
||||
}
|
||||
|
||||
pub mod lifecycle {
|
||||
pub mod legacy_transition_state_reconcile {
|
||||
pub use crate::bucket::lifecycle::legacy_transition_state_reconcile::{
|
||||
LegacyTransitionStateCopyRepresentation, LegacyTransitionStateMetadataAlias, LegacyTransitionStateReconcileError,
|
||||
LegacyTransitionStateReconcileOutcome, LegacyTransitionStateReconcileReadiness,
|
||||
LegacyTransitionStateReconcileRequest, LegacyTransitionStateReconcileResponse,
|
||||
LegacyTransitionStateReconcileSelector, LegacyTransitionStateSetRepresentation, LegacyTransitionStateSource,
|
||||
LegacyTransitionStateTarget,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod bucket_lifecycle_audit {
|
||||
pub use crate::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc;
|
||||
}
|
||||
|
||||
@@ -714,6 +714,14 @@ async fn resolve_transition_delete_version_plan(
|
||||
remote_already_missing: false,
|
||||
})
|
||||
}
|
||||
("null", crate::services::tier::warm_backend::TransitionCandidateProbe::SuspendedNullPresent) => {
|
||||
lease.validate_remote_version_id("null")?;
|
||||
Ok(ResolvedTransitionDeleteVersion {
|
||||
version_id_exact: true,
|
||||
verify_missing_after_delete: false,
|
||||
remote_already_missing: false,
|
||||
})
|
||||
}
|
||||
(_, crate::services::tier::warm_backend::TransitionCandidateProbe::Missing) => {
|
||||
Ok(ResolvedTransitionDeleteVersion {
|
||||
version_id_exact: false,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ mod config_boundary;
|
||||
pub mod core;
|
||||
mod durable_namespace;
|
||||
pub mod evaluator;
|
||||
pub mod legacy_transition_state_reconcile;
|
||||
pub mod manual_transition_job;
|
||||
mod metadata_boundary;
|
||||
pub(crate) use metadata_boundary::{LifecycleExpiryConfigs, get_expiry_configs, get_lifecycle_config};
|
||||
|
||||
@@ -830,6 +830,7 @@ impl From<TransitionCandidateProbe> for TransitionOperatorProbe {
|
||||
match value {
|
||||
TransitionCandidateProbe::Missing => Self::Missing,
|
||||
TransitionCandidateProbe::UnversionedPresent => Self::UnversionedPresent,
|
||||
TransitionCandidateProbe::SuspendedNullPresent => Self::VersionedPresent("null".to_string()),
|
||||
TransitionCandidateProbe::VersionedPresent(version_id) => Self::VersionedPresent(version_id),
|
||||
TransitionCandidateProbe::Ambiguous => Self::Ambiguous,
|
||||
TransitionCandidateProbe::Unsupported => Self::Unsupported,
|
||||
@@ -1183,6 +1184,10 @@ async fn recover_unknown_upload_outcome(
|
||||
TransitionCandidateProbe::UnversionedPresent => {
|
||||
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::unversioned()).await
|
||||
}
|
||||
TransitionCandidateProbe::SuspendedNullPresent => {
|
||||
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::versioned("null".to_string()))
|
||||
.await
|
||||
}
|
||||
TransitionCandidateProbe::VersionedPresent(version_id)
|
||||
if Uuid::parse_str(&version_id).is_ok_and(|version_id| version_id.is_nil()) =>
|
||||
{
|
||||
@@ -1512,6 +1517,14 @@ mod tests {
|
||||
|
||||
const BACKEND_FINGERPRINT: [u8; 32] = [7; 32];
|
||||
|
||||
#[test]
|
||||
fn suspended_null_probe_preserves_operator_null_version_semantics() {
|
||||
assert_eq!(
|
||||
TransitionOperatorProbe::from(TransitionCandidateProbe::SuspendedNullPresent),
|
||||
TransitionOperatorProbe::VersionedPresent("null".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemoryTransactionStore {
|
||||
records: HashMap<Uuid, Vec<u8>>,
|
||||
|
||||
@@ -792,6 +792,8 @@ impl WarmBackend for MockWarmBackend {
|
||||
};
|
||||
if stored.remote_version_id.is_empty() {
|
||||
Ok(TransitionCandidateProbe::UnversionedPresent)
|
||||
} else if stored.remote_version_id == "null" {
|
||||
Ok(TransitionCandidateProbe::SuspendedNullPresent)
|
||||
} else {
|
||||
Ok(TransitionCandidateProbe::VersionedPresent(stored.remote_version_id.clone()))
|
||||
}
|
||||
@@ -991,7 +993,7 @@ mod tests {
|
||||
use bytes::Bytes;
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_probe_distinguishes_missing_unversioned_and_versioned_candidates() {
|
||||
async fn mock_probe_distinguishes_all_known_remote_version_states() {
|
||||
let backend = MockWarmBackend::new();
|
||||
|
||||
assert_eq!(
|
||||
@@ -1029,6 +1031,19 @@ mod tests {
|
||||
TransitionCandidateProbe::VersionedPresent(remote_version)
|
||||
);
|
||||
|
||||
backend.set_put_remote_version(Some("null".to_string())).await;
|
||||
backend
|
||||
.put("suspended-null", ReaderImpl::Body(Bytes::new()), 0)
|
||||
.await
|
||||
.expect("put suspended null candidate");
|
||||
assert_eq!(
|
||||
backend
|
||||
.probe_transition_candidate_state("suspended-null")
|
||||
.await
|
||||
.expect("probe suspended null candidate"),
|
||||
TransitionCandidateProbe::SuspendedNullPresent
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
backend
|
||||
.op_log()
|
||||
@@ -1036,7 +1051,7 @@ mod tests {
|
||||
.into_iter()
|
||||
.filter(|op| matches!(op, MockWarmOp::Probe { .. }))
|
||||
.count(),
|
||||
3
|
||||
4
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,8 @@ pub struct WarmBackendGetOpts {
|
||||
pub enum TransitionCandidateProbe {
|
||||
Missing,
|
||||
UnversionedPresent,
|
||||
/// The provider listed the candidate under the exact S3 `null` version.
|
||||
SuspendedNullPresent,
|
||||
VersionedPresent(String),
|
||||
Ambiguous,
|
||||
Unsupported,
|
||||
|
||||
@@ -214,7 +214,8 @@ impl WarmBackendS3 {
|
||||
return Ok(TransitionCandidateProbe::Ambiguous);
|
||||
}
|
||||
if !versions.is_truncated {
|
||||
return classify_transition_candidates(candidates, bucket_versioning);
|
||||
let confirmed_versioning = self.remote_bucket_versioning().await?;
|
||||
return classify_transition_candidates(candidates, bucket_versioning, confirmed_versioning);
|
||||
}
|
||||
|
||||
advance_version_markers(&mut key_marker, &mut version_id_marker, &versions)?;
|
||||
@@ -275,7 +276,8 @@ impl WarmBackendS3 {
|
||||
version_id: matched_version,
|
||||
ambiguous: false,
|
||||
};
|
||||
return classify_transition_candidates(candidates, bucket_versioning);
|
||||
let confirmed_versioning = self.remote_bucket_versioning().await?;
|
||||
return classify_transition_candidates(candidates, bucket_versioning, confirmed_versioning);
|
||||
}
|
||||
advance_version_markers(&mut key_marker, &mut version_id_marker, &versions)?;
|
||||
}
|
||||
@@ -310,9 +312,15 @@ fn transition_candidate_metadata_matches(
|
||||
|
||||
fn classify_transition_candidates(
|
||||
candidates: TransitionCandidateVersions,
|
||||
bucket_versioning: RemoteBucketVersioning,
|
||||
initial_versioning: RemoteBucketVersioning,
|
||||
confirmed_versioning: RemoteBucketVersioning,
|
||||
) -> Result<TransitionCandidateProbe, std::io::Error> {
|
||||
let probe = candidates.classify(bucket_versioning);
|
||||
// GetBucketVersioning and ListObjectVersions are separate requests. An
|
||||
// observed state change makes the combined proof unsafe to persist.
|
||||
if initial_versioning != confirmed_versioning {
|
||||
return Ok(TransitionCandidateProbe::Ambiguous);
|
||||
}
|
||||
let probe = candidates.classify(initial_versioning);
|
||||
if let TransitionCandidateProbe::VersionedPresent(version_id) = &probe {
|
||||
validate_remote_version_id(version_id)?;
|
||||
}
|
||||
@@ -366,8 +374,19 @@ impl TransitionCandidateVersions {
|
||||
};
|
||||
|
||||
match bucket_versioning {
|
||||
RemoteBucketVersioning::Disabled => TransitionCandidateProbe::UnversionedPresent,
|
||||
RemoteBucketVersioning::Suspended if version_id == "null" => TransitionCandidateProbe::VersionedPresent(version_id),
|
||||
// A never-versioned S3 object may be listed with either an empty
|
||||
// version or the provider's `null` sentinel. Any opaque version is
|
||||
// inconsistent with a disabled bucket and must remain ambiguous.
|
||||
RemoteBucketVersioning::Disabled if version_id.is_empty() || version_id == "null" => {
|
||||
TransitionCandidateProbe::UnversionedPresent
|
||||
}
|
||||
RemoteBucketVersioning::Disabled => TransitionCandidateProbe::Ambiguous,
|
||||
// A `null` version survives a later transition back to Enabled, so
|
||||
// the listed identifier, not only the current bucket status, binds
|
||||
// suspended-null request routing.
|
||||
RemoteBucketVersioning::Suspended | RemoteBucketVersioning::Enabled if version_id == "null" => {
|
||||
TransitionCandidateProbe::SuspendedNullPresent
|
||||
}
|
||||
RemoteBucketVersioning::Suspended | RemoteBucketVersioning::Enabled if !version_id.is_empty() => {
|
||||
TransitionCandidateProbe::VersionedPresent(version_id)
|
||||
}
|
||||
@@ -426,7 +445,8 @@ mod tests {
|
||||
for page in pages {
|
||||
candidates.extend("archive/object", page);
|
||||
}
|
||||
candidates.classify(bucket_versioning)
|
||||
classify_transition_candidates(candidates, bucket_versioning, bucket_versioning)
|
||||
.expect("fixture version identifiers are valid")
|
||||
}
|
||||
|
||||
fn candidate_identity() -> TransitionCandidateIdentity {
|
||||
@@ -505,7 +525,35 @@ mod tests {
|
||||
RemoteBucketVersioning::Suspended,
|
||||
&[list_versions(&[("archive/object", "null")], &[], false)],
|
||||
),
|
||||
TransitionCandidateProbe::VersionedPresent("null".to_string())
|
||||
TransitionCandidateProbe::SuspendedNullPresent
|
||||
);
|
||||
assert_eq!(
|
||||
classify_pages(
|
||||
RemoteBucketVersioning::Enabled,
|
||||
&[list_versions(&[("archive/object", "null")], &[], false)],
|
||||
),
|
||||
TransitionCandidateProbe::SuspendedNullPresent
|
||||
);
|
||||
assert_eq!(
|
||||
classify_pages(
|
||||
RemoteBucketVersioning::Suspended,
|
||||
&[list_versions(&[("archive/object", "version-a")], &[], false)],
|
||||
),
|
||||
TransitionCandidateProbe::VersionedPresent("version-a".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
classify_pages(
|
||||
RemoteBucketVersioning::Disabled,
|
||||
&[list_versions(&[("archive/object", "null")], &[], false)],
|
||||
),
|
||||
TransitionCandidateProbe::UnversionedPresent
|
||||
);
|
||||
assert_eq!(
|
||||
classify_pages(
|
||||
RemoteBucketVersioning::Disabled,
|
||||
&[list_versions(&[("archive/object", "unexpected-version")], &[], false)],
|
||||
),
|
||||
TransitionCandidateProbe::Ambiguous
|
||||
);
|
||||
assert_eq!(
|
||||
classify_pages(RemoteBucketVersioning::Enabled, &[list_versions(&[("archive/object", "")], &[], false)],),
|
||||
@@ -573,11 +621,23 @@ mod tests {
|
||||
let mut candidates = TransitionCandidateVersions::default();
|
||||
candidates.extend("archive/object", &list_versions(&[("archive/object", "version\ninjection")], &[], false));
|
||||
|
||||
let err = classify_transition_candidates(candidates, RemoteBucketVersioning::Enabled)
|
||||
let err = classify_transition_candidates(candidates, RemoteBucketVersioning::Enabled, RemoteBucketVersioning::Enabled)
|
||||
.expect_err("control characters in listed version IDs must fail closed");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_candidate_probe_rejects_bucket_versioning_drift() {
|
||||
let mut candidates = TransitionCandidateVersions::default();
|
||||
candidates.extend("archive/object", &list_versions(&[("archive/object", "null")], &[], false));
|
||||
|
||||
assert_eq!(
|
||||
classify_transition_candidates(candidates, RemoteBucketVersioning::Suspended, RemoteBucketVersioning::Enabled,)
|
||||
.expect("observed versioning drift should be a safe probe result"),
|
||||
TransitionCandidateProbe::Ambiguous
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_bucket_versioning_status_parser_fails_closed() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -3877,6 +3877,31 @@ pub struct SetDisks {
|
||||
>,
|
||||
}
|
||||
|
||||
/// Read every available `xl.meta` copy for an exact logical object after
|
||||
/// enforcing this set's metadata read quorum. Callers use the individual
|
||||
/// buffers only for diagnostics and immutable reconciliation digests; a
|
||||
/// missing disk remains visible as `None` and must never be mistaken for an
|
||||
/// absent metadata key.
|
||||
pub(crate) async fn read_legacy_transition_state_metadata_copies(
|
||||
set: &SetDisks,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
) -> Result<Vec<Option<Vec<u8>>>> {
|
||||
let disk_object = rustfs_utils::path::encode_dir_object(object);
|
||||
let disks = set.get_disks_internal().await;
|
||||
if disks.is_empty() {
|
||||
return Err(to_object_err(StorageError::ErasureReadQuorum, vec![bucket, object]));
|
||||
}
|
||||
|
||||
let read_quorum = disks.len().div_ceil(2).max(1);
|
||||
let (copies, errs) = SetDisks::read_all_raw_file_info(&disks, bucket, disk_object.as_str(), false).await;
|
||||
if let Some(err) = reduce_read_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, read_quorum) {
|
||||
return Err(to_object_err(err.into(), vec![bucket, object]));
|
||||
}
|
||||
|
||||
Ok(copies.into_iter().map(|copy| copy.map(|copy| copy.buf)).collect())
|
||||
}
|
||||
|
||||
// DistributedLock sends the raw ObjectKey to its clients; LockRegistry clones
|
||||
// each endpoint's canonical Arc, so an exact Arc set identifies the lock domain.
|
||||
pub(crate) fn same_distributed_lock_domain(left: &[Arc<dyn LockClient>], right: &[Arc<dyn LockClient>]) -> bool {
|
||||
|
||||
Reference in New Issue
Block a user