mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-23 12:49:04 +00:00
f7003dfddd
* 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.
301 lines
11 KiB
Rust
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);
|
|
}
|
|
}
|