feat(tier): inspect legacy transition state safely

This commit is contained in:
cxymds
2026-09-04 20:45:24 +08:00
parent c93a9d0d27
commit 748bcd83fd
13 changed files with 1448 additions and 16 deletions
+10
View File
@@ -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>>,
+17 -2
View File
@@ -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!(
+25
View File
@@ -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 {
+241 -5
View File
@@ -15,12 +15,13 @@
use crate::admin::auth::authorize_admin_request;
use crate::admin::router::{AdminOperation, Operation, S3Router};
use crate::admin::runtime_sources::object_store_from_extensions;
use crate::admin::storage_api::bucket::is_reserved_or_invalid_bucket;
use crate::admin::storage_api::bucket::{is_reserved_or_invalid_bucket, utils::is_valid_object_prefix};
use crate::admin::storage_api::error::StorageError;
use crate::admin::storage_api::lifecycle::{
ManualTransitionCancelCheck, ManualTransitionJobRecord, ManualTransitionJobState, ManualTransitionProgressSink,
ManualTransitionQueueSnapshot, ManualTransitionRunOptions, ManualTransitionRunReport, ManualTransitionScopeAdmission,
ManualTransitionScopeAdmissionClaim, TransitionOperatorDeleteResult, TransitionOperatorError,
LegacyTransitionStateReconcileError, LegacyTransitionStateReconcileRequest, LegacyTransitionStateReconcileResponse,
LegacyTransitionStateReconcileSelector, ManualTransitionCancelCheck, ManualTransitionJobRecord, ManualTransitionJobState,
ManualTransitionProgressSink, ManualTransitionQueueSnapshot, ManualTransitionRunOptions, ManualTransitionRunReport,
ManualTransitionScopeAdmission, ManualTransitionScopeAdmissionClaim, TransitionOperatorDeleteResult, TransitionOperatorError,
claim_manual_transition_scope_admission, delete_manual_transition_scope_admission_if_current,
delete_transition_candidate_for_operator, enqueue_transition_for_existing_objects_scoped,
finalize_missing_transition_transaction_for_operator, inspect_transition_transaction_for_operator,
@@ -230,6 +231,16 @@ pub fn register_ilm_transition_route(r: &mut S3Router<AdminOperation>) -> std::i
format!("{ADMIN_PREFIX}/v3/ilm/transition/reconcile/{{transaction_id}}").as_str(),
AdminOperation(&TransitionReconcileApplyHandler {}),
)?;
r.insert(
Method::GET,
format!("{ADMIN_PREFIX}/v3/ilm/transition/state/reconcile").as_str(),
AdminOperation(&LegacyTransitionStateReconcileInspectHandler {}),
)?;
r.insert(
Method::POST,
format!("{ADMIN_PREFIX}/v3/ilm/transition/state/reconcile").as_str(),
AdminOperation(&LegacyTransitionStateReconcileApplyHandler {}),
)?;
Ok(())
}
@@ -423,6 +434,92 @@ fn transition_transaction_id_from_params(params: &Params<'_, '_>) -> S3Result<Uu
.map_err(|_| s3_error!(InvalidArgument, "invalid transition transaction id"))
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyTransitionStateReconcileQuery {
bucket: Option<String>,
object: Option<String>,
#[serde(rename = "versionId")]
version_id: Option<String>,
}
fn parse_legacy_transition_state_reconcile_query(query: Option<&str>) -> S3Result<LegacyTransitionStateReconcileSelector> {
let query: LegacyTransitionStateReconcileQuery = serde_urlencoded::from_bytes(query.unwrap_or_default().as_bytes())
.map_err(|_| s3_error!(InvalidArgument, "invalid legacy transition-state reconcile query"))?;
let bucket = query
.bucket
.filter(|bucket| !bucket.is_empty())
.ok_or_else(|| s3_error!(InvalidRequest, "bucket is required"))?;
if is_reserved_or_invalid_bucket(&bucket, false) {
return Err(s3_error!(InvalidBucketName, "invalid bucket name"));
}
let object = query
.object
.filter(|object| !object.is_empty())
.ok_or_else(|| s3_error!(InvalidRequest, "object is required"))?;
if !is_valid_object_prefix(&object) || object.contains('\n') || object.contains('\r') {
return Err(s3_error!(InvalidArgument, "invalid object name"));
}
let version_id = query
.version_id
.filter(|version_id| !version_id.is_empty())
.ok_or_else(|| s3_error!(InvalidRequest, "versionId is required"))?;
let version_id = if version_id == "null" {
version_id
} else {
let parsed = Uuid::parse_str(&version_id).map_err(|_| s3_error!(InvalidArgument, "invalid local versionId"))?;
if parsed.is_nil() {
return Err(s3_error!(InvalidArgument, "invalid local versionId"));
}
parsed.to_string()
};
Ok(LegacyTransitionStateReconcileSelector {
bucket,
object,
version_id,
})
}
fn validate_legacy_transition_state_reconcile_request(
query_selector: &LegacyTransitionStateReconcileSelector,
confirm: bool,
request_selector: &LegacyTransitionStateReconcileSelector,
) -> S3Result<()> {
if !confirm {
return Err(s3_error!(
InvalidRequest,
"legacy transition-state reconciliation requires confirm=true; use GET to inspect without changes"
));
}
if request_selector != query_selector {
return Err(s3_error!(InvalidRequest, "request selector must exactly match the query selector"));
}
Ok(())
}
fn map_legacy_transition_state_reconcile_error(err: LegacyTransitionStateReconcileError) -> S3Error {
match err {
LegacyTransitionStateReconcileError::InvalidSelector(_) | LegacyTransitionStateReconcileError::InvalidRequest(_) => {
s3_error!(InvalidRequest, "invalid legacy transition-state reconciliation request")
}
LegacyTransitionStateReconcileError::StaleExpectedTuple(_) | LegacyTransitionStateReconcileError::Corrupt(_) => {
s3_error!(OperationAborted, "legacy transition-state reconciliation metadata is stale or corrupt")
}
LegacyTransitionStateReconcileError::WriteFenceUnavailable(_) => {
s3_error!(
OperationAborted,
"legacy transition-state reconciliation could not acquire safe write authority"
)
}
LegacyTransitionStateReconcileError::BackendUnavailable(_) => {
s3_error!(InternalError, "legacy transition-state reconciliation backend is unavailable")
}
}
}
fn map_transition_operator_error(err: TransitionOperatorError) -> S3Error {
match err {
TransitionOperatorError::NotFound => s3_error!(NoSuchKey, "transition transaction not found"),
@@ -1089,6 +1186,52 @@ impl Operation for TransitionReconcileApplyHandler {
}
}
pub struct LegacyTransitionStateReconcileInspectHandler {}
#[async_trait::async_trait]
impl Operation for LegacyTransitionStateReconcileInspectHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
authorize_transition_admin_request(&req, AdminAction::ListTierAction).await?;
let selector = parse_legacy_transition_state_reconcile_query(req.uri.query())?;
let Some(store) = object_store_from_extensions(&req.extensions) else {
return Err(s3_error!(InternalError, "object store is not initialized"));
};
let response: LegacyTransitionStateReconcileResponse = store
.inspect_legacy_transition_state(selector)
.await
.map_err(map_legacy_transition_state_reconcile_error)?;
json_response(StatusCode::OK, &response)
}
}
pub struct LegacyTransitionStateReconcileApplyHandler {}
#[async_trait::async_trait]
impl Operation for LegacyTransitionStateReconcileApplyHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
authorize_transition_admin_request(&req, AdminAction::SetTierAction).await?;
let selector = parse_legacy_transition_state_reconcile_query(req.uri.query())?;
let store = object_store_from_extensions(&req.extensions);
let mut input = req.input;
let body = input
.store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE)
.await
.map_err(|_| s3_error!(InvalidRequest, "legacy transition-state reconciliation body is too large or unreadable"))?;
let request: LegacyTransitionStateReconcileRequest = serde_json::from_slice(&body)
.map_err(|_| s3_error!(InvalidRequest, "legacy transition-state reconciliation request must be valid JSON"))?;
validate_legacy_transition_state_reconcile_request(&selector, request.confirm, &request.selector)?;
let Some(store) = store else {
return Err(s3_error!(InternalError, "object store is not initialized"));
};
let response: LegacyTransitionStateReconcileResponse = store
.reconcile_legacy_transition_state(request)
.await
.map_err(map_legacy_transition_state_reconcile_error)?;
json_response(StatusCode::OK, &response)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1171,12 +1314,87 @@ mod tests {
let apply = src
.split("impl Operation for TransitionReconcileApplyHandler")
.nth(1)
.and_then(|block| block.split("#[cfg(test)]").next())
.and_then(|block| block.split("pub struct LegacyTransitionStateReconcileInspectHandler").next())
.expect("apply handler block");
assert!(apply.contains("AdminAction::SetTierAction"));
assert!(!apply.contains("AdminAction::ListTierAction"));
}
#[test]
fn legacy_transition_state_query_requires_one_exact_selector() {
let version_id = Uuid::new_v4();
let query = format!("bucket=test-bucket&object=logs%2F2026%20report&versionId={version_id}");
let selector = parse_legacy_transition_state_reconcile_query(Some(&query)).expect("exact version selector should parse");
assert_eq!(selector.bucket, "test-bucket");
assert_eq!(selector.object, "logs/2026 report");
assert_eq!(selector.version_id, version_id.to_string());
let unversioned =
parse_legacy_transition_state_reconcile_query(Some("bucket=test-bucket&object=logs%2Fcurrent&versionId=null"))
.expect("explicit null selector should parse");
assert_eq!(unversioned.version_id, "null");
for query in [
None,
Some("bucket=test-bucket&object=key"),
Some("bucket=test-bucket&object=&versionId=null"),
Some("bucket=test-bucket&object=key&versionId="),
Some("bucket=test-bucket&object=key&versionId=not-a-uuid"),
Some("bucket=test-bucket&object=key&versionId=00000000-0000-0000-0000-000000000000"),
Some("bucket=test-bucket&object=bad%0Akey&versionId=null"),
Some("bucket=test-bucket&object=key&versionId=null&prefix=wide"),
Some("bucket=test-bucket&bucket=other-bucket&object=key&versionId=null"),
] {
assert!(
parse_legacy_transition_state_reconcile_query(query).is_err(),
"query should fail closed: {query:?}"
);
}
}
#[test]
fn legacy_transition_state_apply_requires_confirmation_and_matching_selector() {
let selector = LegacyTransitionStateReconcileSelector {
bucket: "test-bucket".to_string(),
object: "key".to_string(),
version_id: "null".to_string(),
};
let different = LegacyTransitionStateReconcileSelector {
object: "other-key".to_string(),
..selector.clone()
};
assert!(validate_legacy_transition_state_reconcile_request(&selector, false, &selector).is_err());
assert!(validate_legacy_transition_state_reconcile_request(&selector, true, &different).is_err());
validate_legacy_transition_state_reconcile_request(&selector, true, &selector)
.expect("confirmed exact selector should pass handler validation");
}
#[test]
fn legacy_transition_state_routes_use_read_and_write_tier_actions() {
let src = include_str!("ilm_transition.rs");
let inspect = src
.split("impl Operation for LegacyTransitionStateReconcileInspectHandler")
.nth(1)
.and_then(|block| {
block
.split("impl Operation for LegacyTransitionStateReconcileApplyHandler")
.next()
})
.expect("legacy inspect handler block");
assert!(inspect.contains("AdminAction::ListTierAction"));
assert!(!inspect.contains("AdminAction::SetTierAction"));
let apply = src
.split("impl Operation for LegacyTransitionStateReconcileApplyHandler")
.nth(1)
.and_then(|block| block.split("#[cfg(test)]").next())
.expect("legacy apply handler block");
assert!(apply.contains("AdminAction::SetTierAction"));
assert!(!apply.contains("AdminAction::ListTierAction"));
assert!(apply.contains("validate_legacy_transition_state_reconcile_request"));
}
#[test]
fn manual_transition_query_defaults_to_bounded_run() {
let (bucket, options, run_mode) =
@@ -1632,6 +1850,24 @@ mod tests {
assert_eq!(cancel_err.message(), Some("authentication required"));
}
#[tokio::test]
async fn legacy_transition_state_handlers_reject_missing_credentials_before_selector_or_body() {
let path = "/rustfs/admin/v3/ilm/transition/state/reconcile?bucket=test-bucket&object=key&versionId=null";
let inspect_err = LegacyTransitionStateReconcileInspectHandler {}
.call(manual_transition_job_request(Method::GET, path), Params::new())
.await
.expect_err("inspect handler must reject unsigned requests");
assert_eq!(inspect_err.code(), &S3ErrorCode::InvalidRequest);
assert_eq!(inspect_err.message(), Some("authentication required"));
let apply_err = LegacyTransitionStateReconcileApplyHandler {}
.call(manual_transition_job_request(Method::POST, path), Params::new())
.await
.expect_err("apply handler must reject unsigned requests");
assert_eq!(apply_err.code(), &S3ErrorCode::InvalidRequest);
assert_eq!(apply_err.message(), Some("authentication required"));
}
#[test]
fn manual_transition_job_handlers_authorize_validate_and_load_store() {
let src = include_str!("ilm_transition.rs");
+16
View File
@@ -520,6 +520,18 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
SET_TIER,
RouteRiskLevel::High,
),
admin(
HttpMethod::Get,
"/rustfs/admin/v3/ilm/transition/state/reconcile",
LIST_TIER,
RouteRiskLevel::High,
),
admin(
HttpMethod::Post,
"/rustfs/admin/v3/ilm/transition/state/reconcile",
SET_TIER,
RouteRiskLevel::High,
),
admin(
HttpMethod::Get,
"/rustfs/admin/v3/audit/target/list",
@@ -2164,11 +2176,15 @@ mod tests {
assert_action(HttpMethod::Delete, "/rustfs/admin/v3/ilm/transition/jobs/{job_id}", SET_TIER);
assert_action(HttpMethod::Get, "/rustfs/admin/v3/ilm/transition/reconcile/{transaction_id}", LIST_TIER);
assert_action(HttpMethod::Post, "/rustfs/admin/v3/ilm/transition/reconcile/{transaction_id}", SET_TIER);
assert_action(HttpMethod::Get, "/rustfs/admin/v3/ilm/transition/state/reconcile", LIST_TIER);
assert_action(HttpMethod::Post, "/rustfs/admin/v3/ilm/transition/state/reconcile", SET_TIER);
assert_not_action(HttpMethod::Post, "/rustfs/admin/v3/ilm/transition/run", SERVER_INFO);
assert_not_action(HttpMethod::Get, "/rustfs/admin/v3/ilm/transition/jobs/{job_id}", SERVER_INFO);
assert_not_action(HttpMethod::Delete, "/rustfs/admin/v3/ilm/transition/jobs/{job_id}", SERVER_INFO);
assert_not_action(HttpMethod::Get, "/rustfs/admin/v3/ilm/transition/reconcile/{transaction_id}", SET_TIER);
assert_not_action(HttpMethod::Post, "/rustfs/admin/v3/ilm/transition/reconcile/{transaction_id}", LIST_TIER);
assert_not_action(HttpMethod::Get, "/rustfs/admin/v3/ilm/transition/state/reconcile", SET_TIER);
assert_not_action(HttpMethod::Post, "/rustfs/admin/v3/ilm/transition/state/reconcile", LIST_TIER);
}
#[test]
@@ -225,6 +225,8 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
"/v3/ilm/transition/reconcile/{transaction_id}",
"/v3/ilm/transition/reconcile/11111111-1111-4111-8111-111111111111",
),
admin_route(Method::GET, "/v3/ilm/transition/state/reconcile"),
admin_route(Method::POST, "/v3/ilm/transition/state/reconcile"),
admin_route_sample(
Method::DELETE,
"/v3/ilm/transition/jobs/{job_id}",
@@ -951,6 +953,8 @@ fn test_register_routes_cover_representative_admin_paths() {
Method::POST,
&admin_path("/v3/ilm/transition/reconcile/11111111-1111-4111-8111-111111111111"),
);
assert_route(&router, Method::GET, &admin_path("/v3/ilm/transition/state/reconcile"));
assert_route(&router, Method::POST, &admin_path("/v3/ilm/transition/state/reconcile"));
assert_route(&router, Method::GET, &table_catalog_path("/config"));
assert_route(&router, Method::PUT, &table_catalog_path("/buckets/analytics"));
+4
View File
@@ -223,6 +223,10 @@ pub(crate) mod lifecycle {
renew_manual_transition_job_lease_if_owned, request_manual_transition_job_cancel, save_manual_transition_job_record,
update_manual_transition_job_record,
};
pub(crate) use crate::storage::storage_api::ecstore_bucket::lifecycle::legacy_transition_state_reconcile::{
LegacyTransitionStateReconcileError, LegacyTransitionStateReconcileRequest, LegacyTransitionStateReconcileResponse,
LegacyTransitionStateReconcileSelector,
};
pub(crate) type ManualTransitionCancelCheck =
super::ecstore_bucket::lifecycle::bucket_lifecycle_ops::ManualTransitionCancelCheck;
pub(crate) type ManualTransitionQueueSnapshot =