diff --git a/crates/ecstore/src/api/mod.rs b/crates/ecstore/src/api/mod.rs index 558a3a984..1966538a0 100644 --- a/crates/ecstore/src/api/mod.rs +++ b/crates/ecstore/src/api/mod.rs @@ -194,9 +194,10 @@ pub mod bucket { BucketReplicationResyncStatus, BucketReplicationStat, BucketReplicationStats, BucketStats, DeleteReplicationConfigSnapshot, DeletedObjectReplicationInfo, DurableMrfBacklog, DynReplicationPool, InQueueMetric, MrfOpKind, MrfReplicateEntry, MustReplicateOptions, ObjectOpts, OperatorRuleContract, - REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, - REPLICATE_INCOMING_DELETE, REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, - REPLICATION_WRITABLE_FIELDS, ReplicateDecision, ReplicateObjectInfo, ReplicationBatchAdmission, ReplicationConfig, + REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS, + REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, REPLICATE_INCOMING_DELETE, + REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS, + ReplicateDecision, ReplicateObjectInfo, ReplicationBatchAdmission, ReplicationConfig, ReplicationConfigStructureError, ReplicationConfigurationExt, ReplicationDeleteScheduleInput, ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO, ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge, diff --git a/crates/ecstore/src/bucket/replication/mod.rs b/crates/ecstore/src/bucket/replication/mod.rs index 091b3a964..a60dd018d 100644 --- a/crates/ecstore/src/bucket/replication/mod.rs +++ b/crates/ecstore/src/bucket/replication/mod.rs @@ -44,14 +44,14 @@ mod replication_versioning_boundary; mod runtime_boundary; pub use replication_config_boundary::{ - ObjectOpts, OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS, - REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, - REPLICATION_WRITABLE_FIELDS, ReplicationConfigStructureError, ReplicationConfigurationExt, ReplicationTargetValidationError, - assign_site_replication_rule_priorities, invalid_replication_config_status_field, is_site_replication_role, - is_site_replication_rule, merge_incoming_replication_config, merge_user_replication_config, - replication_target_arn_deployment_id, replication_target_arns, should_remove_replication_target, - site_replication_rule_deployment_id, unsupported_replication_config_field, validate_replication_config_structure, - validate_replication_config_target_arns, + ObjectOpts, OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS, + REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, + REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS, ReplicationConfigStructureError, + ReplicationConfigurationExt, ReplicationTargetValidationError, assign_site_replication_rule_priorities, + invalid_replication_config_status_field, is_site_replication_role, is_site_replication_rule, + merge_incoming_replication_config, merge_user_replication_config, replication_target_arn_deployment_id, + replication_target_arns, should_remove_replication_target, site_replication_rule_deployment_id, + unsupported_replication_config_field, validate_replication_config_structure, validate_replication_config_target_arns, }; pub(crate) use replication_filemeta_boundary::version_purge_statuses_map; pub use replication_filemeta_boundary::{ diff --git a/crates/ecstore/src/bucket/replication/replication_config_boundary.rs b/crates/ecstore/src/bucket/replication/replication_config_boundary.rs index fbd9e88b1..1eb8c3ad3 100644 --- a/crates/ecstore/src/bucket/replication/replication_config_boundary.rs +++ b/crates/ecstore/src/bucket/replication/replication_config_boundary.rs @@ -13,12 +13,12 @@ // limitations under the License. pub use rustfs_replication::{ - ObjectOpts, OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS, - REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, - REPLICATION_WRITABLE_FIELDS, ReplicationConfigStructureError, ReplicationConfigurationExt, ReplicationRuleExt, - ReplicationTargetValidationError, assign_site_replication_rule_priorities, invalid_replication_config_status_field, - is_site_replication_role, is_site_replication_rule, merge_incoming_replication_config, merge_user_replication_config, - replication_target_arn_deployment_id, replication_target_arns, should_remove_replication_target, - site_replication_rule_deployment_id, unsupported_replication_config_field, validate_replication_config_structure, - validate_replication_config_target_arns, + ObjectOpts, OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS, + REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, + REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS, ReplicationConfigStructureError, + ReplicationConfigurationExt, ReplicationRuleExt, ReplicationTargetValidationError, assign_site_replication_rule_priorities, + invalid_replication_config_status_field, is_site_replication_role, is_site_replication_rule, + merge_incoming_replication_config, merge_user_replication_config, replication_target_arn_deployment_id, + replication_target_arns, should_remove_replication_target, site_replication_rule_deployment_id, + unsupported_replication_config_field, validate_replication_config_structure, validate_replication_config_target_arns, }; diff --git a/crates/replication/src/config.rs b/crates/replication/src/config.rs index 58e87ac1a..574d6c49c 100644 --- a/crates/replication/src/config.rs +++ b/crates/replication/src/config.rs @@ -60,9 +60,10 @@ pub const REPLICATION_READ_ONLY_HISTORICAL_FIELDS: &[&str] = &[ "Destination.ReplicationTime", ]; -// v2: disableProxy moved from unsupported to writable (per-target read-proxy -// opt-out is accepted by set-remote-target and the `proxy` update op). -pub const REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION: u32 = 2; +// v3: temporary-credential fields are advertised as read-only historical +// metadata. They remain decodable for MinIO and persisted-data compatibility, +// but set-remote-target rejects them until refresh and rotation are supported. +pub const REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION: u32 = 3; pub const REMOTE_TARGET_WRITABLE_FIELDS: &[&str] = &[ "sourcebucket", @@ -90,6 +91,8 @@ pub const REMOTE_TARGET_WRITABLE_FIELDS: &[&str] = &[ "disableProxy", ]; +pub const REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS: &[&str] = &["credentials.sessionToken", "credentials.expiration"]; + pub const REMOTE_TARGET_UNSUPPORTED_FIELDS: &[&str] = &["edge", "edgeSyncBeforeExpiry"]; #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/crates/replication/src/lib.rs b/crates/replication/src/lib.rs index 4e32b8a3e..e9ab2801d 100644 --- a/crates/replication/src/lib.rs +++ b/crates/replication/src/lib.rs @@ -29,14 +29,15 @@ mod storage_api; pub mod tagging; pub use config::{ - ObjectOpts, OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS, - REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, - REPLICATION_WRITABLE_FIELDS, ReplicationConfigStructureError, ReplicationConfigurationExt, ReplicationTargetValidationError, - active_replication_rule_destination_arns, assign_site_replication_rule_priorities, invalid_replication_config_status_field, - is_reconciler_owned_site_replication_rule, is_site_replication_role, is_site_replication_rule, - merge_incoming_replication_config, merge_user_replication_config, replication_target_arn_deployment_id, - replication_target_arns, should_remove_replication_target, site_replication_rule_deployment_id, - unsupported_replication_config_field, validate_replication_config_structure, validate_replication_config_target_arns, + ObjectOpts, OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS, + REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, + REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS, ReplicationConfigStructureError, + ReplicationConfigurationExt, ReplicationTargetValidationError, active_replication_rule_destination_arns, + assign_site_replication_rule_priorities, invalid_replication_config_status_field, is_reconciler_owned_site_replication_rule, + is_site_replication_role, is_site_replication_rule, merge_incoming_replication_config, merge_user_replication_config, + replication_target_arn_deployment_id, replication_target_arns, should_remove_replication_target, + site_replication_rule_deployment_id, unsupported_replication_config_field, validate_replication_config_structure, + validate_replication_config_target_arns, }; pub use delete::{ DeletedObjectReplicationInfo, delete_marker_purge_mrf_entry, delete_marker_purge_version_id, diff --git a/rustfs/src/admin/handlers/replication.rs b/rustfs/src/admin/handlers/replication.rs index 3e8868967..ed35d614f 100644 --- a/rustfs/src/admin/handlers/replication.rs +++ b/rustfs/src/admin/handlers/replication.rs @@ -23,9 +23,9 @@ use crate::admin::storage_api::bucket::metadata::BUCKET_TARGETS_FILE; use crate::admin::storage_api::bucket::metadata_sys; use crate::admin::storage_api::bucket::metadata_sys::get_replication_config; use crate::admin::storage_api::bucket::replication::REMOTE_TARGET_UNSUPPORTED_FIELDS; -#[cfg(test)] -use crate::admin::storage_api::bucket::replication::REMOTE_TARGET_WRITABLE_FIELDS; use crate::admin::storage_api::bucket::replication::{BucketStats, ReplicationStatusType}; +#[cfg(test)] +use crate::admin::storage_api::bucket::replication::{REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS}; use crate::admin::storage_api::bucket::target::{ BucketTarget, BucketTargetType, Credentials as TargetCredentials, LatencyStat, duration_from_secs_or_nanos, }; @@ -1504,10 +1504,10 @@ impl Operation for ReplicationMrfHandler { #[cfg(test)] mod tests { use super::{ - REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, RemoteTargetCredentialsRequest, RemoteTargetRequest, - ReplicationDiffEntry, SUPPORTED_REMOTE_TARGET_API, TargetUpdateOp, build_mrf_response, extract_query_params, - parse_remote_target_update_ops, render_mrf_backlog, render_replication_diff, unique_replication_peers, - validate_remote_target_tls_settings, + REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS, REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, + RemoteTargetCredentialsRequest, RemoteTargetRequest, ReplicationDiffEntry, SUPPORTED_REMOTE_TARGET_API, TargetUpdateOp, + build_mrf_response, extract_query_params, parse_remote_target_update_ops, render_mrf_backlog, render_replication_diff, + unique_replication_peers, validate_remote_target_tls_settings, }; use crate::admin::storage_api::bucket::target::{BucketTarget, Credentials as TargetCredentials, LatencyStat}; use crate::admin::storage_api::replication::{BucketStats, DurableMrfBacklog, MrfOpKind, MrfReplicateEntry}; @@ -2104,12 +2104,20 @@ mod tests { #[test] fn remote_target_request_rejects_unimplemented_fields() { - for (field, value) in [ - ("credentials.session_token", serde_json::json!("session-token")), - ("credentials.expiration", serde_json::json!("2026-01-01T00:00:00Z")), - ("api", serde_json::json!("s3v2")), - ("edge", serde_json::json!(true)), - ("edgeSyncBeforeExpiry", serde_json::json!(true)), + for (field, value, historical_field) in [ + ( + "credentials.session_token", + serde_json::json!("session-token"), + Some("credentials.sessionToken"), + ), + ( + "credentials.expiration", + serde_json::json!("2026-01-01T00:00:00Z"), + Some("credentials.expiration"), + ), + ("api", serde_json::json!("s3v2"), None), + ("edge", serde_json::json!(true), None), + ("edgeSyncBeforeExpiry", serde_json::json!(true), None), ] { let mut request = valid_remote_target_request(); if let Some((credential_field, credential_name)) = field.split_once('.') { @@ -2125,6 +2133,12 @@ mod tests { assert!(err.to_string().contains(field)); assert!(err.to_string().contains("not supported by this RustFS version")); + if let Some(historical_field) = historical_field { + assert!( + REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS.contains(&historical_field), + "rejected field {field} must be advertised as historical-only" + ); + } } } @@ -2500,6 +2514,17 @@ mod tests { #[test] fn remote_target_capability_fields_do_not_overlap() { + for field in REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS { + assert!( + !REMOTE_TARGET_WRITABLE_FIELDS.contains(field), + "remote target field {field} cannot be both historical-only and writable" + ); + assert!( + !REMOTE_TARGET_UNSUPPORTED_FIELDS.contains(field), + "remote target field {field} cannot be both historical-only and unsupported" + ); + } + for field in REMOTE_TARGET_UNSUPPORTED_FIELDS { assert!( !REMOTE_TARGET_WRITABLE_FIELDS.contains(field), diff --git a/rustfs/src/admin/handlers/system.rs b/rustfs/src/admin/handlers/system.rs index 250304c85..4b134d2e5 100644 --- a/rustfs/src/admin/handlers/system.rs +++ b/rustfs/src/admin/handlers/system.rs @@ -24,8 +24,9 @@ use crate::admin::runtime_sources::{ DefaultAdminUsecase, QueryServerInfoRequest, current_endpoints_handle, default_admin_usecase, object_store_from_req, }; use crate::admin::storage_api::bucket::replication::{ - REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, - REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS, + REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS, REMOTE_TARGET_UNSUPPORTED_FIELDS, + REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, + REPLICATION_WRITABLE_FIELDS, }; use crate::admin::storage_api::cluster::{ CapabilityState, CapabilityStatus, ObservabilitySnapshotProvider, TopologySnapshot, TopologySnapshotProvider, @@ -729,6 +730,15 @@ impl ReplicationCapabilities { name, state: ReplicationFieldState::Supported, }) + .chain( + REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS + .iter() + .copied() + .map(|name| ReplicationFieldCapability { + name, + state: ReplicationFieldState::ReadOnlyHistorical, + }), + ) .chain( REMOTE_TARGET_UNSUPPORTED_FIELDS .iter() @@ -1296,9 +1306,8 @@ mod tests { assert_eq!(response.summary.manual_transition_jobs.state, CapabilityState::Supported); assert_eq!(response.replication.contract_version, 1); assert_eq!(response.replication.bucket_replication.contract_version, 1); - // v2: disableProxy moved from unsupported to writable (per-target - // read-proxy opt-out reached the admin API). - assert_eq!(response.replication.remote_targets.contract_version, 2); + // v3: temporary-credential fields are explicitly historical-only. + assert_eq!(response.replication.remote_targets.contract_version, 3); assert_eq!(response.replication.bucket_replication.status.state, CapabilityState::Supported); assert_eq!(response.replication.remote_targets.status.state, CapabilityState::Supported); assert_eq!( @@ -1347,6 +1356,17 @@ mod tests { .iter() .any(|field| field.name == "healthCheckDuration" && field.state == super::ReplicationFieldState::Supported) ); + for name in ["credentials.sessionToken", "credentials.expiration"] { + assert!( + response + .replication + .remote_targets + .fields + .iter() + .any(|field| field.name == name && field.state == super::ReplicationFieldState::ReadOnlyHistorical), + "remote target field {name} must be advertised as historical-only" + ); + } assert_eq!(response.manual_transition_jobs.contract_version, 1); assert_eq!(response.manual_transition_jobs.status.state, CapabilityState::Supported); assert_eq!(response.manual_transition_jobs.modes, ["enqueue_only", "async"]); @@ -1408,7 +1428,7 @@ mod tests { assert_eq!(value["summary"]["manual_transition_jobs"]["state"], "supported"); assert_eq!(value["replication"]["contract_version"], 1); assert_eq!(value["replication"]["bucket_replication"]["contract_version"], 1); - assert_eq!(value["replication"]["remote_targets"]["contract_version"], 2); + assert_eq!(value["replication"]["remote_targets"]["contract_version"], 3); assert_eq!(value["replication"]["bucket_replication"]["status"]["state"], "supported"); assert_eq!(value["replication"]["remote_targets"]["status"]["state"], "supported"); assert_eq!( @@ -1443,6 +1463,16 @@ mod tests { .iter() .any(|field| field["name"] == "healthCheckDuration" && field["state"] == "supported") ); + for name in ["credentials.sessionToken", "credentials.expiration"] { + assert!( + value["replication"]["remote_targets"]["fields"] + .as_array() + .expect("remote target fields should be an array") + .iter() + .any(|field| field["name"] == name && field["state"] == "read_only_historical"), + "serialized remote target field {name} must be historical-only" + ); + } assert_eq!(value["manual_transition_jobs"]["contract_version"], 1); assert_eq!(value["manual_transition_jobs"]["status"]["state"], "supported"); assert_eq!(value["manual_transition_jobs"]["modes"], json!(["enqueue_only", "async"])); diff --git a/rustfs/src/admin/storage_api.rs b/rustfs/src/admin/storage_api.rs index 1c8a14c26..5ea5b3f74 100644 --- a/rustfs/src/admin/storage_api.rs +++ b/rustfs/src/admin/storage_api.rs @@ -443,10 +443,10 @@ pub(crate) mod quota { pub(crate) mod replication { pub(crate) use super::ecstore_bucket::replication::{ - OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS, - REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, - REPLICATION_WRITABLE_FIELDS, assign_site_replication_rule_priorities, merge_incoming_replication_config, - replication_target_arn_deployment_id, + OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS, + REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, + REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS, assign_site_replication_rule_priorities, + merge_incoming_replication_config, replication_target_arn_deployment_id, }; pub(crate) type BucketReplicationResyncStatus = super::ecstore_bucket::replication::BucketReplicationResyncStatus; pub(crate) type BucketStats = super::ecstore_bucket::replication::BucketStats;