diff --git a/crates/ecstore/src/bucket/target/bucket_target.rs b/crates/ecstore/src/bucket/target/bucket_target.rs index 2fc1aa05c..0f5e3f62b 100644 --- a/crates/ecstore/src/bucket/target/bucket_target.rs +++ b/crates/ecstore/src/bucket/target/bucket_target.rs @@ -206,7 +206,9 @@ pub struct BucketTarget { #[serde(default)] pub region: String, - #[serde(alias = "bandwidth", default)] + // madmin-go v3.0.109 tags this `bandwidthlimit`; `bandwidth` is a legacy + // alias kept for inputs written before the madmin tag was verified. + #[serde(alias = "bandwidthlimit", alias = "bandwidth", default)] pub bandwidth_limit: i64, #[serde(rename = "replicationSync", default)] @@ -576,9 +578,10 @@ mod tests { #[test] fn minio_written_bucket_targets_json_populates_madmin_named_fields() { // A MinIO-written bucket-targets.json carries madmin's JSON tags - // (`bandwidth`, `storageclass`, `resetID`, `deploymentID`, - // `credentials.sessionToken`). On migration these must land in the - // matching fields instead of silently defaulting (backlog#1946). + // (`bandwidthlimit`, `storageclass`, `resetID`, `deploymentID`, + // `credentials.sessionToken` — madmin-go v3.0.109 bucket-targets.go). + // On migration these must land in the matching fields instead of + // silently defaulting (backlog#1951). let targets: BucketTargets = serde_json::from_value(serde_json::json!({ "targets": [{ "sourcebucket": "src", @@ -591,7 +594,7 @@ mod tests { "targetbucket": "dst", "type": "replication", "replicationSync": true, - "bandwidth": 107374182400i64, + "bandwidthlimit": 107374182400i64, "storageclass": "STANDARD", "resetID": "reset-789", "deploymentID": "deploy-123" diff --git a/rustfs/src/admin/handlers/replication.rs b/rustfs/src/admin/handlers/replication.rs index 3c3f56aa7..29027b217 100644 --- a/rustfs/src/admin/handlers/replication.rs +++ b/rustfs/src/admin/handlers/replication.rs @@ -375,9 +375,10 @@ impl RemoteTargetRequest { /// format keeps `healthCheckDuration`/`totalDowntime` in seconds and the /// `latency` stats in milliseconds, but madmin decodes all of them as Go /// `time.Duration` (nanoseconds) — and it looks the fields up under its own -/// JSON tags (`bandwidth`, `storageclass`, `resetID`, `deploymentID`, -/// `credentials.sessionToken`), not the persisted snake_case keys. Re-encode -/// just those fields here without touching the persistence wire format. +/// JSON tags (`bandwidthlimit`, `storageclass`, `resetID`, `deploymentID`, +/// `credentials.sessionToken` — madmin-go v3.0.109 `bucket-targets.go`), not +/// the persisted snake_case keys. Re-encode just those fields here without +/// touching the persistence wire format. fn remote_target_admin_json(target: &BucketTarget) -> Result { fn go_duration_nanos(duration: Duration) -> serde_json::Value { // Saturate instead of truncating: >u64::MAX nanoseconds (~584 years) @@ -399,7 +400,7 @@ fn remote_target_admin_json(target: &BucketTarget) -> Result, + targetbucket: String, + arn: String, + bandwidthlimit: i64, + #[serde(rename = "replicationSync")] + replication_sync: bool, + storageclass: String, + #[serde(rename = "healthCheckDuration")] + health_check_duration: i64, + #[serde(rename = "resetID")] + reset_id: String, + #[serde(rename = "totalDowntime")] + total_downtime: i64, + #[serde(rename = "deploymentID")] + deployment_id: String, + } + + #[derive(Debug, Default, serde::Deserialize)] + #[serde(default)] + struct MadminCredentials { + #[serde(rename = "accessKey")] + access_key: String, + #[serde(rename = "secretKey")] + secret_key: String, + #[serde(rename = "sessionToken")] + session_token: String, + } + + #[test] + fn list_remote_targets_response_decodes_through_madmin_tags() { + // Regression for the review on backlog#1951: the response must decode + // a nonzero bandwidth limit through madmin's `bandwidthlimit` tag (not + // `bandwidth`, which Go would silently drop as an unknown key). + let target = BucketTarget { + source_bucket: "src".to_string(), + endpoint: "192.168.1.10:9000".to_string(), + target_bucket: "target".to_string(), + arn: "arn:rustfs:replication:us-east-1:dep:target".to_string(), + credentials: Some(TargetCredentials { + access_key: "access".to_string(), + secret_key: String::new(), + session_token: Some("session-token".to_string()), + expiration: None, + }), + bandwidth_limit: 1_073_741_824, + replication_sync: true, + storage_class: "STANDARD".to_string(), + health_check_duration: std::time::Duration::from_secs(60), + reset_id: "reset-123".to_string(), + total_downtime: std::time::Duration::from_secs(90), + deployment_id: "deploy-456".to_string(), + ..Default::default() + }; + + let wire = serde_json::to_string(&super::remote_target_admin_json(&target).expect("admin response should serialize")) + .expect("admin response should encode"); + let decoded: MadminBucketTarget = serde_json::from_str(&wire).expect("madmin-shaped decode must succeed"); + + assert_eq!(decoded.bandwidthlimit, 1_073_741_824, "mc must see the nonzero bandwidth limit"); + assert_eq!(decoded.sourcebucket, "src"); + assert_eq!(decoded.endpoint, "192.168.1.10:9000"); + assert_eq!(decoded.targetbucket, "target"); + assert_eq!(decoded.arn, "arn:rustfs:replication:us-east-1:dep:target"); + assert!(decoded.replication_sync); + assert_eq!(decoded.storageclass, "STANDARD"); + assert_eq!(decoded.health_check_duration, 60_000_000_000); + assert_eq!(decoded.reset_id, "reset-123"); + assert_eq!(decoded.total_downtime, 90_000_000_000); + assert_eq!(decoded.deployment_id, "deploy-456"); + let credentials = decoded.credentials.expect("credentials must decode"); + assert_eq!(credentials.access_key, "access"); + assert_eq!(credentials.secret_key, ""); + assert_eq!(credentials.session_token, "session-token"); + } + #[test] fn remote_target_admin_json_latency_round_trips_through_go_duration() { // Round trip: a madmin reader decodes the latency values as Go