mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-18 10:43:15 +00:00
a66350b645
* refactor: remove app admin secondary compat bridges * refactor: remove storage core compat bridge * refactor: remove nested compat bridges * refactor: remove admin handlers compat bridge * refactor: remove runtime local compat bridges
1276 lines
48 KiB
Rust
1276 lines
48 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 super::super::storage_compat::is_reserved_or_invalid_bucket;
|
|
use super::super::storage_compat::utils::is_valid_object_prefix;
|
|
use crate::admin::auth::{authenticate_request, validate_admin_request};
|
|
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
|
use crate::app::context::resolve_object_store_handle;
|
|
use crate::server::ADMIN_PREFIX;
|
|
use crate::server::RemoteAddr;
|
|
use crate::storage::request_context::spawn_traced;
|
|
use bytes::Bytes;
|
|
use http::{HeaderMap, HeaderValue, Uri};
|
|
use hyper::{Method, StatusCode};
|
|
use matchit::Params;
|
|
use rustfs_common::heal_channel::{HealChannelPriority, HealChannelRequest, HealOpts, HealRequestSource};
|
|
use rustfs_config::MAX_HEAL_REQUEST_SIZE;
|
|
use rustfs_heal::heal::utils::format_set_disk_id;
|
|
use rustfs_policy::policy::action::{Action, AdminAction};
|
|
use rustfs_scanner::scanner::{BackgroundHealInfo, read_background_heal_info};
|
|
use rustfs_storage_api::HealOperations as _;
|
|
use rustfs_utils::path::path_join;
|
|
use s3s::header::{CONTENT_LENGTH, CONTENT_TYPE};
|
|
use s3s::{Body, S3Request, S3Response, S3Result, s3_error};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashSet;
|
|
use std::path::PathBuf;
|
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
|
use tokio::sync::mpsc;
|
|
use tracing::{info, warn};
|
|
|
|
const LOG_COMPONENT_ADMIN_API: &str = "admin_api";
|
|
const LOG_SUBSYSTEM_HEAL_ADMIN: &str = "heal_admin";
|
|
const EVENT_ADMIN_REQUEST_REJECTED: &str = "admin_request_rejected";
|
|
const EVENT_ADMIN_REQUEST_FAILED: &str = "admin_request_failed";
|
|
const EVENT_ADMIN_RESPONSE_EMITTED: &str = "admin_response_emitted";
|
|
|
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
|
struct HealInitParams {
|
|
bucket: String,
|
|
obj_prefix: String,
|
|
hs: HealOpts,
|
|
client_token: String,
|
|
force_start: bool,
|
|
force_stop: bool,
|
|
}
|
|
|
|
fn extract_heal_init_params(body: &Bytes, uri: &Uri, params: Params<'_, '_>) -> S3Result<HealInitParams> {
|
|
let mut hip = HealInitParams {
|
|
bucket: params.get("bucket").map(|s| s.to_string()).unwrap_or_default(),
|
|
obj_prefix: params.get("prefix").map(|s| s.to_string()).unwrap_or_default(),
|
|
..Default::default()
|
|
};
|
|
validate_heal_target(&hip.bucket, &hip.obj_prefix)?;
|
|
|
|
if let Some(query) = uri.query() {
|
|
let mut seen = HashSet::with_capacity(3);
|
|
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
|
|
match key.as_ref() {
|
|
"clientToken" => {
|
|
if !seen.insert("clientToken") {
|
|
return Err(s3_error!(InvalidArgument, "duplicate heal query parameter"));
|
|
}
|
|
hip.client_token = value.into_owned();
|
|
}
|
|
"forceStart" => {
|
|
if !seen.insert("forceStart") {
|
|
return Err(s3_error!(InvalidArgument, "duplicate heal query parameter"));
|
|
}
|
|
hip.force_start = parse_heal_query_bool(value.as_ref())?;
|
|
}
|
|
"forceStop" => {
|
|
if !seen.insert("forceStop") {
|
|
return Err(s3_error!(InvalidArgument, "duplicate heal query parameter"));
|
|
}
|
|
hip.force_stop = parse_heal_query_bool(value.as_ref())?;
|
|
}
|
|
_ => return Err(s3_error!(InvalidArgument, "unknown heal query parameter")),
|
|
}
|
|
}
|
|
}
|
|
|
|
if hip.force_start && (hip.force_stop || !hip.client_token.is_empty()) {
|
|
return Err(s3_error!(
|
|
InvalidRequest,
|
|
"invalid combination of clientToken, forceStart, and forceStop parameters"
|
|
));
|
|
}
|
|
|
|
if hip.client_token.is_empty() {
|
|
hip.hs = serde_json::from_slice(body).map_err(|e| {
|
|
warn!(
|
|
event = EVENT_ADMIN_REQUEST_REJECTED,
|
|
component = LOG_COMPONENT_ADMIN_API,
|
|
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
|
operation = "heal_request_init",
|
|
result = "rejected",
|
|
reason = "request_body_parse_failed",
|
|
error = ?e,
|
|
"admin request rejected"
|
|
);
|
|
s3_error!(InvalidRequest, "err request body parse")
|
|
})?;
|
|
}
|
|
|
|
Ok(hip)
|
|
}
|
|
|
|
fn parse_heal_query_bool(value: &str) -> S3Result<bool> {
|
|
match value {
|
|
"true" => Ok(true),
|
|
"false" => Ok(false),
|
|
_ => Err(s3_error!(InvalidArgument, "invalid heal query boolean")),
|
|
}
|
|
}
|
|
|
|
fn validate_heal_target(bucket: &str, obj_prefix: &str) -> S3Result<()> {
|
|
if bucket.is_empty() && !obj_prefix.is_empty() {
|
|
return Err(s3_error!(InvalidRequest, "invalid bucket name"));
|
|
}
|
|
if !bucket.is_empty() && is_reserved_or_invalid_bucket(bucket, false) {
|
|
return Err(s3_error!(InvalidRequest, "invalid bucket name"));
|
|
}
|
|
if !is_valid_object_prefix(obj_prefix) {
|
|
return Err(s3_error!(InvalidRequest, "invalid object name"));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn register_heal_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
|
|
// Some APIs are only available in EC mode
|
|
// if is_dist_erasure().await || is_erasure().await {
|
|
r.insert(
|
|
Method::POST,
|
|
format!("{}{}", ADMIN_PREFIX, "/v3/heal/").as_str(),
|
|
AdminOperation(&HealHandler {}),
|
|
)?;
|
|
|
|
r.insert(
|
|
Method::POST,
|
|
format!("{}{}", ADMIN_PREFIX, "/v3/heal/{bucket}").as_str(),
|
|
AdminOperation(&HealHandler {}),
|
|
)?;
|
|
|
|
r.insert(
|
|
Method::POST,
|
|
format!("{}{}", ADMIN_PREFIX, "/v3/heal/{bucket}/{prefix}").as_str(),
|
|
AdminOperation(&HealHandler {}),
|
|
)?;
|
|
|
|
r.insert(
|
|
Method::POST,
|
|
format!("{}{}", ADMIN_PREFIX, "/v3/background-heal/status").as_str(),
|
|
AdminOperation(&BackgroundHealStatusHandler {}),
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct HealResp {
|
|
resp_bytes: Vec<u8>,
|
|
api_err: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct HealStartSuccess {
|
|
client_token: String,
|
|
client_address: String,
|
|
start_time: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct HealTaskStatus {
|
|
summary: String,
|
|
#[serde(rename = "detail")]
|
|
failure_detail: String,
|
|
start_time: String,
|
|
#[serde(rename = "settings")]
|
|
heal_settings: HealOpts,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
items: Vec<rustfs_madmin::heal_commands::HealResultItem>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct BackgroundHealStatus<'a> {
|
|
#[serde(flatten)]
|
|
info: &'a BackgroundHealInfo,
|
|
heal_queue_length: u64,
|
|
heal_active_tasks: u64,
|
|
heal_operations: rustfs_heal::HealOperationsSnapshot,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct HealTaskStatusPayload {
|
|
summary: String,
|
|
#[serde(default)]
|
|
items: Vec<rustfs_madmin::heal_commands::HealResultItem>,
|
|
}
|
|
|
|
fn map_heal_response(result: Option<HealResp>) -> S3Result<(StatusCode, Vec<u8>)> {
|
|
match result {
|
|
Some(result) => {
|
|
if let Some(err) = result.api_err {
|
|
return Err(s3_error!(InternalError, "{err}"));
|
|
}
|
|
|
|
if result.resp_bytes.is_empty() {
|
|
return Err(s3_error!(InternalError, "heal response body is empty"));
|
|
}
|
|
|
|
Ok((StatusCode::OK, result.resp_bytes))
|
|
}
|
|
None => Err(s3_error!(InternalError, "heal channel closed unexpectedly")),
|
|
}
|
|
}
|
|
|
|
fn current_rfc3339_time() -> S3Result<String> {
|
|
OffsetDateTime::now_utc()
|
|
.format(&Rfc3339)
|
|
.map_err(|e| s3_error!(InternalError, "failed to format heal timestamp: {e}"))
|
|
}
|
|
|
|
fn encode_json<T: Serialize>(value: &T) -> S3Result<Vec<u8>> {
|
|
serde_json::to_vec(value).map_err(|e| s3_error!(InternalError, "failed to serialize heal response: {e}"))
|
|
}
|
|
|
|
fn encode_heal_start_success(client_token: String, client_address: String) -> S3Result<Vec<u8>> {
|
|
encode_json(&HealStartSuccess {
|
|
client_token,
|
|
client_address,
|
|
start_time: current_rfc3339_time()?,
|
|
})
|
|
}
|
|
|
|
fn encode_heal_task_status(
|
|
summary: String,
|
|
failure_detail: String,
|
|
heal_settings: HealOpts,
|
|
items: Vec<rustfs_madmin::heal_commands::HealResultItem>,
|
|
) -> S3Result<Vec<u8>> {
|
|
encode_json(&HealTaskStatus {
|
|
summary,
|
|
failure_detail,
|
|
start_time: current_rfc3339_time()?,
|
|
heal_settings,
|
|
items,
|
|
})
|
|
}
|
|
|
|
fn build_heal_channel_request(hip: &HealInitParams) -> HealChannelRequest {
|
|
let root_erasure_set_target =
|
|
hip.bucket.is_empty() && hip.obj_prefix.is_empty() && matches!((hip.hs.pool, hip.hs.set), (Some(_), Some(_)));
|
|
let recursive = if (!hip.bucket.is_empty() && hip.obj_prefix.is_empty()) || root_erasure_set_target {
|
|
true
|
|
} else {
|
|
hip.hs.recursive
|
|
};
|
|
let mut heal_request = rustfs_common::heal_channel::create_heal_request(
|
|
hip.bucket.clone(),
|
|
if hip.obj_prefix.is_empty() {
|
|
None
|
|
} else {
|
|
Some(hip.obj_prefix.clone())
|
|
},
|
|
hip.force_start,
|
|
Some(HealChannelPriority::High),
|
|
);
|
|
|
|
heal_request.pool_index = hip.hs.pool;
|
|
heal_request.set_index = hip.hs.set;
|
|
if root_erasure_set_target && let (Some(pool), Some(set)) = (hip.hs.pool, hip.hs.set) {
|
|
heal_request.disk = Some(format_set_disk_id(pool, set));
|
|
}
|
|
heal_request.scan_mode = Some(hip.hs.scan_mode);
|
|
heal_request.remove_corrupted = Some(hip.hs.remove);
|
|
heal_request.recreate_missing = Some(hip.hs.recreate);
|
|
heal_request.update_parity = Some(hip.hs.update_parity);
|
|
heal_request.recursive = Some(recursive);
|
|
heal_request.dry_run = Some(hip.hs.dry_run);
|
|
heal_request.source = HealRequestSource::Admin;
|
|
heal_request
|
|
}
|
|
|
|
fn heal_channel_response_status(
|
|
response: &rustfs_common::heal_channel::HealChannelResponse,
|
|
) -> (String, Vec<rustfs_madmin::heal_commands::HealResultItem>) {
|
|
let Some(data) = response.data.as_deref() else {
|
|
return ("running".to_string(), Vec::new());
|
|
};
|
|
|
|
if let Ok(payload) = serde_json::from_slice::<HealTaskStatusPayload>(data)
|
|
&& !payload.summary.is_empty()
|
|
{
|
|
return (payload.summary, payload.items);
|
|
}
|
|
|
|
let summary = std::str::from_utf8(data)
|
|
.ok()
|
|
.filter(|summary| !summary.is_empty())
|
|
.unwrap_or("running")
|
|
.to_string();
|
|
(summary, Vec::new())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn heal_channel_response_summary(response: &rustfs_common::heal_channel::HealChannelResponse) -> String {
|
|
heal_channel_response_status(response).0
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn heal_channel_response_items(
|
|
response: &rustfs_common::heal_channel::HealChannelResponse,
|
|
) -> Vec<rustfs_madmin::heal_commands::HealResultItem> {
|
|
heal_channel_response_status(response).1
|
|
}
|
|
|
|
fn encode_background_heal_status(
|
|
info: &BackgroundHealInfo,
|
|
heal_operations: rustfs_heal::HealOperationsSnapshot,
|
|
) -> S3Result<Vec<u8>> {
|
|
let status = BackgroundHealStatus {
|
|
info,
|
|
heal_queue_length: heal_operations.queue_length,
|
|
heal_active_tasks: heal_operations.active_tasks,
|
|
heal_operations,
|
|
};
|
|
serde_json::to_vec(&status).map_err(|e| {
|
|
warn!(
|
|
event = EVENT_ADMIN_REQUEST_FAILED,
|
|
component = LOG_COMPONENT_ADMIN_API,
|
|
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
|
operation = "background_heal_status",
|
|
result = "failed",
|
|
reason = "serialize_background_heal_status_failed",
|
|
error = %e,
|
|
"admin request failed"
|
|
);
|
|
s3_error!(InternalError, "failed to serialize background heal status: {e}")
|
|
})
|
|
}
|
|
|
|
fn validate_heal_request_mode(hip: &HealInitParams) -> S3Result<()> {
|
|
if hip.bucket.is_empty() && hip.client_token.is_empty() && !hip.force_stop {
|
|
return match (hip.hs.pool, hip.hs.set) {
|
|
(Some(_), Some(_)) => Ok(()),
|
|
(Some(_), None) | (None, Some(_)) => {
|
|
Err(s3_error!(InvalidRequest, "root heal erasure-set target requires both pool and set"))
|
|
}
|
|
(None, None) => Err(s3_error!(InvalidRequest, "starting heal without a bucket target is not supported")),
|
|
};
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn should_handle_root_heal_directly(hip: &HealInitParams) -> bool {
|
|
hip.bucket.is_empty()
|
|
&& hip.obj_prefix.is_empty()
|
|
&& hip.client_token.is_empty()
|
|
&& !hip.force_stop
|
|
&& hip.hs.pool.is_none()
|
|
&& hip.hs.set.is_none()
|
|
}
|
|
|
|
fn map_root_heal_status(heal_err: Option<super::super::storage_compat::Error>) -> S3Result<()> {
|
|
match heal_err {
|
|
None => Ok(()),
|
|
Some(super::super::storage_compat::StorageError::NoHealRequired) => {
|
|
info!(
|
|
event = EVENT_ADMIN_RESPONSE_EMITTED,
|
|
component = LOG_COMPONENT_ADMIN_API,
|
|
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
|
operation = "root_heal",
|
|
result = "success",
|
|
state = "no_heal_required",
|
|
"admin response emitted"
|
|
);
|
|
Ok(())
|
|
}
|
|
Some(err) => {
|
|
warn!(
|
|
event = EVENT_ADMIN_REQUEST_FAILED,
|
|
component = LOG_COMPONENT_ADMIN_API,
|
|
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
|
operation = "root_heal",
|
|
result = "failed",
|
|
reason = "root_heal_failed",
|
|
error = %err,
|
|
"admin request failed"
|
|
);
|
|
Err(s3_error!(InternalError, "root heal failed: {err}"))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn json_response(status: StatusCode, body: Vec<u8>) -> S3Response<(StatusCode, Body)> {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
|
if let Ok(value) = HeaderValue::from_str(&body.len().to_string()) {
|
|
headers.insert(CONTENT_LENGTH, value);
|
|
}
|
|
S3Response::with_headers((status, Body::from(body)), headers)
|
|
}
|
|
|
|
async fn validate_heal_admin_request(req: &S3Request<Body>) -> S3Result<()> {
|
|
let Some(input_cred) = req.credentials.as_ref() else {
|
|
return Err(s3_error!(InvalidRequest, "authentication required"));
|
|
};
|
|
|
|
let (cred, owner) = authenticate_request(&req.headers, &req.uri, input_cred).await?;
|
|
|
|
validate_admin_request(
|
|
&req.headers,
|
|
&cred,
|
|
owner,
|
|
false,
|
|
vec![Action::AdminAction(AdminAction::HealAdminAction)],
|
|
req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0)),
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub struct HealHandler {}
|
|
|
|
#[async_trait::async_trait]
|
|
impl Operation for HealHandler {
|
|
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
|
validate_heal_admin_request(&req).await?;
|
|
let client_address = req
|
|
.extensions
|
|
.get::<Option<RemoteAddr>>()
|
|
.and_then(|opt| opt.map(|addr| addr.0.to_string()))
|
|
.unwrap_or_default();
|
|
let mut input = req.input;
|
|
let bytes = match input.store_all_limited(MAX_HEAL_REQUEST_SIZE).await {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
warn!(
|
|
event = EVENT_ADMIN_REQUEST_REJECTED,
|
|
component = LOG_COMPONENT_ADMIN_API,
|
|
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
|
operation = "heal_request_body",
|
|
result = "rejected",
|
|
reason = "request_body_read_failed",
|
|
error = ?e,
|
|
"admin request rejected"
|
|
);
|
|
return Err(s3_error!(InvalidRequest, "heal request body too large or failed to read"));
|
|
}
|
|
};
|
|
let hip = extract_heal_init_params(&bytes, &req.uri, params)?;
|
|
// The heal channel currently models bucket/object work. Root heal reuses the
|
|
// existing format-heal path directly so `/v3/heal/` is accepted intentionally.
|
|
if should_handle_root_heal_directly(&hip) {
|
|
let Some(store) = resolve_object_store_handle() else {
|
|
warn!(
|
|
event = EVENT_ADMIN_REQUEST_FAILED,
|
|
component = LOG_COMPONENT_ADMIN_API,
|
|
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
|
operation = "root_heal",
|
|
result = "failed",
|
|
reason = "server_not_initialized",
|
|
"admin request failed"
|
|
);
|
|
return Err(s3_error!(InternalError, "server not initialized"));
|
|
};
|
|
|
|
let (_, heal_err) = store.heal_format(hip.hs.dry_run).await.map_err(|e| {
|
|
warn!(
|
|
event = EVENT_ADMIN_REQUEST_FAILED,
|
|
component = LOG_COMPONENT_ADMIN_API,
|
|
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
|
operation = "root_heal",
|
|
result = "failed",
|
|
reason = "heal_format_failed",
|
|
error = %e,
|
|
"admin request failed"
|
|
);
|
|
s3_error!(InternalError, "root heal failed: {e}")
|
|
})?;
|
|
|
|
map_root_heal_status(heal_err)?;
|
|
let body = encode_heal_start_success("root-heal".to_string(), client_address)?;
|
|
info!(
|
|
event = EVENT_ADMIN_RESPONSE_EMITTED,
|
|
component = LOG_COMPONENT_ADMIN_API,
|
|
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
|
operation = "root_heal",
|
|
result = "success",
|
|
state = "started",
|
|
"admin response emitted"
|
|
);
|
|
|
|
return Ok(json_response(StatusCode::OK, body));
|
|
}
|
|
validate_heal_request_mode(&hip)?;
|
|
let response_operation = if hip.force_stop {
|
|
"cancel_heal"
|
|
} else if !hip.client_token.is_empty() && !hip.force_start {
|
|
"query_heal_status"
|
|
} else {
|
|
"start_heal"
|
|
};
|
|
|
|
let heal_path = path_join(&[PathBuf::from(hip.bucket.clone()), PathBuf::from(hip.obj_prefix.clone())]);
|
|
let (tx, mut rx) = mpsc::channel(1);
|
|
|
|
if !hip.client_token.is_empty() && !hip.force_start && !hip.force_stop {
|
|
// Query heal status
|
|
let tx_clone = tx.clone();
|
|
let heal_path_str = heal_path.to_str().unwrap_or_default().to_string();
|
|
let client_token = hip.client_token.clone();
|
|
spawn_traced(async move {
|
|
match rustfs_common::heal_channel::query_heal_status(heal_path_str, client_token).await {
|
|
Ok(response) if response.success => {
|
|
let (summary, items) = heal_channel_response_status(&response);
|
|
let resp_bytes =
|
|
encode_heal_task_status(summary, response.error.unwrap_or_default(), HealOpts::default(), items);
|
|
match resp_bytes {
|
|
Ok(resp_bytes) => {
|
|
let _ = tx_clone
|
|
.send(HealResp {
|
|
resp_bytes,
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
}
|
|
Err(e) => {
|
|
let _ = tx_clone
|
|
.send(HealResp {
|
|
api_err: Some(e.to_string()),
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
Ok(response) => {
|
|
let _ = tx_clone
|
|
.send(HealResp {
|
|
api_err: Some(response.error.unwrap_or_else(|| "query heal status failed".to_string())),
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
}
|
|
Err(e) => {
|
|
let _ = tx_clone
|
|
.send(HealResp {
|
|
api_err: Some(format!("query heal status failed: {e}")),
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
});
|
|
} else if hip.force_stop {
|
|
// Cancel heal task
|
|
let tx_clone = tx.clone();
|
|
let heal_path_str = heal_path.to_str().unwrap_or_default().to_string();
|
|
let client_token = hip.client_token.clone();
|
|
let client_address = client_address.clone();
|
|
let heal_settings = hip.hs;
|
|
spawn_traced(async move {
|
|
match rustfs_common::heal_channel::cancel_heal_task(heal_path_str, client_token.clone()).await {
|
|
Ok(response) if response.success => {
|
|
let resp_bytes = if client_token.is_empty() {
|
|
encode_heal_start_success(response.request_id, client_address)
|
|
} else {
|
|
let (summary, items) = heal_channel_response_status(&response);
|
|
encode_heal_task_status(summary, response.error.unwrap_or_default(), heal_settings, items)
|
|
};
|
|
match resp_bytes {
|
|
Ok(resp_bytes) => {
|
|
let _ = tx_clone
|
|
.send(HealResp {
|
|
resp_bytes,
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
}
|
|
Err(e) => {
|
|
let _ = tx_clone
|
|
.send(HealResp {
|
|
api_err: Some(e.to_string()),
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
Ok(response) => {
|
|
let _ = tx_clone
|
|
.send(HealResp {
|
|
api_err: Some(response.error.unwrap_or_else(|| "cancel heal task failed".to_string())),
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
}
|
|
Err(e) => {
|
|
let _ = tx_clone
|
|
.send(HealResp {
|
|
api_err: Some(format!("cancel heal task failed: {e}")),
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
});
|
|
} else if hip.client_token.is_empty() {
|
|
// Use new heal channel mechanism
|
|
let tx_clone = tx.clone();
|
|
let client_address = client_address.clone();
|
|
spawn_traced(async move {
|
|
// Create heal request through channel
|
|
let heal_request = build_heal_channel_request(&hip);
|
|
let client_token = heal_request.id.clone();
|
|
|
|
match rustfs_common::heal_channel::send_heal_request_with_admission(heal_request).await {
|
|
Ok(admission) if admission.is_admitted() => {
|
|
let resp_bytes = encode_heal_start_success(client_token, client_address);
|
|
match resp_bytes {
|
|
Ok(resp_bytes) => {
|
|
let _ = tx_clone
|
|
.send(HealResp {
|
|
resp_bytes,
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
}
|
|
Err(e) => {
|
|
let _ = tx_clone
|
|
.send(HealResp {
|
|
api_err: Some(e.to_string()),
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
Ok(admission) => {
|
|
let _ = tx_clone
|
|
.send(HealResp {
|
|
api_err: Some(format!(
|
|
"heal request not admitted: admission={}, reason={}",
|
|
admission.result_label(),
|
|
admission.reason_label()
|
|
)),
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
}
|
|
Err(e) => {
|
|
// Error - send error response
|
|
let _ = tx_clone
|
|
.send(HealResp {
|
|
api_err: Some(format!("send heal request failed: {e}")),
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
let (status, body) = map_heal_response(rx.recv().await)?;
|
|
info!(
|
|
event = EVENT_ADMIN_RESPONSE_EMITTED,
|
|
component = LOG_COMPONENT_ADMIN_API,
|
|
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
|
operation = response_operation,
|
|
result = "success",
|
|
status_code = status.as_u16(),
|
|
"admin response emitted"
|
|
);
|
|
Ok(json_response(status, body))
|
|
}
|
|
}
|
|
|
|
pub struct BackgroundHealStatusHandler {}
|
|
|
|
#[async_trait::async_trait]
|
|
impl Operation for BackgroundHealStatusHandler {
|
|
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
|
validate_heal_admin_request(&req).await?;
|
|
|
|
let Some(store) = resolve_object_store_handle() else {
|
|
warn!(
|
|
event = EVENT_ADMIN_REQUEST_FAILED,
|
|
component = LOG_COMPONENT_ADMIN_API,
|
|
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
|
operation = "background_heal_status",
|
|
result = "failed",
|
|
reason = "server_not_initialized",
|
|
"admin request failed"
|
|
);
|
|
return Err(s3_error!(InternalError, "server not initialized"));
|
|
};
|
|
|
|
let info = read_background_heal_info(store).await;
|
|
let heal_operations = rustfs_heal::current_heal_operations_snapshot().await;
|
|
let body = encode_background_heal_status(&info, heal_operations)?;
|
|
info!(
|
|
event = EVENT_ADMIN_RESPONSE_EMITTED,
|
|
component = LOG_COMPONENT_ADMIN_API,
|
|
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
|
operation = "background_heal_status",
|
|
result = "success",
|
|
"admin response emitted"
|
|
);
|
|
|
|
Ok(json_response(StatusCode::OK, body))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::super::super::storage_compat::StorageError;
|
|
use super::extract_heal_init_params;
|
|
use super::{
|
|
HealInitParams, HealResp, build_heal_channel_request, encode_background_heal_status, encode_heal_start_success,
|
|
encode_heal_task_status, heal_channel_response_items, heal_channel_response_summary, json_response, map_heal_response,
|
|
map_root_heal_status, should_handle_root_heal_directly, validate_heal_request_mode, validate_heal_target,
|
|
};
|
|
use bytes::Bytes;
|
|
use http::StatusCode;
|
|
use http::Uri;
|
|
use matchit::Router;
|
|
use rustfs_common::heal_channel::{HealChannelPriority, HealOpts, HealRequestSource, HealScanMode};
|
|
use rustfs_scanner::scanner::BackgroundHealInfo;
|
|
use s3s::{
|
|
S3ErrorCode,
|
|
header::{CONTENT_LENGTH, CONTENT_TYPE},
|
|
};
|
|
use serde_json::json;
|
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
|
use tokio::sync::mpsc;
|
|
use tracing::debug;
|
|
|
|
#[test]
|
|
fn test_heal_opts_serialization() {
|
|
// Test that HealOpts can be properly deserialized
|
|
let heal_opts_json = json!({
|
|
"recursive": true,
|
|
"dryRun": false,
|
|
"remove": true,
|
|
"recreate": false,
|
|
"scanMode": 2,
|
|
"updateParity": true,
|
|
"nolock": false
|
|
});
|
|
|
|
let json_str = serde_json::to_string(&heal_opts_json).unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
|
|
|
|
assert_eq!(parsed["recursive"], true);
|
|
assert_eq!(parsed["scanMode"], 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_heal_opts_url_encoding() {
|
|
// Test URL encoding/decoding of HealOpts
|
|
let opts = HealOpts {
|
|
recursive: true,
|
|
dry_run: false,
|
|
remove: true,
|
|
recreate: false,
|
|
scan_mode: rustfs_common::heal_channel::HealScanMode::Normal,
|
|
update_parity: false,
|
|
no_lock: true,
|
|
pool: Some(1),
|
|
set: Some(0),
|
|
};
|
|
|
|
let encoded = serde_urlencoded::to_string(opts).unwrap();
|
|
assert!(encoded.contains("recursive=true"));
|
|
assert!(encoded.contains("remove=true"));
|
|
|
|
// Test round-trip
|
|
let decoded: HealOpts = serde_urlencoded::from_str(&encoded).unwrap();
|
|
assert_eq!(decoded.recursive, opts.recursive);
|
|
assert_eq!(decoded.scan_mode, opts.scan_mode);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_heal_init_params_invalid_control_combination_returns_descriptive_error() {
|
|
let uri: Uri = "/rustfs/admin/v3/heal/test-bucket?clientToken=token&forceStart=true"
|
|
.parse()
|
|
.expect("uri should parse");
|
|
|
|
let mut router = Router::new();
|
|
router
|
|
.insert("/rustfs/admin/v3/heal/{bucket}", ())
|
|
.expect("route should insert");
|
|
let matched = router.at("/rustfs/admin/v3/heal/test-bucket").expect("route should match");
|
|
|
|
let err = extract_heal_init_params(&Bytes::new(), &uri, matched.params).expect_err("must reject invalid combo");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("invalid combination of clientToken, forceStart, and forceStop parameters")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_heal_init_params_allows_client_token_force_stop() {
|
|
let uri: Uri = "/rustfs/admin/v3/heal/test-bucket?clientToken=token&forceStop=true"
|
|
.parse()
|
|
.expect("uri should parse");
|
|
|
|
let mut router = Router::new();
|
|
router
|
|
.insert("/rustfs/admin/v3/heal/{bucket}", ())
|
|
.expect("route should insert");
|
|
let matched = router.at("/rustfs/admin/v3/heal/test-bucket").expect("route should match");
|
|
|
|
let parsed = extract_heal_init_params(&Bytes::new(), &uri, matched.params).expect("client-token stop should be accepted");
|
|
assert_eq!(parsed.client_token, "token");
|
|
assert!(parsed.force_stop);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_heal_init_params_rejects_unknown_duplicate_and_invalid_bool() {
|
|
let mut router = Router::new();
|
|
router
|
|
.insert("/rustfs/admin/v3/heal/{bucket}", ())
|
|
.expect("route should insert");
|
|
|
|
for query in [
|
|
"forceStart=yes",
|
|
"forceStart=true&forceStart=false",
|
|
"clientToken=token&unexpected=true",
|
|
] {
|
|
let uri: Uri = format!("/rustfs/admin/v3/heal/test-bucket?{query}")
|
|
.parse()
|
|
.expect("uri should parse");
|
|
let matched = router.at("/rustfs/admin/v3/heal/test-bucket").expect("route should match");
|
|
let err = extract_heal_init_params(&Bytes::new(), &uri, matched.params)
|
|
.expect_err("strict heal query should reject malformed input");
|
|
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_heal_channel_request_preserves_admin_heal_options() {
|
|
let hip = HealInitParams {
|
|
bucket: "bucket".to_string(),
|
|
obj_prefix: "prefix".to_string(),
|
|
hs: HealOpts {
|
|
recursive: true,
|
|
dry_run: true,
|
|
remove: true,
|
|
recreate: true,
|
|
scan_mode: HealScanMode::Deep,
|
|
update_parity: true,
|
|
pool: Some(1),
|
|
set: Some(2),
|
|
..Default::default()
|
|
},
|
|
force_start: true,
|
|
..Default::default()
|
|
};
|
|
|
|
let request = build_heal_channel_request(&hip);
|
|
|
|
assert_eq!(request.bucket, "bucket");
|
|
assert_eq!(request.object_prefix.as_deref(), Some("prefix"));
|
|
assert_eq!(request.priority, HealChannelPriority::High);
|
|
assert!(request.force_start);
|
|
assert_eq!(request.scan_mode, Some(HealScanMode::Deep));
|
|
assert_eq!(request.recursive, Some(true));
|
|
assert_eq!(request.dry_run, Some(true));
|
|
assert_eq!(request.remove_corrupted, Some(true));
|
|
assert_eq!(request.recreate_missing, Some(true));
|
|
assert_eq!(request.update_parity, Some(true));
|
|
assert_eq!(request.pool_index, Some(1));
|
|
assert_eq!(request.set_index, Some(2));
|
|
}
|
|
|
|
#[test]
|
|
fn test_root_heal_channel_request_with_pool_set_targets_erasure_set() {
|
|
let hip = HealInitParams {
|
|
hs: HealOpts {
|
|
scan_mode: HealScanMode::Deep,
|
|
recreate: true,
|
|
pool: Some(1),
|
|
set: Some(2),
|
|
..Default::default()
|
|
},
|
|
force_start: true,
|
|
..Default::default()
|
|
};
|
|
|
|
let request = build_heal_channel_request(&hip);
|
|
|
|
assert_eq!(request.bucket, "");
|
|
assert_eq!(request.disk.as_deref(), Some("pool_1_set_2"));
|
|
assert_eq!(request.priority, HealChannelPriority::High);
|
|
assert_eq!(request.source, HealRequestSource::Admin);
|
|
assert_eq!(request.pool_index, Some(1));
|
|
assert_eq!(request.set_index, Some(2));
|
|
assert_eq!(request.scan_mode, Some(HealScanMode::Deep));
|
|
assert_eq!(request.recursive, Some(true));
|
|
assert_eq!(request.recreate_missing, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn test_bucket_heal_channel_request_defaults_to_recursive() {
|
|
let hip = HealInitParams {
|
|
bucket: "bucket".to_string(),
|
|
obj_prefix: String::new(),
|
|
hs: HealOpts {
|
|
recursive: false,
|
|
scan_mode: HealScanMode::Deep,
|
|
recreate: true,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
|
|
let request = build_heal_channel_request(&hip);
|
|
|
|
assert_eq!(request.bucket, "bucket");
|
|
assert_eq!(request.object_prefix, None);
|
|
assert_eq!(request.recursive, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_heal_init_params_allows_root_heal_target() {
|
|
let uri: Uri = "/rustfs/admin/v3/heal/".parse().expect("uri should parse");
|
|
let heal_opts = json!({
|
|
"recursive": false,
|
|
"dryRun": false,
|
|
"remove": false,
|
|
"recreate": false,
|
|
"scanMode": 1,
|
|
"updateParity": false,
|
|
"nolock": false
|
|
});
|
|
|
|
let mut router = Router::new();
|
|
router.insert("/rustfs/admin/v3/heal/", ()).expect("route should insert");
|
|
let matched = router.at("/rustfs/admin/v3/heal/").expect("route should match");
|
|
|
|
let parsed = extract_heal_init_params(
|
|
&Bytes::from(serde_json::to_vec(&heal_opts).expect("json should serialize")),
|
|
&uri,
|
|
matched.params,
|
|
)
|
|
.expect("root heal target should be accepted");
|
|
|
|
assert!(parsed.bucket.is_empty());
|
|
assert!(parsed.obj_prefix.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_heal_request_mode_rejects_root_heal_start() {
|
|
let err = validate_heal_request_mode(&HealInitParams::default()).expect_err("must reject root heal start");
|
|
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
|
assert!(
|
|
err.to_string()
|
|
.contains("starting heal without a bucket target is not supported")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_heal_request_mode_allows_root_erasure_set_target() {
|
|
validate_heal_request_mode(&HealInitParams {
|
|
hs: HealOpts {
|
|
pool: Some(1),
|
|
set: Some(2),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
})
|
|
.expect("root heal with pool and set should start tracked erasure-set rebuild");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_heal_request_mode_rejects_partial_root_erasure_set_target() {
|
|
let err = validate_heal_request_mode(&HealInitParams {
|
|
hs: HealOpts {
|
|
pool: Some(1),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
})
|
|
.expect_err("root heal must provide both pool and set");
|
|
|
|
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
|
assert!(
|
|
err.to_string()
|
|
.contains("root heal erasure-set target requires both pool and set")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_handle_root_heal_directly_for_root_start_modes() {
|
|
assert!(should_handle_root_heal_directly(&HealInitParams::default()));
|
|
assert!(should_handle_root_heal_directly(&HealInitParams {
|
|
force_start: true,
|
|
..Default::default()
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_handle_root_heal_directly_skips_query_cancel_and_bucket_targets() {
|
|
assert!(!should_handle_root_heal_directly(&HealInitParams {
|
|
client_token: "heal-token".to_string(),
|
|
..Default::default()
|
|
}));
|
|
assert!(!should_handle_root_heal_directly(&HealInitParams {
|
|
force_stop: true,
|
|
..Default::default()
|
|
}));
|
|
assert!(!should_handle_root_heal_directly(&HealInitParams {
|
|
bucket: "bucket".to_string(),
|
|
..Default::default()
|
|
}));
|
|
assert!(!should_handle_root_heal_directly(&HealInitParams {
|
|
hs: HealOpts {
|
|
pool: Some(1),
|
|
set: Some(2),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_root_heal_status_allows_no_heal_required() {
|
|
map_root_heal_status(Some(StorageError::NoHealRequired)).expect("NoHealRequired should stay non-fatal");
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_root_heal_status_rejects_fatal_errors() {
|
|
let err = map_root_heal_status(Some(StorageError::Unexpected)).expect_err("fatal status must fail");
|
|
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
|
assert!(err.to_string().contains("root heal failed: Unexpected error"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_heal_request_mode_allows_root_query_and_cancel() {
|
|
validate_heal_request_mode(&HealInitParams {
|
|
client_token: "heal-token".to_string(),
|
|
..Default::default()
|
|
})
|
|
.expect("root heal status query should be accepted");
|
|
|
|
validate_heal_request_mode(&HealInitParams {
|
|
force_stop: true,
|
|
..Default::default()
|
|
})
|
|
.expect("root heal cancel should be accepted");
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_heal_init_params_rejects_prefix_without_bucket() {
|
|
let err = validate_heal_target("", "prefix").expect_err("must reject empty bucket");
|
|
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
|
assert!(err.to_string().contains("invalid bucket name"));
|
|
}
|
|
|
|
#[ignore] // FIXME: failed in github actions - keeping original test
|
|
#[test]
|
|
fn test_decode() {
|
|
let b = b"{\"recursive\":false,\"dryRun\":false,\"remove\":false,\"recreate\":false,\"scanMode\":1,\"updateParity\":false,\"nolock\":false}";
|
|
let s: HealOpts = serde_urlencoded::from_bytes(b).unwrap();
|
|
debug!("Parsed HealOpts: {:?}", s);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_map_heal_response_propagates_errors() {
|
|
let (tx, mut rx) = mpsc::channel(1);
|
|
drop(tx);
|
|
let result = map_heal_response(rx.recv().await);
|
|
assert!(result.is_err());
|
|
assert_eq!(result.unwrap_err().code(), &S3ErrorCode::InternalError);
|
|
|
|
let (tx, mut rx) = mpsc::channel(1);
|
|
tx.send(HealResp {
|
|
resp_bytes: vec![],
|
|
api_err: Some("channel failed".to_string()),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let result = map_heal_response(rx.recv().await);
|
|
assert!(result.is_err());
|
|
assert_eq!(result.unwrap_err().code(), &S3ErrorCode::InternalError);
|
|
|
|
let (tx, mut rx) = mpsc::channel(1);
|
|
tx.send(HealResp {
|
|
resp_bytes: vec![],
|
|
api_err: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let result = map_heal_response(rx.recv().await);
|
|
assert!(result.is_err());
|
|
assert_eq!(result.unwrap_err().code(), &S3ErrorCode::InternalError);
|
|
|
|
let (tx, mut rx) = mpsc::channel(1);
|
|
let _ = tx
|
|
.send(HealResp {
|
|
resp_bytes: vec![1, 2, 3],
|
|
api_err: None,
|
|
})
|
|
.await;
|
|
let result = map_heal_response(rx.recv().await).expect("heal response should be successful");
|
|
assert_eq!(result.0, StatusCode::OK);
|
|
assert_eq!(result.1, vec![1, 2, 3]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_encode_background_heal_status_uses_expected_shape() {
|
|
let info = BackgroundHealInfo {
|
|
bitrot_start_time: None,
|
|
bitrot_start_cycle: 42,
|
|
current_scan_mode: HealScanMode::Deep,
|
|
};
|
|
|
|
let operations = rustfs_heal::HealOperationsSnapshot {
|
|
queue_length: 3,
|
|
active_tasks: 2,
|
|
..Default::default()
|
|
};
|
|
|
|
let encoded = encode_background_heal_status(&info, operations).expect("background heal info should serialize");
|
|
let json: serde_json::Value = serde_json::from_slice(&encoded).expect("json should deserialize");
|
|
|
|
assert_eq!(json["bitrotStartCycle"], 42);
|
|
assert_eq!(json["currentScanMode"], 2);
|
|
assert!(json["bitrotStartTime"].is_null());
|
|
assert!(json["healQueueLength"].is_u64());
|
|
assert!(json["healActiveTasks"].is_u64());
|
|
assert_eq!(json["healOperations"]["queueLength"], json["healQueueLength"]);
|
|
assert_eq!(json["healOperations"]["activeTasks"], json["healActiveTasks"]);
|
|
assert!(json["healOperations"]["queuedBySource"]["scanner"].is_u64());
|
|
assert!(json["healOperations"]["queuedBySource"]["admin"].is_u64());
|
|
assert!(json["healOperations"]["queuedByPriority"]["low"].is_u64());
|
|
assert!(json["healOperations"]["queuedByPriority"]["high"].is_u64());
|
|
}
|
|
|
|
#[test]
|
|
fn test_encode_heal_start_success_uses_client_wire_shape() {
|
|
let encoded = encode_heal_start_success("token-1".to_string(), "127.0.0.1:9000".to_string())
|
|
.expect("start response should serialize");
|
|
let json: serde_json::Value = serde_json::from_slice(&encoded).expect("json should deserialize");
|
|
|
|
assert_eq!(json["clientToken"], "token-1");
|
|
assert_eq!(json["clientAddress"], "127.0.0.1:9000");
|
|
let start_time = json["startTime"].as_str().expect("startTime should be a string");
|
|
OffsetDateTime::parse(start_time, &Rfc3339).expect("startTime should be RFC3339");
|
|
}
|
|
|
|
#[test]
|
|
fn test_encode_heal_task_status_uses_client_wire_shape() {
|
|
let encoded =
|
|
encode_heal_task_status("Heal status query accepted".to_string(), String::new(), HealOpts::default(), Vec::new())
|
|
.expect("status response should serialize");
|
|
let json: serde_json::Value = serde_json::from_slice(&encoded).expect("json should deserialize");
|
|
|
|
assert_eq!(json["summary"], "Heal status query accepted");
|
|
assert_eq!(json["detail"], "");
|
|
assert!(json["items"].is_null());
|
|
assert!(json["settings"].is_object());
|
|
let start_time = json["startTime"].as_str().expect("startTime should be a string");
|
|
OffsetDateTime::parse(start_time, &Rfc3339).expect("startTime should be RFC3339");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_heal_channel_request_preserves_client_options() {
|
|
let hip = HealInitParams {
|
|
bucket: "bucket-a".to_string(),
|
|
obj_prefix: "prefix-a".to_string(),
|
|
force_start: true,
|
|
hs: HealOpts {
|
|
recursive: true,
|
|
dry_run: true,
|
|
remove: true,
|
|
recreate: false,
|
|
scan_mode: HealScanMode::Deep,
|
|
update_parity: false,
|
|
no_lock: true,
|
|
pool: Some(1),
|
|
set: Some(2),
|
|
},
|
|
..Default::default()
|
|
};
|
|
|
|
let request = build_heal_channel_request(&hip);
|
|
|
|
assert_eq!(request.bucket, "bucket-a");
|
|
assert_eq!(request.object_prefix.as_deref(), Some("prefix-a"));
|
|
assert_eq!(request.priority, HealChannelPriority::High);
|
|
assert_eq!(request.source, HealRequestSource::Admin);
|
|
assert!(request.force_start);
|
|
assert_eq!(request.pool_index, Some(1));
|
|
assert_eq!(request.set_index, Some(2));
|
|
assert_eq!(request.scan_mode, Some(HealScanMode::Deep));
|
|
assert_eq!(request.remove_corrupted, Some(true));
|
|
assert_eq!(request.recreate_missing, Some(false));
|
|
assert_eq!(request.update_parity, Some(false));
|
|
assert_eq!(request.recursive, Some(true));
|
|
assert_eq!(request.dry_run, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn test_heal_channel_response_summary_defaults_to_running() {
|
|
let response = rustfs_common::heal_channel::create_heal_response("token".to_string(), true, None, None);
|
|
assert_eq!(heal_channel_response_summary(&response), "running");
|
|
|
|
let response =
|
|
rustfs_common::heal_channel::create_heal_response("token".to_string(), true, Some(b"finished".to_vec()), None);
|
|
assert_eq!(heal_channel_response_summary(&response), "finished");
|
|
}
|
|
|
|
#[test]
|
|
fn test_heal_channel_response_status_preserves_items() {
|
|
let payload = serde_json::json!({
|
|
"summary": "finished",
|
|
"items": [{
|
|
"resultId": 0,
|
|
"type": "object",
|
|
"bucket": "bucket-a",
|
|
"object": "object-a",
|
|
"versionId": "",
|
|
"detail": "",
|
|
"parityBlocks": 2,
|
|
"dataBlocks": 2,
|
|
"diskCount": 4,
|
|
"setCount": 1,
|
|
"before": { "drives": [] },
|
|
"after": { "drives": [] },
|
|
"objectSize": 1024
|
|
}]
|
|
});
|
|
let response = rustfs_common::heal_channel::create_heal_response(
|
|
"token".to_string(),
|
|
true,
|
|
Some(serde_json::to_vec(&payload).expect("payload should serialize")),
|
|
None,
|
|
);
|
|
|
|
assert_eq!(heal_channel_response_summary(&response), "finished");
|
|
let items = heal_channel_response_items(&response);
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0].bucket, "bucket-a");
|
|
assert_eq!(items[0].object, "object-a");
|
|
assert_eq!(items[0].object_size, 1024);
|
|
}
|
|
|
|
#[test]
|
|
fn test_json_response_sets_application_json_content_type() {
|
|
let response = json_response(StatusCode::OK, b"{}".to_vec());
|
|
let content_type = response.headers.get(CONTENT_TYPE).and_then(|value| value.to_str().ok());
|
|
assert_eq!(content_type, Some("application/json"),);
|
|
let content_length = response.headers.get(CONTENT_LENGTH).and_then(|value| value.to_str().ok());
|
|
assert_eq!(content_length, Some("2"),);
|
|
}
|
|
}
|