mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-05 04:47:43 +00:00
1102 lines
42 KiB
Rust
1102 lines
42 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.
|
|
|
|
//! Admin application use-case contracts.
|
|
|
|
use super::storage_api::admin_usecase::admin::get_server_info;
|
|
use super::storage_api::admin_usecase::capacity::{
|
|
PoolDecommissionInfo, PoolStatus, RebalStatus, get_total_usable_capacity, get_total_usable_capacity_free,
|
|
};
|
|
use super::storage_api::admin_usecase::contract::StorageAdminApi;
|
|
use super::storage_api::admin_usecase::contract::bucket::{BucketOperations as _, BucketOptions};
|
|
use super::storage_api::admin_usecase::data_usage::{apply_bucket_usage_memory_overlay, load_data_usage_from_backend_cached};
|
|
use super::storage_api::admin_usecase::{ECStore, EndpointServerPools};
|
|
use crate::app::runtime_sources::{
|
|
AppContext, current_app_context, current_endpoints_handle, current_object_store_handle_for_context,
|
|
};
|
|
use crate::cluster_snapshot::{
|
|
ClusterReadOnlySnapshot, ClusterRuntimeStatusSnapshot, cluster_read_only_snapshot_from_endpoint_pools,
|
|
collect_cluster_read_only_snapshot,
|
|
};
|
|
use crate::error::ApiError;
|
|
use crate::server::{DependencyReadiness, collect_dependency_readiness_report as collect_runtime_dependency_readiness_report};
|
|
use rustfs_data_usage::DataUsageInfo;
|
|
use rustfs_madmin::{InfoMessage, StorageInfo};
|
|
use s3s::S3ErrorCode;
|
|
use std::collections::HashSet;
|
|
use std::sync::Arc;
|
|
use tracing::{debug, error, info, warn};
|
|
|
|
pub type AdminUsecaseResult<T> = Result<T, ApiError>;
|
|
pub const ADMIN_CLUSTER_SNAPSHOT_ROUTE: &str = "/rustfs/admin/v4/cluster/snapshot";
|
|
pub const ADMIN_EXTENSIONS_CATALOG_ROUTE: &str = "/rustfs/admin/v4/extensions/catalog";
|
|
pub const ADMIN_RUNTIME_CAPABILITIES_ROUTE: &str = "/rustfs/admin/v4/runtime/capabilities";
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
pub struct QueryServerInfoRequest {
|
|
pub include_pools: bool,
|
|
}
|
|
|
|
pub struct QueryServerInfoResponse {
|
|
pub info: InfoMessage,
|
|
}
|
|
|
|
impl std::fmt::Debug for QueryServerInfoResponse {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("QueryServerInfoResponse").finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct QueryPoolStatusRequest {
|
|
pub pool: String,
|
|
pub by_id: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct AdminPoolDecommissionInfo {
|
|
#[serde(rename = "startTime", with = "time::serde::rfc3339::option")]
|
|
pub start_time: Option<time::OffsetDateTime>,
|
|
#[serde(rename = "startSize")]
|
|
pub start_size: usize,
|
|
#[serde(rename = "totalSize")]
|
|
pub total_size: usize,
|
|
#[serde(rename = "currentSize")]
|
|
pub current_size: usize,
|
|
#[serde(rename = "complete")]
|
|
pub complete: bool,
|
|
#[serde(rename = "failed")]
|
|
pub failed: bool,
|
|
#[serde(rename = "canceled")]
|
|
pub canceled: bool,
|
|
#[serde(rename = "queued")]
|
|
pub queued: bool,
|
|
#[serde(rename = "queuedBuckets")]
|
|
pub queued_buckets: Vec<String>,
|
|
#[serde(rename = "decommissionedBuckets")]
|
|
pub decommissioned_buckets: Vec<String>,
|
|
#[serde(rename = "bucket")]
|
|
pub bucket: String,
|
|
#[serde(rename = "prefix")]
|
|
pub prefix: String,
|
|
#[serde(rename = "object")]
|
|
pub object: String,
|
|
#[serde(rename = "stage")]
|
|
pub stage: String,
|
|
#[serde(rename = "objectsDecommissioned")]
|
|
pub items_decommissioned: usize,
|
|
#[serde(rename = "objectsDecommissionedFailed")]
|
|
pub items_decommission_failed: usize,
|
|
#[serde(rename = "bytesDecommissioned")]
|
|
pub bytes_done: usize,
|
|
#[serde(rename = "bytesDecommissionedFailed")]
|
|
pub bytes_failed: usize,
|
|
#[serde(rename = "waitingReason")]
|
|
pub waiting_reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct AdminPoolStatus {
|
|
#[serde(rename = "id")]
|
|
pub id: usize,
|
|
#[serde(rename = "cmdline")]
|
|
pub cmd_line: String,
|
|
#[serde(rename = "lastUpdate", with = "time::serde::rfc3339")]
|
|
pub last_update: time::OffsetDateTime,
|
|
#[serde(rename = "totalSize")]
|
|
pub total_size: usize,
|
|
#[serde(rename = "currentSize")]
|
|
pub current_size: usize,
|
|
#[serde(rename = "usedSize")]
|
|
pub used_size: usize,
|
|
#[serde(rename = "used")]
|
|
pub used: f64,
|
|
#[serde(rename = "status")]
|
|
pub status: String,
|
|
#[serde(rename = "decommissionStatus")]
|
|
pub decommission_status: String,
|
|
#[serde(rename = "rebalanceStatus")]
|
|
pub rebalance_status: String,
|
|
#[serde(rename = "decommissionInfo")]
|
|
pub decommission: Option<AdminPoolDecommissionInfo>,
|
|
}
|
|
|
|
pub type AdminPoolListItem = AdminPoolStatus;
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct AdminDecommissionPoolStatus {
|
|
#[serde(rename = "id")]
|
|
pub id: usize,
|
|
#[serde(rename = "cmdline")]
|
|
pub cmd_line: String,
|
|
#[serde(rename = "status")]
|
|
pub status: String,
|
|
#[serde(rename = "poolStatus")]
|
|
pub pool_status: String,
|
|
#[serde(rename = "decommissionInfo")]
|
|
pub decommission: Option<AdminPoolDecommissionInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct AdminDecommissionStatus {
|
|
#[serde(rename = "pools")]
|
|
pub pools: Vec<AdminDecommissionPoolStatus>,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
pub struct DefaultAdminUsecase {
|
|
context: Option<Arc<AppContext>>,
|
|
}
|
|
|
|
impl DefaultAdminUsecase {
|
|
const POOL_STATUS_CANCELED: &'static str = "canceled";
|
|
const POOL_STATUS_COMPLETE: &'static str = "complete";
|
|
const POOL_STATUS_FAILED: &'static str = "failed";
|
|
const POOL_STATUS_QUEUED: &'static str = "queued";
|
|
const POOL_STATUS_RUNNING: &'static str = "running";
|
|
const POOL_STATUS_UNKNOWN: &'static str = "unknown";
|
|
const POOL_STATE_ACTIVE: &'static str = "active";
|
|
const POOL_STATE_BLOCKED: &'static str = "blocked";
|
|
const POOL_STATE_DECOMMISSIONED: &'static str = "decommissioned";
|
|
const POOL_STATE_DECOMMISSIONING: &'static str = "decommissioning";
|
|
const POOL_STATE_REBALANCING: &'static str = "rebalancing";
|
|
const REBALANCE_STATUS_COMPLETED: &'static str = "completed";
|
|
const REBALANCE_STATUS_FAILED: &'static str = "failed";
|
|
const REBALANCE_STATUS_NONE: &'static str = "none";
|
|
const REBALANCE_STATUS_STARTED: &'static str = "started";
|
|
const REBALANCE_STATUS_STOPPING: &'static str = "stopping";
|
|
const REBALANCE_STATUS_STOPPED: &'static str = "stopped";
|
|
|
|
#[cfg(test)]
|
|
pub fn without_context() -> Self {
|
|
Self { context: None }
|
|
}
|
|
|
|
pub fn from_global() -> Self {
|
|
Self {
|
|
context: current_app_context(),
|
|
}
|
|
}
|
|
|
|
fn endpoints(&self) -> Option<EndpointServerPools> {
|
|
self.context.as_ref().and_then(|context| context.endpoints().handle())
|
|
}
|
|
|
|
fn object_store(&self) -> Option<Arc<ECStore>> {
|
|
current_object_store_handle_for_context(self.context.as_deref())
|
|
}
|
|
|
|
fn app_error(code: S3ErrorCode, message: impl Into<String>) -> ApiError {
|
|
ApiError {
|
|
code,
|
|
message: message.into(),
|
|
source: None,
|
|
}
|
|
}
|
|
|
|
fn app_error_default(code: S3ErrorCode) -> ApiError {
|
|
let message = ApiError::error_code_to_message(&code);
|
|
Self::app_error(code, message)
|
|
}
|
|
|
|
fn map_data_usage_load_result<E>(result: Result<DataUsageInfo, E>) -> AdminUsecaseResult<DataUsageInfo> {
|
|
result.map_err(|_| Self::app_error(S3ErrorCode::InternalError, "load_data_usage_from_backend failed"))
|
|
}
|
|
|
|
fn data_usage_snapshot_covers_namespace(info: &DataUsageInfo, buckets: impl IntoIterator<Item = String>) -> bool {
|
|
if !info.is_complete_bucket_usage_snapshot() {
|
|
return false;
|
|
}
|
|
|
|
let current_buckets: HashSet<String> = buckets.into_iter().filter(|bucket| !bucket.starts_with('.')).collect();
|
|
current_buckets.len() == info.buckets_usage.len()
|
|
&& current_buckets.iter().all(|bucket| info.buckets_usage.contains_key(bucket))
|
|
}
|
|
|
|
async fn refresh_rebalance_status_snapshot(store: &ECStore) -> AdminUsecaseResult<()> {
|
|
store.refresh_rebalance_status_meta().await.map_err(|err| {
|
|
error!("refresh rebalance metadata for pool status failed: {:?}", err);
|
|
ApiError::from(err)
|
|
})
|
|
}
|
|
|
|
async fn refresh_pool_status_snapshot(store: &ECStore) -> AdminUsecaseResult<()> {
|
|
store.refresh_pool_status_meta().await.map_err(|err| {
|
|
error!("refresh pool metadata for pool status failed: {:?}", err);
|
|
ApiError::from(err)
|
|
})
|
|
}
|
|
|
|
pub async fn execute_query_server_info(&self, req: QueryServerInfoRequest) -> AdminUsecaseResult<QueryServerInfoResponse> {
|
|
let info = get_server_info(req.include_pools).await;
|
|
Ok(QueryServerInfoResponse { info })
|
|
}
|
|
|
|
pub async fn execute_query_storage_info(&self) -> AdminUsecaseResult<StorageInfo> {
|
|
let Some(store) = self.object_store() else {
|
|
return Err(Self::app_error(S3ErrorCode::InternalError, "Not init"));
|
|
};
|
|
|
|
Ok(StorageAdminApi::storage_info(store.as_ref()).await)
|
|
}
|
|
|
|
pub async fn execute_query_data_usage_info(&self) -> AdminUsecaseResult<DataUsageInfo> {
|
|
let Some(store) = self.object_store() else {
|
|
return Err(Self::app_error(S3ErrorCode::InternalError, "Not init"));
|
|
};
|
|
|
|
Self::query_data_usage_info_with_store(store).await
|
|
}
|
|
|
|
/// Serve the last persisted scanner snapshot plus an authoritative
|
|
/// single-process overlay. Distributed nodes must not promote their
|
|
/// process-local absolute counters to cluster-wide bucket totals.
|
|
/// This path never triggers a live full-version listing
|
|
/// (rustfs/backlog#1306); freshness is owned by the scanner.
|
|
pub(crate) async fn query_data_usage_info_with_store(store: Arc<ECStore>) -> AdminUsecaseResult<DataUsageInfo> {
|
|
let mut info = Self::map_data_usage_load_result(load_data_usage_from_backend_cached(store.clone()).await)?;
|
|
apply_bucket_usage_memory_overlay(&mut info).await;
|
|
let buckets = store
|
|
.list_bucket(&BucketOptions {
|
|
cached: true,
|
|
no_metadata: true,
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.map_err(|_| Self::app_error(S3ErrorCode::InternalError, "list_bucket failed"))?;
|
|
if !Self::data_usage_snapshot_covers_namespace(&info, buckets.into_iter().map(|bucket| bucket.name)) {
|
|
// The default wire shape distinguishes unknown usage from a confirmed zero snapshot.
|
|
info = DataUsageInfo::default();
|
|
}
|
|
|
|
let storage_info = StorageAdminApi::storage_info(store.as_ref()).await;
|
|
|
|
// Keep the same capacity correction behavior as the previous admin handler implementation.
|
|
const MAX_REASONABLE_CAPACITY: u64 = 100_000 * 1024 * 1024 * 1024 * 1024; // 100 PiB
|
|
const MIN_REASONABLE_CAPACITY: u64 = 1024 * 1024 * 1024; // 1 GiB
|
|
|
|
let total_u64 = get_total_usable_capacity(&storage_info.disks, &storage_info) as u64;
|
|
let free_u64 = get_total_usable_capacity_free(&storage_info.disks, &storage_info) as u64;
|
|
|
|
if total_u64 > MAX_REASONABLE_CAPACITY {
|
|
error!(
|
|
"Abnormal total capacity detected: {} bytes ({:.2} TiB), capping to physical capacity",
|
|
total_u64,
|
|
total_u64 as f64 / (1024.0_f64.powi(4))
|
|
);
|
|
|
|
let disk_count = storage_info.disks.len();
|
|
if disk_count > 0 {
|
|
use std::collections::HashSet;
|
|
let unique_disks: HashSet<String> = storage_info
|
|
.disks
|
|
.iter()
|
|
.map(|disk| format!("{}|{}", disk.endpoint, disk.drive_path))
|
|
.collect();
|
|
|
|
let actual_disk_count = unique_disks.len();
|
|
|
|
if let Some(first_disk) = storage_info.disks.first() {
|
|
info.total_capacity = first_disk.total_space * actual_disk_count as u64;
|
|
info.total_free_capacity = first_disk.available_space * actual_disk_count as u64;
|
|
|
|
info!(
|
|
"Applied capacity correction: {} unique disks, capacity per disk: {} bytes",
|
|
actual_disk_count, first_disk.total_space
|
|
);
|
|
} else {
|
|
info.total_capacity = 0;
|
|
info.total_free_capacity = 0;
|
|
}
|
|
} else {
|
|
info.total_capacity = 0;
|
|
info.total_free_capacity = 0;
|
|
}
|
|
} else if total_u64 < MIN_REASONABLE_CAPACITY && total_u64 > 0 {
|
|
warn!(
|
|
"Unusually small total capacity: {} bytes ({:.2} GiB)",
|
|
total_u64,
|
|
total_u64 as f64 / (1024.0_f64.powi(3))
|
|
);
|
|
info.total_capacity = total_u64;
|
|
info.total_free_capacity = free_u64;
|
|
} else {
|
|
info.total_capacity = total_u64;
|
|
info.total_free_capacity = free_u64;
|
|
}
|
|
|
|
info.total_used_capacity = info.total_capacity.saturating_sub(info.total_free_capacity);
|
|
debug!(
|
|
"Capacity statistics: total={:.2} TiB, free={:.2} TiB, used={:.2} TiB",
|
|
info.total_capacity as f64 / (1024.0_f64.powi(4)),
|
|
info.total_free_capacity as f64 / (1024.0_f64.powi(4)),
|
|
info.total_used_capacity as f64 / (1024.0_f64.powi(4))
|
|
);
|
|
|
|
Ok(info)
|
|
}
|
|
|
|
pub async fn execute_list_pool_statuses(&self) -> AdminUsecaseResult<Vec<PoolStatus>> {
|
|
let Some(store) = self.object_store() else {
|
|
return Err(Self::app_error(S3ErrorCode::InternalError, "Not init"));
|
|
};
|
|
|
|
let Some(endpoints) = self.endpoints() else {
|
|
return Err(Self::app_error_default(S3ErrorCode::NotImplemented));
|
|
};
|
|
|
|
Self::refresh_pool_status_snapshot(store.as_ref()).await?;
|
|
|
|
let mut pool_statuses = Vec::new();
|
|
for (idx, _) in endpoints.as_ref().iter().enumerate() {
|
|
let state = store.status(idx).await.map_err(ApiError::from)?;
|
|
pool_statuses.push(state);
|
|
}
|
|
|
|
Ok(pool_statuses)
|
|
}
|
|
|
|
pub async fn execute_list_pools(&self) -> AdminUsecaseResult<Vec<AdminPoolListItem>> {
|
|
let Some(store) = self.object_store() else {
|
|
return Err(Self::app_error(S3ErrorCode::InternalError, "Not init"));
|
|
};
|
|
let pool_statuses = self.execute_list_pool_statuses().await?;
|
|
Self::refresh_rebalance_status_snapshot(store.as_ref()).await?;
|
|
let mut items = Vec::with_capacity(pool_statuses.len());
|
|
for status in pool_statuses {
|
|
let rebalance_status = store.pool_rebalance_status(status.id).await;
|
|
items.push(Self::pool_list_item_from_status(status, rebalance_status));
|
|
}
|
|
Ok(items)
|
|
}
|
|
|
|
fn resolve_pool_index(&self, req: &QueryPoolStatusRequest, endpoints: &EndpointServerPools) -> AdminUsecaseResult<usize> {
|
|
let has_idx = if req.by_id {
|
|
Self::parse_pool_idx_by_id(&req.pool, endpoints.as_ref().len())
|
|
} else {
|
|
endpoints.get_pool_idx(&req.pool)
|
|
};
|
|
|
|
let Some(idx) = has_idx else {
|
|
warn!("specified pool {} not found, please specify a valid pool", req.pool);
|
|
return Err(Self::app_error_default(S3ErrorCode::InvalidArgument));
|
|
};
|
|
|
|
Ok(idx)
|
|
}
|
|
|
|
pub async fn execute_query_pool_status(&self, req: QueryPoolStatusRequest) -> AdminUsecaseResult<AdminPoolStatus> {
|
|
let Some(endpoints) = self.endpoints() else {
|
|
return Err(Self::app_error_default(S3ErrorCode::NotImplemented));
|
|
};
|
|
|
|
if endpoints.legacy() {
|
|
return Err(Self::app_error_default(S3ErrorCode::NotImplemented));
|
|
}
|
|
|
|
let idx = self.resolve_pool_index(&req, &endpoints)?;
|
|
|
|
let Some(store) = self.object_store() else {
|
|
return Err(Self::app_error(S3ErrorCode::InternalError, "Not init"));
|
|
};
|
|
|
|
Self::refresh_pool_status_snapshot(store.as_ref()).await?;
|
|
let status = store.status(idx).await.map_err(ApiError::from)?;
|
|
Self::refresh_rebalance_status_snapshot(store.as_ref()).await?;
|
|
let rebalance_status = store.pool_rebalance_status(idx).await;
|
|
Ok(Self::pool_list_item_from_status(status, rebalance_status))
|
|
}
|
|
|
|
pub async fn execute_list_decommission_status(&self) -> AdminUsecaseResult<AdminDecommissionStatus> {
|
|
let Some(store) = self.object_store() else {
|
|
return Err(Self::app_error(S3ErrorCode::InternalError, "Not init"));
|
|
};
|
|
let Some(endpoints) = self.endpoints() else {
|
|
return Err(Self::app_error_default(S3ErrorCode::NotImplemented));
|
|
};
|
|
if endpoints.legacy() {
|
|
return Err(Self::app_error_default(S3ErrorCode::NotImplemented));
|
|
}
|
|
let pool_statuses = self.execute_list_pool_statuses().await?;
|
|
Self::refresh_rebalance_status_snapshot(store.as_ref()).await?;
|
|
let mut pools = Vec::with_capacity(pool_statuses.len());
|
|
for status in pool_statuses {
|
|
let rebalance_status = store.pool_rebalance_status(status.id).await;
|
|
pools.push(Self::decommission_pool_status_from_status(status, rebalance_status));
|
|
}
|
|
Ok(AdminDecommissionStatus { pools })
|
|
}
|
|
|
|
pub async fn execute_query_decommission_status(
|
|
&self,
|
|
req: QueryPoolStatusRequest,
|
|
) -> AdminUsecaseResult<AdminDecommissionPoolStatus> {
|
|
let Some(endpoints) = self.endpoints() else {
|
|
return Err(Self::app_error_default(S3ErrorCode::NotImplemented));
|
|
};
|
|
|
|
if endpoints.legacy() {
|
|
return Err(Self::app_error_default(S3ErrorCode::NotImplemented));
|
|
}
|
|
|
|
let idx = self.resolve_pool_index(&req, &endpoints)?;
|
|
|
|
let Some(store) = self.object_store() else {
|
|
return Err(Self::app_error(S3ErrorCode::InternalError, "Not init"));
|
|
};
|
|
|
|
Self::refresh_pool_status_snapshot(store.as_ref()).await?;
|
|
let status = store.status(idx).await.map_err(ApiError::from)?;
|
|
Self::refresh_rebalance_status_snapshot(store.as_ref()).await?;
|
|
let rebalance_status = store.pool_rebalance_status(idx).await;
|
|
Ok(Self::decommission_pool_status_from_status(status, rebalance_status))
|
|
}
|
|
|
|
fn pool_list_item_from_status(status: PoolStatus, rebalance_status: (RebalStatus, bool)) -> AdminPoolListItem {
|
|
let PoolStatus {
|
|
id,
|
|
cmd_line,
|
|
last_update,
|
|
decommission,
|
|
} = status;
|
|
let total_size = decommission.as_ref().map(|info| info.total_size).unwrap_or_default();
|
|
let current_size = decommission.as_ref().map(|info| info.current_size).unwrap_or_default();
|
|
let used_size = total_size.saturating_sub(current_size);
|
|
let decommission = decommission.filter(PoolDecommissionInfo::has_decommission_state);
|
|
let decommission_status = Self::pool_decommission_status(decommission.as_ref());
|
|
let rebalance_status = Self::pool_rebalance_status(rebalance_status);
|
|
let pool_state = Self::pool_lifecycle_state(decommission.as_ref(), rebalance_status);
|
|
|
|
AdminPoolStatus {
|
|
id,
|
|
cmd_line,
|
|
last_update,
|
|
total_size,
|
|
current_size,
|
|
used_size,
|
|
used: Self::used_ratio(total_size, used_size),
|
|
status: pool_state.to_string(),
|
|
decommission_status: decommission_status.to_string(),
|
|
rebalance_status: rebalance_status.to_string(),
|
|
decommission: decommission.map(Self::admin_decommission_info_from_pool),
|
|
}
|
|
}
|
|
|
|
fn pool_lifecycle_state(decommission: Option<&PoolDecommissionInfo>, rebalance_status: &'static str) -> &'static str {
|
|
match decommission {
|
|
Some(info) if info.complete => Self::POOL_STATE_DECOMMISSIONED,
|
|
Some(info) if info.failed || info.canceled => Self::POOL_STATE_BLOCKED,
|
|
Some(info) if !info.has_decommission_state() && Self::rebalance_status_is_active(rebalance_status) => {
|
|
Self::POOL_STATE_REBALANCING
|
|
}
|
|
Some(info) if !info.has_decommission_state() => Self::POOL_STATE_ACTIVE,
|
|
Some(_) => Self::POOL_STATE_DECOMMISSIONING,
|
|
None if Self::rebalance_status_is_active(rebalance_status) => Self::POOL_STATE_REBALANCING,
|
|
None => Self::POOL_STATE_ACTIVE,
|
|
}
|
|
}
|
|
|
|
fn pool_decommission_status(decommission: Option<&PoolDecommissionInfo>) -> &'static str {
|
|
match decommission {
|
|
Some(info) if info.complete => Self::POOL_STATUS_COMPLETE,
|
|
Some(info) if info.failed => Self::POOL_STATUS_FAILED,
|
|
Some(info) if info.canceled => Self::POOL_STATUS_CANCELED,
|
|
Some(info) if info.queued => Self::POOL_STATUS_QUEUED,
|
|
Some(info) if info.start_time.is_some() => Self::POOL_STATUS_RUNNING,
|
|
Some(info) if !info.has_decommission_state() => Self::REBALANCE_STATUS_NONE,
|
|
Some(_) => Self::POOL_STATUS_UNKNOWN,
|
|
None => Self::REBALANCE_STATUS_NONE,
|
|
}
|
|
}
|
|
|
|
fn rebalance_status_is_active(status: &'static str) -> bool {
|
|
matches!(status, Self::REBALANCE_STATUS_STARTED | Self::REBALANCE_STATUS_STOPPING)
|
|
}
|
|
|
|
fn pool_rebalance_status((status, stopping): (RebalStatus, bool)) -> &'static str {
|
|
if stopping {
|
|
return Self::REBALANCE_STATUS_STOPPING;
|
|
}
|
|
|
|
match status {
|
|
RebalStatus::None => Self::REBALANCE_STATUS_NONE,
|
|
RebalStatus::Started => Self::REBALANCE_STATUS_STARTED,
|
|
RebalStatus::Completed => Self::REBALANCE_STATUS_COMPLETED,
|
|
RebalStatus::Stopped => Self::REBALANCE_STATUS_STOPPED,
|
|
RebalStatus::Failed => Self::REBALANCE_STATUS_FAILED,
|
|
}
|
|
}
|
|
|
|
fn decommission_pool_status_from_status(
|
|
status: PoolStatus,
|
|
rebalance_status: (RebalStatus, bool),
|
|
) -> AdminDecommissionPoolStatus {
|
|
let PoolStatus {
|
|
id,
|
|
cmd_line,
|
|
decommission,
|
|
..
|
|
} = status;
|
|
let decommission = decommission.filter(PoolDecommissionInfo::has_decommission_state);
|
|
let status = Self::pool_decommission_status(decommission.as_ref()).to_string();
|
|
let rebalance_status = Self::pool_rebalance_status(rebalance_status);
|
|
let pool_status = Self::pool_lifecycle_state(decommission.as_ref(), rebalance_status).to_string();
|
|
|
|
AdminDecommissionPoolStatus {
|
|
id,
|
|
cmd_line,
|
|
status,
|
|
pool_status,
|
|
decommission: decommission.map(Self::admin_decommission_info_from_pool),
|
|
}
|
|
}
|
|
|
|
fn admin_decommission_info_from_pool(info: PoolDecommissionInfo) -> AdminPoolDecommissionInfo {
|
|
let waiting_reason = Self::decommission_waiting_reason(&info).map(str::to_string);
|
|
AdminPoolDecommissionInfo {
|
|
start_time: info.start_time,
|
|
start_size: info.start_size,
|
|
total_size: info.total_size,
|
|
current_size: info.current_size,
|
|
complete: info.complete,
|
|
failed: info.failed,
|
|
canceled: info.canceled,
|
|
queued: info.queued,
|
|
queued_buckets: info.queued_buckets,
|
|
decommissioned_buckets: info.decommissioned_buckets,
|
|
bucket: info.bucket,
|
|
prefix: info.prefix,
|
|
object: info.object,
|
|
stage: info.stage,
|
|
items_decommissioned: info.items_decommissioned,
|
|
items_decommission_failed: info.items_decommission_failed,
|
|
bytes_done: info.bytes_done,
|
|
bytes_failed: info.bytes_failed,
|
|
waiting_reason,
|
|
}
|
|
}
|
|
|
|
fn decommission_waiting_reason(info: &PoolDecommissionInfo) -> Option<&'static str> {
|
|
if !info.has_decommission_state() || info.complete || info.failed || info.canceled || info.start_time.is_some() {
|
|
return None;
|
|
}
|
|
if info.queued {
|
|
return Some("queued");
|
|
}
|
|
Some("waiting_for_worker")
|
|
}
|
|
|
|
fn used_ratio(total_size: usize, used_size: usize) -> f64 {
|
|
if total_size == 0 {
|
|
return 0.0;
|
|
}
|
|
|
|
used_size as f64 / total_size as f64
|
|
}
|
|
|
|
fn parse_pool_idx_by_id(pool: &str, endpoint_count: usize) -> Option<usize> {
|
|
let idx = pool.parse::<usize>().ok()?;
|
|
(idx < endpoint_count).then_some(idx)
|
|
}
|
|
|
|
pub async fn execute_collect_dependency_readiness(&self) -> DependencyReadiness {
|
|
let report = collect_runtime_dependency_readiness_report().await;
|
|
if let Some(endpoint_pools) = current_endpoints_handle() {
|
|
let runtime_status = ClusterRuntimeStatusSnapshot::from_readiness_report(report);
|
|
return cluster_read_only_snapshot_from_endpoint_pools(&endpoint_pools, runtime_status)
|
|
.runtime_status
|
|
.readiness;
|
|
}
|
|
report.readiness
|
|
}
|
|
|
|
pub async fn execute_collect_cluster_read_only_snapshot(&self) -> Option<ClusterReadOnlySnapshot> {
|
|
let endpoint_pools = current_endpoints_handle()?;
|
|
collect_cluster_read_only_snapshot(&endpoint_pools).await
|
|
}
|
|
|
|
pub fn cluster_snapshot_route(&self) -> &'static str {
|
|
ADMIN_CLUSTER_SNAPSHOT_ROUTE
|
|
}
|
|
|
|
pub fn runtime_capabilities_route(&self) -> &'static str {
|
|
ADMIN_RUNTIME_CAPABILITIES_ROUTE
|
|
}
|
|
|
|
pub fn extensions_catalog_route(&self) -> &'static str {
|
|
ADMIN_EXTENSIONS_CATALOG_ROUTE
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::super::storage_api::admin_usecase::capacity::{PoolDecommissionInfo, PoolStatus};
|
|
use super::*;
|
|
use time::OffsetDateTime;
|
|
use tracing_subscriber::{Layer, Registry, layer::Context, prelude::*};
|
|
|
|
struct RejectEvents;
|
|
|
|
impl<S> Layer<S> for RejectEvents
|
|
where
|
|
S: tracing::Subscriber,
|
|
{
|
|
fn on_event(&self, _event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
|
|
panic!("data usage error mapping must not emit a duplicate event");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn data_usage_load_error_is_generic_and_not_logged_again() {
|
|
let subscriber = Registry::default().with(RejectEvents);
|
|
|
|
tracing::subscriber::with_default(subscriber, || {
|
|
let err = DefaultAdminUsecase::map_data_usage_load_result::<&str>(Err("sensitive disk path"))
|
|
.expect_err("load failure must reach the response");
|
|
assert_eq!(err.code, S3ErrorCode::InternalError);
|
|
assert_eq!(err.message, "load_data_usage_from_backend failed");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn data_usage_snapshot_must_cover_the_current_bucket_namespace() {
|
|
let mut info = DataUsageInfo {
|
|
last_update: Some(std::time::SystemTime::UNIX_EPOCH),
|
|
usage_snapshot_complete: true,
|
|
buckets_count: 1,
|
|
..Default::default()
|
|
};
|
|
info.buckets_usage.insert("bucket-a".to_string(), Default::default());
|
|
|
|
assert!(DefaultAdminUsecase::data_usage_snapshot_covers_namespace(
|
|
&info,
|
|
["bucket-a".to_string(), ".rustfs.sys".to_string()]
|
|
));
|
|
assert!(!DefaultAdminUsecase::data_usage_snapshot_covers_namespace(
|
|
&info,
|
|
["bucket-a".to_string(), "bucket-b".to_string()]
|
|
));
|
|
|
|
info.usage_snapshot_complete = false;
|
|
assert!(!DefaultAdminUsecase::data_usage_snapshot_covers_namespace(
|
|
&info,
|
|
["bucket-a".to_string()]
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_query_storage_info_returns_internal_error_when_store_uninitialized() {
|
|
let usecase = DefaultAdminUsecase::without_context();
|
|
|
|
let err = usecase.execute_query_storage_info().await.unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InternalError);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_query_data_usage_info_returns_internal_error_when_store_uninitialized() {
|
|
let usecase = DefaultAdminUsecase::without_context();
|
|
|
|
let err = usecase.execute_query_data_usage_info().await.unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InternalError);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_collect_dependency_readiness_returns_state_flags() {
|
|
let usecase = DefaultAdminUsecase::without_context();
|
|
|
|
let readiness = usecase.execute_collect_dependency_readiness().await;
|
|
let _ = readiness.storage_ready;
|
|
let _ = readiness.iam_ready;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_collect_cluster_read_only_snapshot_returns_none_without_context() {
|
|
let usecase = DefaultAdminUsecase::without_context();
|
|
|
|
let snapshot = usecase.execute_collect_cluster_read_only_snapshot().await;
|
|
|
|
assert!(snapshot.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn admin_usecase_exposes_stable_discovery_routes() {
|
|
let usecase = DefaultAdminUsecase::without_context();
|
|
|
|
assert_eq!(usecase.cluster_snapshot_route(), "/rustfs/admin/v4/cluster/snapshot");
|
|
assert_eq!(usecase.runtime_capabilities_route(), "/rustfs/admin/v4/runtime/capabilities");
|
|
assert_eq!(usecase.extensions_catalog_route(), "/rustfs/admin/v4/extensions/catalog");
|
|
}
|
|
|
|
#[test]
|
|
fn admin_query_pool_status_by_id_rejects_non_numeric_index() {
|
|
assert_eq!(DefaultAdminUsecase::parse_pool_idx_by_id("pool-a", 4), None);
|
|
}
|
|
|
|
#[test]
|
|
fn admin_query_pool_status_by_id_rejects_out_of_range_index() {
|
|
assert_eq!(DefaultAdminUsecase::parse_pool_idx_by_id("4", 4), None);
|
|
}
|
|
|
|
#[test]
|
|
fn admin_query_pool_status_by_id_accepts_valid_index() {
|
|
assert_eq!(DefaultAdminUsecase::parse_pool_idx_by_id("0", 4), Some(0));
|
|
}
|
|
|
|
#[test]
|
|
fn admin_pool_list_item_maps_capacity_without_decommission_state_as_active() {
|
|
let now = OffsetDateTime::UNIX_EPOCH;
|
|
let pool = PoolStatus {
|
|
id: 2,
|
|
cmd_line: "http://node{1...4}/disk{1...4}".to_string(),
|
|
last_update: now,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
total_size: 1_000,
|
|
current_size: 250,
|
|
..Default::default()
|
|
}),
|
|
};
|
|
|
|
let item = DefaultAdminUsecase::pool_list_item_from_status(pool, (RebalStatus::None, false));
|
|
|
|
assert_eq!(item.id, 2);
|
|
assert_eq!(item.total_size, 1_000);
|
|
assert_eq!(item.current_size, 250);
|
|
assert_eq!(item.used_size, 750);
|
|
assert!((item.used - 0.75).abs() < f64::EPSILON);
|
|
assert_eq!(item.status, "active");
|
|
assert_eq!(item.decommission_status, "none");
|
|
assert_eq!(item.rebalance_status, "none");
|
|
assert!(item.decommission.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn admin_pool_list_item_maps_inconsistent_decommission_progress_as_unknown() {
|
|
let item = DefaultAdminUsecase::pool_list_item_from_status(
|
|
PoolStatus {
|
|
id: 2,
|
|
cmd_line: "http://node{1...4}/disk{1...4}".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
total_size: 1_000,
|
|
current_size: 250,
|
|
items_decommissioned: 1,
|
|
..Default::default()
|
|
}),
|
|
},
|
|
(RebalStatus::None, false),
|
|
);
|
|
|
|
assert_eq!(item.status, "decommissioning");
|
|
assert_eq!(item.decommission_status, "unknown");
|
|
assert!(item.decommission.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn admin_pool_list_item_serializes_admin_api_fields() {
|
|
let item = DefaultAdminUsecase::pool_list_item_from_status(
|
|
PoolStatus {
|
|
id: 1,
|
|
cmd_line: "pool-1".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
},
|
|
(RebalStatus::Completed, false),
|
|
);
|
|
|
|
let value = serde_json::to_value(item).unwrap();
|
|
|
|
assert_eq!(
|
|
value,
|
|
serde_json::json!({
|
|
"id": 1,
|
|
"cmdline": "pool-1",
|
|
"lastUpdate": "1970-01-01T00:00:00Z",
|
|
"totalSize": 0,
|
|
"currentSize": 0,
|
|
"usedSize": 0,
|
|
"used": 0.0,
|
|
"status": "active",
|
|
"decommissionStatus": "none",
|
|
"rebalanceStatus": "completed",
|
|
"decommissionInfo": null
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn admin_pool_list_item_saturates_used_size_when_current_exceeds_total() {
|
|
let pool = PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
total_size: 100,
|
|
current_size: 150,
|
|
..Default::default()
|
|
}),
|
|
};
|
|
|
|
let item = DefaultAdminUsecase::pool_list_item_from_status(pool, (RebalStatus::None, false));
|
|
|
|
assert_eq!(item.total_size, 100);
|
|
assert_eq!(item.current_size, 150);
|
|
assert_eq!(item.used_size, 0);
|
|
assert_eq!(item.used, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn admin_pool_list_item_maps_running_decommission_status() {
|
|
let pool = PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
total_size: 1_000,
|
|
current_size: 500,
|
|
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
..Default::default()
|
|
}),
|
|
};
|
|
|
|
let item = DefaultAdminUsecase::pool_list_item_from_status(pool, (RebalStatus::Started, false));
|
|
|
|
assert_eq!(item.status, "decommissioning");
|
|
assert_eq!(item.decommission_status, "running");
|
|
assert_eq!(item.rebalance_status, "started");
|
|
}
|
|
|
|
#[test]
|
|
fn admin_pool_list_item_keeps_decommission_state_ahead_of_rebalance_state() {
|
|
let item = DefaultAdminUsecase::pool_list_item_from_status(
|
|
PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
..Default::default()
|
|
}),
|
|
},
|
|
(RebalStatus::Started, false),
|
|
);
|
|
|
|
assert_eq!(item.status, "blocked");
|
|
assert_eq!(item.decommission_status, "failed");
|
|
assert_eq!(item.rebalance_status, "started");
|
|
}
|
|
|
|
#[test]
|
|
fn admin_pool_list_item_exposes_queued_decommission_state() {
|
|
let item = DefaultAdminUsecase::pool_list_item_from_status(
|
|
PoolStatus {
|
|
id: 3,
|
|
cmd_line: "pool-3".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
queued: true,
|
|
queued_buckets: vec!["bucket-a".to_string(), ".rustfs.sys/config".to_string()],
|
|
decommissioned_buckets: vec!["bucket-done".to_string()],
|
|
bucket: "bucket-a".to_string(),
|
|
prefix: "prefix/".to_string(),
|
|
object: "object.txt".to_string(),
|
|
stage: "migrate_object".to_string(),
|
|
items_decommissioned: 7,
|
|
items_decommission_failed: 1,
|
|
bytes_done: 1024,
|
|
bytes_failed: 64,
|
|
..Default::default()
|
|
}),
|
|
},
|
|
(RebalStatus::None, false),
|
|
);
|
|
|
|
assert_eq!(item.status, "decommissioning");
|
|
assert_eq!(item.decommission_status, "queued");
|
|
let value = serde_json::to_value(item).expect("admin pool status should serialize");
|
|
assert_eq!(value["decommissionInfo"]["queued"], true);
|
|
assert_eq!(
|
|
value["decommissionInfo"]["queuedBuckets"],
|
|
serde_json::json!(["bucket-a", ".rustfs.sys/config"])
|
|
);
|
|
assert_eq!(value["decommissionInfo"]["decommissionedBuckets"], serde_json::json!(["bucket-done"]));
|
|
assert_eq!(value["decommissionInfo"]["bucket"], "bucket-a");
|
|
assert_eq!(value["decommissionInfo"]["prefix"], "prefix/");
|
|
assert_eq!(value["decommissionInfo"]["object"], "object.txt");
|
|
assert_eq!(value["decommissionInfo"]["stage"], "migrate_object");
|
|
assert_eq!(value["decommissionInfo"]["objectsDecommissioned"], 7);
|
|
assert_eq!(value["decommissionInfo"]["objectsDecommissionedFailed"], 1);
|
|
assert_eq!(value["decommissionInfo"]["bytesDecommissioned"], 1024);
|
|
assert_eq!(value["decommissionInfo"]["bytesDecommissionedFailed"], 64);
|
|
assert_eq!(value["decommissionInfo"]["waitingReason"], "queued");
|
|
}
|
|
|
|
#[test]
|
|
fn admin_pool_list_item_maps_terminal_decommission_statuses() {
|
|
let complete = DefaultAdminUsecase::pool_decommission_status(Some(&PoolDecommissionInfo {
|
|
complete: true,
|
|
..Default::default()
|
|
}));
|
|
let failed = DefaultAdminUsecase::pool_decommission_status(Some(&PoolDecommissionInfo {
|
|
failed: true,
|
|
..Default::default()
|
|
}));
|
|
let canceled = DefaultAdminUsecase::pool_decommission_status(Some(&PoolDecommissionInfo {
|
|
canceled: true,
|
|
..Default::default()
|
|
}));
|
|
let queued = DefaultAdminUsecase::pool_decommission_status(Some(&PoolDecommissionInfo {
|
|
queued: true,
|
|
..Default::default()
|
|
}));
|
|
let idle = DefaultAdminUsecase::pool_decommission_status(None);
|
|
|
|
assert_eq!(complete, "complete");
|
|
assert_eq!(failed, "failed");
|
|
assert_eq!(canceled, "canceled");
|
|
assert_eq!(queued, "queued");
|
|
assert_eq!(idle, "none");
|
|
}
|
|
|
|
#[test]
|
|
fn admin_pool_list_item_keeps_rebalance_failure_separate_from_pool_state() {
|
|
let item = DefaultAdminUsecase::pool_list_item_from_status(
|
|
PoolStatus {
|
|
id: 1,
|
|
cmd_line: "pool-1".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
},
|
|
(RebalStatus::Failed, false),
|
|
);
|
|
|
|
assert_eq!(item.status, "active");
|
|
assert_eq!(item.decommission_status, "none");
|
|
assert_eq!(item.rebalance_status, "failed");
|
|
}
|
|
|
|
#[test]
|
|
fn admin_pool_list_item_maps_started_rebalance_to_pool_rebalancing() {
|
|
let item = DefaultAdminUsecase::pool_list_item_from_status(
|
|
PoolStatus {
|
|
id: 1,
|
|
cmd_line: "pool-1".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
},
|
|
(RebalStatus::Started, false),
|
|
);
|
|
|
|
assert_eq!(item.status, "rebalancing");
|
|
assert_eq!(item.decommission_status, "none");
|
|
assert_eq!(item.rebalance_status, "started");
|
|
}
|
|
|
|
#[test]
|
|
fn admin_pool_list_item_maps_stopping_rebalance_status() {
|
|
let item = DefaultAdminUsecase::pool_list_item_from_status(
|
|
PoolStatus {
|
|
id: 1,
|
|
cmd_line: "pool-1".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
},
|
|
(RebalStatus::Started, true),
|
|
);
|
|
|
|
assert_eq!(item.status, "rebalancing");
|
|
assert_eq!(item.decommission_status, "none");
|
|
assert_eq!(item.rebalance_status, "stopping");
|
|
}
|
|
|
|
#[test]
|
|
fn admin_pool_lifecycle_state_distinguishes_decommission_terminal_states() {
|
|
let complete = DefaultAdminUsecase::pool_lifecycle_state(
|
|
Some(&PoolDecommissionInfo {
|
|
complete: true,
|
|
..Default::default()
|
|
}),
|
|
DefaultAdminUsecase::REBALANCE_STATUS_NONE,
|
|
);
|
|
let failed = DefaultAdminUsecase::pool_lifecycle_state(
|
|
Some(&PoolDecommissionInfo {
|
|
failed: true,
|
|
..Default::default()
|
|
}),
|
|
DefaultAdminUsecase::REBALANCE_STATUS_NONE,
|
|
);
|
|
let canceled = DefaultAdminUsecase::pool_lifecycle_state(
|
|
Some(&PoolDecommissionInfo {
|
|
canceled: true,
|
|
..Default::default()
|
|
}),
|
|
DefaultAdminUsecase::REBALANCE_STATUS_NONE,
|
|
);
|
|
let rebalancing = DefaultAdminUsecase::pool_lifecycle_state(None, DefaultAdminUsecase::REBALANCE_STATUS_STARTED);
|
|
|
|
assert_eq!(complete, "decommissioned");
|
|
assert_eq!(failed, "blocked");
|
|
assert_eq!(canceled, "blocked");
|
|
assert_eq!(rebalancing, "rebalancing");
|
|
}
|
|
|
|
#[test]
|
|
fn admin_decommission_status_serializes_task_status_and_pool_status() {
|
|
let item = DefaultAdminUsecase::decommission_pool_status_from_status(
|
|
PoolStatus {
|
|
id: 3,
|
|
cmd_line: "pool-3".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
..Default::default()
|
|
}),
|
|
},
|
|
(RebalStatus::Started, false),
|
|
);
|
|
|
|
let value = serde_json::to_value(item).expect("decommission status should serialize");
|
|
|
|
assert_eq!(
|
|
value,
|
|
serde_json::json!({
|
|
"id": 3,
|
|
"cmdline": "pool-3",
|
|
"status": "failed",
|
|
"poolStatus": "blocked",
|
|
"decommissionInfo": {
|
|
"startTime": null,
|
|
"startSize": 0,
|
|
"totalSize": 0,
|
|
"currentSize": 0,
|
|
"complete": false,
|
|
"failed": true,
|
|
"canceled": false,
|
|
"queued": false,
|
|
"queuedBuckets": [],
|
|
"decommissionedBuckets": [],
|
|
"bucket": "",
|
|
"prefix": "",
|
|
"object": "",
|
|
"stage": "",
|
|
"objectsDecommissioned": 0,
|
|
"objectsDecommissionedFailed": 0,
|
|
"bytesDecommissioned": 0,
|
|
"bytesDecommissionedFailed": 0,
|
|
"waitingReason": null
|
|
}
|
|
})
|
|
);
|
|
}
|
|
}
|