mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-26 08:18:18 +00:00
feat(admin): advertise site replication capabilities (#5131)
This commit is contained in:
@@ -22,6 +22,7 @@ pub enum CapabilityState {
|
||||
Supported,
|
||||
Unsupported,
|
||||
Disabled,
|
||||
#[serde(other)]
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
@@ -133,4 +134,12 @@ mod tests {
|
||||
assert_eq!(decoded.1.reason.as_deref(), Some("target does not expose profiler"));
|
||||
assert!(!decoded.1.state.is_supported());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_capability_state_deserializes_as_unknown() {
|
||||
let status: CapabilityStatus = serde_json::from_str(r#"{"state":"future_state"}"#)
|
||||
.expect("future capability states should preserve conservative compatibility");
|
||||
|
||||
assert_eq!(status.state, CapabilityState::Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
|
||||
use super::{cluster_snapshot, metrics};
|
||||
use crate::admin::auth::validate_admin_request;
|
||||
use crate::admin::route_policy::{
|
||||
ADMIN_ROUTE_POLICY_SPECS, DEFERRED_ADMIN_ROUTE_POLICIES, DeferredAdminRoutePolicy, DeferredRoutePolicyReason,
|
||||
};
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::admin::runtime_sources::{
|
||||
DefaultAdminUsecase, QueryServerInfoRequest, current_endpoints_handle, default_admin_usecase, object_store_from_req,
|
||||
@@ -31,9 +34,10 @@ use matchit::Params;
|
||||
use rustfs_concurrency::WorkloadAdmissionRegistrySnapshot;
|
||||
use rustfs_madmin::{InfoMessage, StorageInfo};
|
||||
use rustfs_policy::policy::action::{Action, AdminAction, S3Action};
|
||||
use rustfs_security_governance::{AdminRouteSpec, HttpMethod};
|
||||
use s3s::header::CONTENT_TYPE;
|
||||
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
@@ -54,6 +58,9 @@ const OBSERVABILITY_SUMMARY_RESOLVED: &str = "observability summary resolved fro
|
||||
const TOPOLOGY_SUMMARY_RESOLVED: &str = "topology summary resolved from capability snapshot";
|
||||
const TOPOLOGY_SNAPSHOT_NOT_AVAILABLE: &str = "endpoint topology is not available before storage endpoint pools initialize";
|
||||
pub(crate) const RUNTIME_CAPABILITIES_ROUTE_SUFFIX: &str = "/v4/runtime/capabilities";
|
||||
const SITE_REPLICATION_INFO_ROUTE: &str = "/rustfs/admin/v3/site-replication/info";
|
||||
const SITE_REPLICATION_EDIT_ROUTE: &str = "/rustfs/admin/v3/site-replication/edit";
|
||||
const SITE_REPLICATION_RESYNC_ROUTE: &str = "/rustfs/admin/v3/site-replication/resync/op";
|
||||
|
||||
macro_rules! log_system_request_rejected {
|
||||
($operation:expr, $reason:expr) => {
|
||||
@@ -609,7 +616,7 @@ impl Operation for StorageInfoHandler {
|
||||
|
||||
pub struct DataUsageInfoHandler {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeCapabilitiesSummary {
|
||||
pub observability: CapabilityStatus,
|
||||
pub userspace_profiling: CapabilityStatus,
|
||||
@@ -617,6 +624,12 @@ pub struct RuntimeCapabilitiesSummary {
|
||||
pub platform: CapabilityStatus,
|
||||
pub topology: CapabilityStatus,
|
||||
pub cluster_snapshot: CapabilityStatus,
|
||||
#[serde(default)]
|
||||
pub site_replication_info: CapabilityStatus,
|
||||
#[serde(default)]
|
||||
pub site_replication_edit: CapabilityStatus,
|
||||
#[serde(default)]
|
||||
pub site_replication_resync: CapabilityStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
@@ -729,9 +742,42 @@ fn build_runtime_capabilities_summary(
|
||||
cluster_snapshot: cluster_snapshot_summary.cloned().unwrap_or_else(|| {
|
||||
CapabilityStatus::unknown().with_reason("cluster snapshot is not available before storage endpoint pools initialize")
|
||||
}),
|
||||
site_replication_info: admin_route_capability(HttpMethod::Get, SITE_REPLICATION_INFO_ROUTE),
|
||||
site_replication_edit: admin_route_capability(HttpMethod::Put, SITE_REPLICATION_EDIT_ROUTE),
|
||||
site_replication_resync: admin_route_capability(HttpMethod::Put, SITE_REPLICATION_RESYNC_ROUTE),
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_route_capability(method: HttpMethod, path: &str) -> CapabilityStatus {
|
||||
admin_route_capability_from_inventory(method, path, ADMIN_ROUTE_POLICY_SPECS, DEFERRED_ADMIN_ROUTE_POLICIES)
|
||||
}
|
||||
|
||||
fn admin_route_capability_from_inventory(
|
||||
method: HttpMethod,
|
||||
path: &str,
|
||||
direct: &[AdminRouteSpec],
|
||||
deferred: &[DeferredAdminRoutePolicy],
|
||||
) -> CapabilityStatus {
|
||||
if direct.iter().any(|spec| spec.method() == method && spec.path() == path) {
|
||||
return CapabilityStatus::supported().with_reason("public admin route is registered with an implemented handler");
|
||||
}
|
||||
|
||||
match deferred
|
||||
.iter()
|
||||
.find(|policy| policy.method() == method && policy.path() == path)
|
||||
.map(|policy| policy.reason())
|
||||
{
|
||||
Some(DeferredRoutePolicyReason::NotImplemented) | None => {
|
||||
CapabilityStatus::unsupported().with_reason("public admin route is absent or not implemented")
|
||||
}
|
||||
Some(_) => CapabilityStatus::supported().with_reason("public admin route is registered with contextual authorization"),
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_capabilities_gate_actions() -> Vec<Action> {
|
||||
vec![Action::AdminAction(AdminAction::ServerInfoAdminAction)]
|
||||
}
|
||||
|
||||
fn summarize_named_capability_statuses<const N: usize>(
|
||||
statuses: [(&'static str, &CapabilityStatus); N],
|
||||
subject: &'static str,
|
||||
@@ -767,15 +813,7 @@ impl Operation for RuntimeCapabilitiesHandler {
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
||||
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
validate_admin_request(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::AdminAction(AdminAction::ServerInfoAdminAction)],
|
||||
remote_addr,
|
||||
)
|
||||
.await?;
|
||||
validate_admin_request(&req.headers, &cred, owner, false, runtime_capabilities_gate_actions(), remote_addr).await?;
|
||||
|
||||
let response = build_runtime_capabilities_response().await.map_err(|err| {
|
||||
log_system_request_failed!("runtime_capabilities", "build_runtime_capabilities_failed", err);
|
||||
@@ -838,18 +876,26 @@ impl Operation for DataUsageInfoHandler {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
OBSERVABILITY_SUMMARY_RESOLVED, ServerInfoResponse, TOPOLOGY_SNAPSHOT_NOT_AVAILABLE, TOPOLOGY_SUMMARY_RESOLVED,
|
||||
build_runtime_capabilities_response, build_runtime_capabilities_summary, data_usage_info_gate_actions,
|
||||
system_admin_discovery,
|
||||
OBSERVABILITY_SUMMARY_RESOLVED, RuntimeCapabilitiesHandler, SITE_REPLICATION_EDIT_ROUTE, SITE_REPLICATION_INFO_ROUTE,
|
||||
SITE_REPLICATION_RESYNC_ROUTE, ServerInfoResponse, TOPOLOGY_SNAPSHOT_NOT_AVAILABLE, TOPOLOGY_SUMMARY_RESOLVED,
|
||||
admin_route_capability_from_inventory, build_runtime_capabilities_response, build_runtime_capabilities_summary,
|
||||
data_usage_info_gate_actions, runtime_capabilities_gate_actions, system_admin_discovery,
|
||||
};
|
||||
use crate::admin::router::Operation;
|
||||
use crate::admin::runtime_sources::DefaultAdminUsecase;
|
||||
use crate::admin::storage_api::cluster::{
|
||||
CapabilityState, CapabilityStatus, MemorySamplingState, ObservabilitySnapshot, PlatformSupport, TopologyCapabilities,
|
||||
TopologySnapshot, UserspaceProfilingCapability,
|
||||
};
|
||||
use http::{Extensions, HeaderMap, Uri};
|
||||
use hyper::Method;
|
||||
use matchit::Params;
|
||||
use rustfs_concurrency::WorkloadClass;
|
||||
use rustfs_madmin::{InfoMessage, StorageInfo};
|
||||
use rustfs_policy::policy::action::{Action, AdminAction, S3Action};
|
||||
use rustfs_security_governance::HttpMethod;
|
||||
use s3s::{Body, S3ErrorCode, S3Request};
|
||||
use serde_json::json;
|
||||
|
||||
/// Authz regression pin (rustfs/backlog#1306): datausageinfo stays an
|
||||
/// any-of gate over exactly DataUsageInfoAdminAction OR ListBucketAction.
|
||||
@@ -882,6 +928,111 @@ mod tests {
|
||||
assert_eq!(response.summary.cluster_snapshot.state, CapabilityState::Unknown);
|
||||
assert_eq!(response.observability.platform.os.as_deref(), Some(std::env::consts::OS));
|
||||
assert_eq!(response.workload_admission.entries().len(), WorkloadClass::REQUIRED.len());
|
||||
assert_eq!(response.summary.site_replication_info.state, CapabilityState::Supported);
|
||||
assert_eq!(response.summary.site_replication_edit.state, CapabilityState::Supported);
|
||||
assert_eq!(response.summary.site_replication_resync.state, CapabilityState::Supported);
|
||||
|
||||
let value = serde_json::to_value(response).expect("runtime capability response should serialize");
|
||||
assert_eq!(value["summary"]["site_replication_info"]["state"], "supported");
|
||||
assert_eq!(value["summary"]["site_replication_edit"]["state"], "supported");
|
||||
assert_eq!(value["summary"]["site_replication_resync"]["state"], "supported");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_replication_capabilities_follow_public_route_inventory() {
|
||||
for (method, path) in [
|
||||
(HttpMethod::Get, SITE_REPLICATION_INFO_ROUTE),
|
||||
(HttpMethod::Put, SITE_REPLICATION_EDIT_ROUTE),
|
||||
(HttpMethod::Put, SITE_REPLICATION_RESYNC_ROUTE),
|
||||
] {
|
||||
assert_eq!(
|
||||
admin_route_capability_from_inventory(
|
||||
method,
|
||||
path,
|
||||
crate::admin::route_policy::ADMIN_ROUTE_POLICY_SPECS,
|
||||
crate::admin::route_policy::DEFERRED_ADMIN_ROUTE_POLICIES,
|
||||
)
|
||||
.state,
|
||||
CapabilityState::Supported,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_replication_capabilities_reject_absent_internal_only_and_not_implemented_routes() {
|
||||
use crate::admin::route_policy::{DeferredAdminRoutePolicy, DeferredRoutePolicyReason};
|
||||
|
||||
let not_implemented = [DeferredAdminRoutePolicy::new(
|
||||
HttpMethod::Put,
|
||||
SITE_REPLICATION_EDIT_ROUTE,
|
||||
DeferredRoutePolicyReason::NotImplemented,
|
||||
)];
|
||||
assert_eq!(
|
||||
admin_route_capability_from_inventory(HttpMethod::Put, SITE_REPLICATION_EDIT_ROUTE, &[], ¬_implemented).state,
|
||||
CapabilityState::Unsupported,
|
||||
);
|
||||
assert_eq!(
|
||||
admin_route_capability_from_inventory(HttpMethod::Put, SITE_REPLICATION_EDIT_ROUTE, &[], &[]).state,
|
||||
CapabilityState::Unsupported,
|
||||
);
|
||||
|
||||
let internal_only = crate::admin::route_policy::ADMIN_ROUTE_POLICY_SPECS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|spec| spec.path() == "/rustfs/admin/v3/site-replication/peer/edit-capabilities")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
admin_route_capability_from_inventory(HttpMethod::Put, SITE_REPLICATION_EDIT_ROUTE, &internal_only, &[]).state,
|
||||
CapabilityState::Unsupported,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_runtime_summary_defaults_site_replication_capabilities_to_unknown() {
|
||||
let summary: super::RuntimeCapabilitiesSummary = serde_json::from_value(json!({
|
||||
"observability": { "state": "supported" },
|
||||
"userspace_profiling": { "state": "supported" },
|
||||
"memory_sampling": { "state": "supported" },
|
||||
"platform": { "state": "supported" },
|
||||
"topology": { "state": "supported" },
|
||||
"cluster_snapshot": { "state": "supported" }
|
||||
}))
|
||||
.expect("older runtime summary should remain compatible");
|
||||
|
||||
assert_eq!(summary.site_replication_info.state, CapabilityState::Unknown);
|
||||
assert_eq!(summary.site_replication_edit.state, CapabilityState::Unknown);
|
||||
assert_eq!(summary.site_replication_resync.state, CapabilityState::Unknown);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_capabilities_authentication_failure_is_not_reported_as_unsupported() {
|
||||
let request = S3Request {
|
||||
input: Body::empty(),
|
||||
method: Method::GET,
|
||||
uri: Uri::from_static("/rustfs/admin/v4/runtime/capabilities"),
|
||||
headers: HeaderMap::new(),
|
||||
extensions: Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
};
|
||||
|
||||
let error = RuntimeCapabilitiesHandler {}
|
||||
.call(request, Params::new())
|
||||
.await
|
||||
.expect_err("runtime capabilities must reject unauthenticated requests before capability resolution");
|
||||
assert_eq!(error.code(), &S3ErrorCode::InvalidRequest);
|
||||
}
|
||||
|
||||
/// Authorization denial for this exact action is pinned to AccessDenied by
|
||||
/// `crate::admin::auth::tests::non_admin_credential_is_denied`.
|
||||
#[test]
|
||||
fn runtime_capabilities_gate_requires_server_info_authorization() {
|
||||
assert_eq!(
|
||||
runtime_capabilities_gate_actions(),
|
||||
vec![Action::AdminAction(AdminAction::ServerInfoAdminAction)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user