Files
rustfs/rustfs/src/admin/site_replication_identity.rs
T
唐小鸭 f7003dfddd fix(admin): four site-replication interop correctness fixes (B5-rc T2) (#6399)
* fix(admin): send versioningEnabled on site replication make-bucket ops

The outbound make-with-versioning bucket-op query only carried
operation/createdAt/lockEnabled. MinIO's own create-bucket hook sends
versioningEnabled=true on this op, so align the outbound query with
MinIO's site-replication make-bucket wire contract. Route both outbound
builders (bootstrap plan and create-bucket hook) through one shared
builder that always appends versioningEnabled=true. RustFS's own inbound
handler force-enables versioning either way, so RustFS-to-RustFS
behavior is unchanged; the MinIO release verified against
(RELEASE.2025-09-07) also force-enables versioning regardless of the
flag, so this aligns the wire contract rather than changing observable
behavior there.

* fix(admin): propagate purge-deleted-bucket errors in site replication

The purge-deleted-bucket branch of the peer bucket-ops handler dropped
the delete_bucket error and answered 200, so a peer-driven purge that
failed (disk full, quorum loss) was reported as success while the
bucket survived on this site. Tolerate only bucket-not-found (the purge
raced an earlier replay or a local delete) and propagate every other
error through ApiError like the sibling delete branches do.

* fix(admin): derive fallback site deployment ID with UUIDv5

deployment_id_for_endpoint used DefaultHasher, whose algorithm is not
guaranteed stable across Rust releases. The fallback fires when a peer
response carries an empty deploymentID; the result is persisted in
site-replication state, used for collision disambiguation, and
broadcast to peers, so a toolchain bump could re-derive a different ID
for the same endpoint. Note that the add preflight currently rejects
that case upstream of this fallback. Derive UUIDv5 (NAMESPACE_URL) over
the canonical endpoint instead, and log a structured warn when a peer
metainfo response arrives without a deploymentID. Already persisted
fallback IDs are non-empty and therefore never re-derived, so existing
state is unaffected.

* fix(admin): stream site replication devnull body without 1MB cap

The site-replication devnull endpoint buffered the request body through
read_plain_admin_body, which enforces the 1MB admin body cap. MinIO
peers stream multi-megabyte probe bodies to this endpoint during site
netperf link checks and expect an unbounded discard, so any larger
probe got a 400 and was misreported as a broken link. Stream and
discard the body chunk by chunk with no size cap instead, mirroring
MinIO's io.Discard drain. The response stays 204 with an empty body.
2026-08-23 04:24:04 +08:00

301 lines
11 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use rustfs_madmin::{PeerInfo, SyncStatus};
use std::collections::BTreeMap;
use url::Url;
use uuid::Uuid;
fn has_http_scheme(endpoint: &str) -> bool {
endpoint.get(..7).is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://"))
|| endpoint
.get(..8)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
}
pub fn canonical_endpoint(endpoint: &str) -> String {
let trimmed = endpoint.trim().trim_end_matches('/');
let candidate = if has_http_scheme(trimmed) {
trimmed.to_string()
} else {
format!("http://{trimmed}")
};
Url::parse(&candidate)
.ok()
.map(|url| {
let scheme = url.scheme().to_ascii_lowercase();
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
let port = url.port_or_known_default();
match port {
Some(port) => format!("{scheme}://{host}:{port}"),
None => format!("{scheme}://{host}"),
}
})
.unwrap_or_else(|| trimmed.to_ascii_lowercase())
}
pub fn site_identity_key(endpoint: &str) -> String {
let trimmed = endpoint.trim().trim_end_matches('/');
let candidate = if has_http_scheme(trimmed) {
trimmed.to_string()
} else {
format!("http://{trimmed}")
};
Url::parse(&candidate)
.ok()
.map(|url| {
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
match url.port_or_known_default() {
Some(port) => format!("{host}:{port}"),
None => host,
}
})
.unwrap_or_else(|| trimmed.to_ascii_lowercase())
}
/// Fallback deployment ID for a peer that reported none. UUIDv5 over the
/// canonical endpoint: the ID is persisted in site-replication state and
/// broadcast to peers, so it must be identical across Rust toolchains
/// (`DefaultHasher` is not) and across spellings of the same endpoint.
pub fn deployment_id_for_endpoint(endpoint: &str) -> String {
Uuid::new_v5(&Uuid::NAMESPACE_URL, canonical_endpoint(endpoint).as_bytes()).to_string()
}
pub fn same_identity_endpoint(left: &str, right: &str) -> bool {
site_identity_key(left) == site_identity_key(right)
}
pub(crate) fn mark_unknown_peer_sync_enabled(peers: &mut BTreeMap<String, PeerInfo>) {
for peer in peers.values_mut() {
if peer.sync_state == SyncStatus::Unknown {
peer.sync_state = SyncStatus::Enable;
}
}
}
pub(super) fn is_https_endpoint(endpoint: &str) -> bool {
canonical_endpoint(endpoint).starts_with("https://")
}
fn merge_identity_peer(existing: PeerInfo, incoming: PeerInfo) -> PeerInfo {
let existing_https = is_https_endpoint(&existing.endpoint);
let incoming_https = is_https_endpoint(&incoming.endpoint);
let mut merged = if incoming_https && !existing_https {
incoming.clone()
} else {
existing.clone()
};
let fallback = if merged.deployment_id == incoming.deployment_id {
existing
} else {
incoming
};
if merged.deployment_id.is_empty() {
merged.deployment_id = fallback.deployment_id;
}
if merged.name.is_empty() {
merged.name = fallback.name;
}
if merged.api_version.is_none() {
merged.api_version = fallback.api_version;
}
merged.replicate_ilm_expiry |= fallback.replicate_ilm_expiry;
merged
}
pub fn normalize_peer_map_by_identity_with<F>(peers: BTreeMap<String, PeerInfo>, mut normalize: F) -> BTreeMap<String, PeerInfo>
where
F: FnMut(PeerInfo) -> PeerInfo,
{
let mut peers_by_identity = BTreeMap::<String, PeerInfo>::new();
for (_, peer) in peers {
let normalized_peer = normalize(peer);
let identity = site_identity_key(&normalized_peer.endpoint);
if let Some(existing) = peers_by_identity.remove(&identity) {
peers_by_identity.insert(identity, normalize(merge_identity_peer(existing, normalized_peer)));
} else {
peers_by_identity.insert(identity, normalized_peer);
}
}
let mut normalized = BTreeMap::<String, PeerInfo>::new();
for (_, mut peer) in peers_by_identity {
if peer.deployment_id.is_empty() {
peer.deployment_id = deployment_id_for_endpoint(&peer.endpoint);
}
let mut deployment_id = peer.deployment_id.clone();
if let Some(existing) = normalized.get(&deployment_id)
&& site_identity_key(&existing.endpoint) != site_identity_key(&peer.endpoint)
{
deployment_id = format!("{deployment_id}-{}", deployment_id_for_endpoint(&peer.endpoint));
peer.deployment_id = deployment_id.clone();
}
if let Some(existing) = normalized.get(&deployment_id).cloned() {
normalized.insert(deployment_id, normalize(merge_identity_peer(existing, peer)));
} else {
normalized.insert(deployment_id, peer);
}
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
use rustfs_madmin::{BucketBandwidth, SyncStatus};
fn peer(name: &str, endpoint: &str) -> PeerInfo {
PeerInfo {
name: name.to_string(),
endpoint: endpoint.to_string(),
deployment_id: name.to_string(),
sync_state: SyncStatus::Unknown,
default_bandwidth: BucketBandwidth::default(),
replicate_ilm_expiry: false,
object_naming_mode: String::new(),
skip_tls_verify: false,
ca_cert_pem: String::new(),
api_version: None,
}
}
/// B8 red-light: the fallback deployment ID must be a toolchain-stable
/// UUIDv5 over the canonical endpoint — `DefaultHasher` output is not
/// guaranteed stable across Rust releases, yet the ID is persisted in
/// site-replication state and broadcast to peers.
#[test]
fn deployment_id_for_endpoint_is_stable_uuid_v5_over_canonical_endpoint() {
let endpoint = "https://node-a.example.com:9000";
let id = deployment_id_for_endpoint(endpoint);
let parsed = uuid::Uuid::parse_str(&id).expect("fallback deployment ID must be a UUID");
assert_eq!(parsed.get_version_num(), 5, "fallback deployment ID must be UUIDv5");
// Deterministic for the same endpoint and for spelling variants that
// share a canonical form; distinct endpoints stay distinct.
assert_eq!(id, deployment_id_for_endpoint(endpoint));
assert_eq!(id, deployment_id_for_endpoint(" HTTPS://Node-A.Example.Com:9000/ "));
assert_ne!(id, deployment_id_for_endpoint("https://node-b.example.com:9000"));
}
#[test]
fn canonical_endpoint_accepts_case_insensitive_scheme() {
assert_eq!(
canonical_endpoint(" HTTPS://Node-A.Example.Com:9000/ "),
"https://node-a.example.com:9000"
);
}
#[test]
fn site_identity_key_accepts_case_insensitive_scheme() {
assert_eq!(site_identity_key("HTTPS://Node-A.Example.Com:9000/"), "node-a.example.com:9000");
assert!(same_identity_endpoint(
"HTTPS://Node-A.Example.Com:9000/",
"http://node-a.example.com:9000"
));
}
#[test]
fn normalize_peer_map_deduplicates_case_insensitive_scheme() {
let peers = BTreeMap::from([
("remote-http".to_string(), peer("remote-http", "http://node-a.example.com:9000")),
("remote-https".to_string(), peer("remote-https", "HTTPS://Node-A.Example.Com:9000/")),
]);
let normalized = normalize_peer_map_by_identity_with(peers, |peer| peer);
assert_eq!(normalized.len(), 1);
let peer = normalized.values().next().expect("normalized peer should exist");
assert_eq!(peer.endpoint, "HTTPS://Node-A.Example.Com:9000/");
assert_eq!(peer.deployment_id, "remote-https");
}
#[test]
fn normalize_peer_map_backfills_metadata_when_https_peer_wins() {
let peers = BTreeMap::from([
(
"remote-http".to_string(),
PeerInfo {
api_version: Some("v1".to_string()),
replicate_ilm_expiry: true,
skip_tls_verify: true,
ca_cert_pem: "fallback-ca".to_string(),
..peer("remote-http", "http://node-a.example.com:9000")
},
),
(
"remote-https".to_string(),
PeerInfo {
name: String::new(),
deployment_id: String::new(),
..peer("remote-https", "https://node-a.example.com:9000")
},
),
]);
let normalized = normalize_peer_map_by_identity_with(peers, |peer| peer);
assert_eq!(normalized.len(), 1);
let peer = normalized.values().next().expect("normalized peer should exist");
assert_eq!(peer.endpoint, "https://node-a.example.com:9000");
assert_eq!(peer.name, "remote-http");
assert_eq!(peer.deployment_id, "remote-http");
assert_eq!(peer.api_version.as_deref(), Some("v1"));
assert!(peer.replicate_ilm_expiry);
assert!(!peer.skip_tls_verify);
assert_eq!(peer.ca_cert_pem, "");
}
#[test]
fn normalize_peer_map_generates_missing_deployment_id() {
let endpoint = "https://node-a.example.com:9000";
let peers = BTreeMap::from([(
"remote".to_string(),
PeerInfo {
deployment_id: String::new(),
..peer("remote", endpoint)
},
)]);
let normalized = normalize_peer_map_by_identity_with(peers, |peer| peer);
let expected_deployment_id = deployment_id_for_endpoint(endpoint);
assert!(normalized.contains_key(&expected_deployment_id));
assert_eq!(normalized[&expected_deployment_id].deployment_id, expected_deployment_id);
}
#[test]
fn normalize_peer_map_suffixes_colliding_deployment_id_for_distinct_identity() {
let first = peer("shared", "https://node-a.example.com:9000");
let second_endpoint = "https://node-b.example.com:9000";
let second = PeerInfo {
deployment_id: "shared".to_string(),
..peer("remote-b", second_endpoint)
};
let peers = BTreeMap::from([("first".to_string(), first), ("second".to_string(), second)]);
let normalized = normalize_peer_map_by_identity_with(peers, |peer| peer);
let expected_second_id = format!("shared-{}", deployment_id_for_endpoint(second_endpoint));
assert_eq!(normalized.len(), 2);
assert!(normalized.contains_key("shared"));
assert!(normalized.contains_key(&expected_second_id));
assert_eq!(normalized[&expected_second_id].deployment_id, expected_second_id);
}
}