From 7667b7aaf866406407cbdec82e157d1b8ac02f6c Mon Sep 17 00:00:00 2001 From: houseme Date: Sun, 26 Jul 2026 13:32:28 +0800 Subject: [PATCH] feat(admin): expose ILM expiry status (#5268) Co-authored-by: heihutu --- rustfs/src/admin/console.rs | 18 ++++ rustfs/src/admin/handlers/mod.rs | 1 + rustfs/src/admin/handlers/scanner.rs | 95 ++++++++++++++++++++- rustfs/src/admin/route_policy.rs | 12 +++ rustfs/src/admin/route_registration_test.rs | 3 + 5 files changed, 128 insertions(+), 1 deletion(-) diff --git a/rustfs/src/admin/console.rs b/rustfs/src/admin/console.rs index c803c635d..50de63b10 100644 --- a/rustfs/src/admin/console.rs +++ b/rustfs/src/admin/console.rs @@ -226,6 +226,12 @@ struct ApiDiscovery { cluster_snapshot: String, #[serde(rename = "extensionsCatalog")] extensions_catalog: String, + #[serde(rename = "ilmExpiryStatus")] + ilm_expiry_status: String, + #[serde(rename = "ilmTransitionRun")] + ilm_transition_run: String, + #[serde(rename = "ilmTransitionJob")] + ilm_transition_job: String, } fn console_api_discovery() -> ApiDiscovery { @@ -234,6 +240,9 @@ fn console_api_discovery() -> ApiDiscovery { runtime_capabilities: usecase.runtime_capabilities_route().to_string(), cluster_snapshot: usecase.cluster_snapshot_route().to_string(), extensions_catalog: usecase.extensions_catalog_route().to_string(), + ilm_expiry_status: format!("{RUSTFS_ADMIN_PREFIX}/ilm/expiry/status"), + ilm_transition_run: format!("{RUSTFS_ADMIN_PREFIX}/ilm/transition/run"), + ilm_transition_job: format!("{RUSTFS_ADMIN_PREFIX}/ilm/transition/jobs/{{job_id}}"), } } @@ -853,6 +862,9 @@ mod tests { assert_eq!(cfg.api.discovery.runtime_capabilities, "/rustfs/admin/v4/runtime/capabilities"); assert_eq!(cfg.api.discovery.cluster_snapshot, "/rustfs/admin/v4/cluster/snapshot"); assert_eq!(cfg.api.discovery.extensions_catalog, "/rustfs/admin/v4/extensions/catalog"); + assert_eq!(cfg.api.discovery.ilm_expiry_status, "/rustfs/admin/v3/ilm/expiry/status"); + assert_eq!(cfg.api.discovery.ilm_transition_run, "/rustfs/admin/v3/ilm/transition/run"); + assert_eq!(cfg.api.discovery.ilm_transition_job, "/rustfs/admin/v3/ilm/transition/jobs/{job_id}"); } #[tokio::test] @@ -871,6 +883,12 @@ mod tests { assert_eq!(value["api"]["discovery"]["runtimeCapabilities"], "/rustfs/admin/v4/runtime/capabilities"); assert_eq!(value["api"]["discovery"]["clusterSnapshot"], "/rustfs/admin/v4/cluster/snapshot"); assert_eq!(value["api"]["discovery"]["extensionsCatalog"], "/rustfs/admin/v4/extensions/catalog"); + assert_eq!(value["api"]["discovery"]["ilmExpiryStatus"], "/rustfs/admin/v3/ilm/expiry/status"); + assert_eq!(value["api"]["discovery"]["ilmTransitionRun"], "/rustfs/admin/v3/ilm/transition/run"); + assert_eq!( + value["api"]["discovery"]["ilmTransitionJob"], + "/rustfs/admin/v3/ilm/transition/jobs/{job_id}" + ); } #[test] diff --git a/rustfs/src/admin/handlers/mod.rs b/rustfs/src/admin/handlers/mod.rs index b18f541a8..bb4273274 100644 --- a/rustfs/src/admin/handlers/mod.rs +++ b/rustfs/src/admin/handlers/mod.rs @@ -120,6 +120,7 @@ mod tests { let _list_remote_target_handler = replication::ListRemoteTargetHandler {}; let _remove_remote_target_handler = replication::RemoveRemoteTargetHandler {}; let _scanner_status_handler = scanner::ScannerStatusHandler {}; + let _ilm_expiry_status_handler = scanner::IlmExpiryStatusHandler {}; let _manual_transition_handler = ilm_transition::ManualTransitionRunHandler {}; let _manual_transition_status_handler = ilm_transition::ManualTransitionJobStatusHandler {}; let _manual_transition_cancel_handler = ilm_transition::ManualTransitionJobCancelHandler {}; diff --git a/rustfs/src/admin/handlers/scanner.rs b/rustfs/src/admin/handlers/scanner.rs index 859d676d7..b9ff901c5 100644 --- a/rustfs/src/admin/handlers/scanner.rs +++ b/rustfs/src/admin/handlers/scanner.rs @@ -22,7 +22,7 @@ use chrono::Utc; use http::{HeaderMap, HeaderValue}; use hyper::{Method, StatusCode}; use matchit::Params; -use rustfs_common::metrics::ScannerMetricsReport; +use rustfs_common::metrics::{ScannerLifecycleExpirySnapshot, ScannerMaintenanceControlSnapshot, ScannerMetricsReport}; use rustfs_credentials::Credentials; use rustfs_policy::policy::action::{Action, AdminAction}; use s3s::header::CONTENT_TYPE; @@ -49,6 +49,17 @@ struct ScannerFreshnessStatus { reason: Option<&'static str>, } +#[derive(Debug, Serialize)] +struct IlmExpiryStatusResponse { + enabled: bool, + disabled_reason: Option, + freshness: ScannerFreshnessStatus, + lifecycle_expiry: ScannerLifecycleExpirySnapshot, + maintenance_control: ScannerMaintenanceControlSnapshot, + current_cycle_lifecycle_expiry_actions: u64, + last_cycle_lifecycle_expiry_actions: u64, +} + fn scanner_disabled_reason(enabled: bool) -> Option { (!enabled).then(|| format!("disabled by {ENV_SCANNER_ENABLED}")) } @@ -110,12 +121,35 @@ fn scanner_status_response( } } +fn ilm_expiry_status_response( + enabled: bool, + metrics: ScannerMetricsReport, + runtime_config: rustfs_scanner::runtime_config::ScannerRuntimeConfigStatus, + cycle_schedule: rustfs_scanner::ScannerCycleScheduleStatus, +) -> IlmExpiryStatusResponse { + let freshness = scanner_freshness_status(&metrics, &runtime_config, cycle_schedule.effective_interval_seconds()); + IlmExpiryStatusResponse { + enabled, + disabled_reason: scanner_disabled_reason(enabled), + freshness, + lifecycle_expiry: metrics.lifecycle_expiry, + maintenance_control: metrics.maintenance_control, + current_cycle_lifecycle_expiry_actions: metrics.current_cycle_lifecycle_expiry_actions, + last_cycle_lifecycle_expiry_actions: metrics.last_cycle_lifecycle_expiry_actions, + } +} + pub fn register_scanner_route(r: &mut S3Router) -> std::io::Result<()> { r.insert( Method::GET, format!("{ADMIN_PREFIX}/v3/scanner/status").as_str(), AdminOperation(&ScannerStatusHandler {}), )?; + r.insert( + Method::GET, + format!("{ADMIN_PREFIX}/v3/ilm/expiry/status").as_str(), + AdminOperation(&IlmExpiryStatusHandler {}), + )?; Ok(()) } @@ -172,6 +206,25 @@ impl Operation for ScannerStatusHandler { } } +pub struct IlmExpiryStatusHandler {} + +#[async_trait::async_trait] +impl Operation for IlmExpiryStatusHandler { + async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { + let _cred = validate_scanner_status_request(&req).await?; + let enabled = scanner_enabled_from_env(); + let metrics = current_scanner_metrics_report().await; + let runtime_config = rustfs_scanner::scanner_runtime_config_status(); + let cycle_schedule = rustfs_scanner::scanner_cycle_schedule_status(); + let response = ilm_expiry_status_response(enabled, metrics, runtime_config, cycle_schedule); + let body = serde_json::to_vec(&response).map_err(|err| { + S3Error::with_message(S3ErrorCode::InternalError, format!("failed to encode ILM expiry status: {err}")) + })?; + + json_response(body) + } +} + #[cfg(test)] mod tests { use super::*; @@ -244,4 +297,44 @@ mod tests { assert_eq!(encoded["cycle_schedule"]["clean_idle_backoff_enabled"], false); assert_eq!(encoded["cycle_schedule"]["clean_idle_backoff_multiplier"], 1); } + + #[test] + fn ilm_expiry_status_serializes_lifecycle_expiry_contract() { + let metrics = ScannerMetricsReport { + lifecycle_expiry: ScannerLifecycleExpirySnapshot { + current_queue_capacity: 32, + current_queued: 7, + current_active: 2, + current_workers: 4, + queue_missed: 3, + scanner_queued: 17, + scanner_missed: 5, + scanner_blocked: 11, + scanner_not_enqueued: 13, + delete_failed: 19, + }, + maintenance_control: ScannerMaintenanceControlSnapshot { + primary_control: "expiry_backlog".to_string(), + ..Default::default() + }, + current_cycle_lifecycle_expiry_actions: 23, + last_cycle_lifecycle_expiry_actions: 29, + ..Default::default() + }; + let response = ilm_expiry_status_response( + true, + metrics, + rustfs_scanner::scanner_runtime_config_status(), + rustfs_scanner::ScannerCycleScheduleStatus::default(), + ); + + let encoded = serde_json::to_value(response).expect("ILM expiry status should serialize"); + assert_eq!(encoded["lifecycle_expiry"]["current_queue_capacity"].as_u64(), Some(32)); + assert_eq!(encoded["lifecycle_expiry"]["scanner_blocked"].as_u64(), Some(11)); + assert_eq!(encoded["lifecycle_expiry"]["scanner_not_enqueued"].as_u64(), Some(13)); + assert_eq!(encoded["lifecycle_expiry"]["delete_failed"].as_u64(), Some(19)); + assert_eq!(encoded["maintenance_control"]["primary_control"].as_str(), Some("expiry_backlog")); + assert_eq!(encoded["current_cycle_lifecycle_expiry_actions"].as_u64(), Some(23)); + assert_eq!(encoded["last_cycle_lifecycle_expiry_actions"].as_u64(), Some(29)); + } } diff --git a/rustfs/src/admin/route_policy.rs b/rustfs/src/admin/route_policy.rs index de3f7bc42..783620e3d 100644 --- a/rustfs/src/admin/route_policy.rs +++ b/rustfs/src/admin/route_policy.rs @@ -413,6 +413,12 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[ admin(HttpMethod::Get, "/rustfs/admin/v3/config", CONFIG_UPDATE, RouteRiskLevel::High), admin(HttpMethod::Put, "/rustfs/admin/v3/config", CONFIG_UPDATE, RouteRiskLevel::High), admin(HttpMethod::Get, "/rustfs/admin/v3/scanner/status", SERVER_INFO, RouteRiskLevel::Sensitive), + admin( + HttpMethod::Get, + "/rustfs/admin/v3/ilm/expiry/status", + SERVER_INFO, + RouteRiskLevel::Sensitive, + ), admin(HttpMethod::Post, "/rustfs/admin/v3/ilm/transition/run", SET_TIER, RouteRiskLevel::High), admin( HttpMethod::Get, @@ -1812,6 +1818,12 @@ mod tests { } } + #[test] + fn route_policy_allows_server_info_for_ilm_expiry_status() { + assert_action(HttpMethod::Get, "/rustfs/admin/v3/ilm/expiry/status", SERVER_INFO); + assert_not_action(HttpMethod::Get, "/rustfs/admin/v3/ilm/expiry/status", SET_TIER); + } + #[test] fn route_policy_requires_set_tier_for_manual_transition_routes() { assert_action(HttpMethod::Post, "/rustfs/admin/v3/ilm/transition/run", SET_TIER); diff --git a/rustfs/src/admin/route_registration_test.rs b/rustfs/src/admin/route_registration_test.rs index aef6c049e..c3d2fefb0 100644 --- a/rustfs/src/admin/route_registration_test.rs +++ b/rustfs/src/admin/route_registration_test.rs @@ -195,6 +195,7 @@ fn expected_admin_route_matrix() -> Vec { admin_route(Method::PUT, "/v3/tier"), admin_route_sample(Method::POST, "/v3/tier/{tiername}", "/v3/tier/HOT"), admin_route(Method::POST, "/v3/tier/clear"), + admin_route(Method::GET, "/v3/ilm/expiry/status"), admin_route(Method::POST, "/v3/ilm/transition/run"), admin_route_sample( Method::GET, @@ -843,6 +844,7 @@ fn test_register_routes_cover_representative_admin_paths() { assert_route(&router, Method::GET, &admin_path("/v3/config")); assert_route(&router, Method::PUT, &admin_path("/v3/config")); assert_route(&router, Method::GET, &admin_path("/v3/scanner/status")); + assert_route(&router, Method::GET, &admin_path("/v3/ilm/expiry/status")); assert_route(&router, Method::POST, &admin_path("/v3/ilm/transition/run")); assert_route( &router, @@ -1317,6 +1319,7 @@ fn test_admin_alias_paths_match_existing_admin_routes() { (Method::GET, compat_admin_alias_path("/v3/config")), (Method::PUT, compat_admin_alias_path("/v3/config")), (Method::GET, compat_admin_alias_path("/v3/scanner/status")), + (Method::GET, compat_admin_alias_path("/v3/ilm/expiry/status")), ] { assert!( router.contains_compatible_route(method.clone(), &path),