fix(scanner): preserve background heal compatibility (#3041)

This commit is contained in:
weisd
2026-05-22 14:38:35 +08:00
committed by GitHub
parent b42766f1d3
commit 69345fe059
12 changed files with 1960 additions and 113 deletions
+369 -29
View File
@@ -20,7 +20,7 @@ use bytes::Bytes;
use http::{HeaderMap, HeaderValue, Uri};
use hyper::{Method, StatusCode};
use matchit::Params;
use rustfs_common::heal_channel::HealOpts;
use rustfs_common::heal_channel::{HealChannelPriority, HealChannelRequest, HealOpts};
use rustfs_config::MAX_HEAL_REQUEST_SIZE;
use rustfs_ecstore::bucket::utils::is_valid_object_prefix;
use rustfs_ecstore::new_object_layer_fn;
@@ -29,10 +29,11 @@ use rustfs_ecstore::store_utils::is_reserved_or_invalid_bucket;
use rustfs_policy::policy::action::{Action, AdminAction};
use rustfs_scanner::scanner::{BackgroundHealInfo, read_background_heal_info};
use rustfs_utils::path::path_join;
use s3s::header::CONTENT_TYPE;
use s3s::header::{CONTENT_LENGTH, CONTENT_TYPE};
use s3s::{Body, S3Request, S3Response, S3Result, s3_error};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tokio::spawn;
use tokio::sync::mpsc;
use tracing::{info, warn};
@@ -75,7 +76,7 @@ fn extract_heal_init_params(body: &Bytes, uri: &Uri, params: Params<'_, '_>) ->
}
}
if (hip.force_start && hip.force_stop) || (!hip.client_token.is_empty() && (hip.force_start || hip.force_stop)) {
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"
@@ -142,6 +143,34 @@ struct HealResp {
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, 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) => {
@@ -149,12 +178,105 @@ fn map_heal_response(result: Option<HealResp>) -> S3Result<(StatusCode, Vec<u8>)
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 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::Normal),
);
heal_request.pool_index = hip.hs.pool;
heal_request.set_index = hip.hs.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(hip.hs.recursive);
heal_request.dry_run = Some(hip.hs.dry_run);
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) -> S3Result<Vec<u8>> {
serde_json::to_vec(info).map_err(|e| s3_error!(InternalError, "failed to serialize background heal status: {e}"))
}
@@ -185,6 +307,9 @@ fn map_root_heal_status(heal_err: Option<rustfs_ecstore::error::Error>) -> S3Res
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)
}
@@ -213,6 +338,11 @@ impl Operation for HealHandler {
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
warn!("handle HealHandler, req: {:?}, params: {:?}", req, params);
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,
@@ -236,8 +366,9 @@ impl Operation for HealHandler {
.map_err(|e| 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)?;
return Ok(S3Response::new((StatusCode::OK, Body::empty())));
return Ok(json_response(StatusCode::OK, body));
}
validate_heal_request_mode(&hip)?;
info!("body: {:?}", hip);
@@ -252,11 +383,33 @@ impl Operation for HealHandler {
let client_token = hip.client_token.clone();
spawn(async move {
match rustfs_common::heal_channel::query_heal_status(heal_path_str, client_token).await {
Ok(_) => {
// TODO: Get actual response from channel
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 {
resp_bytes: vec![],
api_err: Some(response.error.unwrap_or_else(|| "query heal status failed".to_string())),
..Default::default()
})
.await;
@@ -275,13 +428,41 @@ impl Operation for HealHandler {
// 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(async move {
match rustfs_common::heal_channel::cancel_heal_task(heal_path_str).await {
Ok(_) => {
// TODO: Get actual response from channel
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 {
resp_bytes: vec![],
api_err: Some(response.error.unwrap_or_else(|| "cancel heal task failed".to_string())),
..Default::default()
})
.await;
@@ -299,25 +480,42 @@ impl Operation for HealHandler {
} else if hip.client_token.is_empty() {
// Use new heal channel mechanism
let tx_clone = tx.clone();
let client_address = client_address.clone();
spawn(async move {
// Create heal request through channel
let 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(rustfs_common::heal_channel::HealChannelPriority::Normal),
);
let heal_request = build_heal_channel_request(&hip);
let client_token = heal_request.id.clone();
match rustfs_common::heal_channel::send_heal_request(heal_request).await {
Ok(_) => {
// Success - send empty response for now
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 {
resp_bytes: vec![],
api_err: Some(format!(
"heal request not admitted: admission={}, reason={}",
admission.result_label(),
admission.reason_label()
)),
..Default::default()
})
.await;
@@ -336,7 +534,7 @@ impl Operation for HealHandler {
}
let (status, body) = map_heal_response(rx.recv().await)?;
Ok(S3Response::new((status, Body::from(body))))
Ok(json_response(status, body))
}
}
@@ -363,8 +561,9 @@ impl Operation for BackgroundHealStatusHandler {
mod tests {
use super::extract_heal_init_params;
use super::{
HealInitParams, HealResp, encode_background_heal_status, json_response, map_heal_response, map_root_heal_status,
should_handle_root_heal_directly, validate_heal_request_mode, validate_heal_target,
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;
@@ -373,8 +572,12 @@ mod tests {
use rustfs_common::heal_channel::{HealOpts, HealScanMode};
use rustfs_ecstore::error::StorageError;
use rustfs_scanner::scanner::BackgroundHealInfo;
use s3s::{S3ErrorCode, header::CONTENT_TYPE};
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;
@@ -442,6 +645,23 @@ mod tests {
);
}
#[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_allows_root_heal_target() {
let uri: Uri = "/rustfs/admin/v3/heal/".parse().expect("uri should parse");
@@ -566,6 +786,17 @@ mod tests {
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 {
@@ -594,10 +825,119 @@ mod tests {
assert!(json["bitrotStartTime"].is_null());
}
#[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!(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"),);
}
}
+4 -4
View File
@@ -572,10 +572,6 @@ async fn run(config: rustfs::config::Config) -> Result<()> {
init_heal_manager(heal_storage, None).await?;
}
if enable_scanner {
init_data_scanner(ctx.clone(), store.clone()).await;
}
if !enable_heal && !enable_scanner {
info!(target: "rustfs::main::run","Both scanner and heal are disabled, skipping AHM service initialization");
}
@@ -610,6 +606,10 @@ async fn run(config: rustfs::config::Config) -> Result<()> {
// Publish ready only after all critical bootstrap metadata is in place
state_manager.update(ServiceState::Ready);
if enable_scanner {
init_data_scanner(ctx.clone(), store.clone()).await;
}
// Perform hibernation for 1 second
tokio::time::sleep(SHUTDOWN_TIMEOUT).await;
// listen to the shutdown signal
+67 -13
View File
@@ -275,7 +275,7 @@ fn should_force_zero_content_length_for_empty_body_route<B>(req: &HttpRequest<B>
return false;
}
if is_empty_body_admin_path(req.method(), req.uri().path()) {
if is_empty_body_admin_path(req.method(), req.uri()) {
return true;
}
@@ -286,7 +286,8 @@ fn should_force_zero_content_length_for_empty_body_route<B>(req: &HttpRequest<B>
is_empty_body_s3_path(req.method(), req.uri())
}
fn is_empty_body_admin_path(method: &Method, path: &str) -> bool {
fn is_empty_body_admin_path(method: &Method, uri: &http::Uri) -> bool {
let path = uri.path();
match *method {
Method::PUT => matches!(
path,
@@ -295,21 +296,40 @@ fn is_empty_body_admin_path(method: &Method, path: &str) -> bool {
| "/rustfs/admin/v3/set-user-status"
| "/rustfs/admin/v3/set-group-status"
),
Method::POST => matches!(
path,
"/minio/admin/v3/rebalance/start"
| "/minio/admin/v3/rebalance/stop"
| "/minio/admin/v3/pools/decommission"
| "/minio/admin/v3/pools/cancel"
| "/rustfs/admin/v3/rebalance/start"
| "/rustfs/admin/v3/rebalance/stop"
| "/rustfs/admin/v3/pools/decommission"
| "/rustfs/admin/v3/pools/cancel"
),
Method::POST => {
matches!(
path,
"/minio/admin/v3/rebalance/start"
| "/minio/admin/v3/rebalance/stop"
| "/minio/admin/v3/pools/decommission"
| "/minio/admin/v3/pools/cancel"
| "/rustfs/admin/v3/rebalance/start"
| "/rustfs/admin/v3/rebalance/stop"
| "/rustfs/admin/v3/pools/decommission"
| "/rustfs/admin/v3/pools/cancel"
) || is_heal_status_query(path, uri.query())
}
_ => false,
}
}
fn is_heal_status_query(path: &str, query: Option<&str>) -> bool {
let Some(query) = query else {
return false;
};
if !path.find("/v3/heal/").is_some_and(|index| path[..index].ends_with("/admin")) {
return false;
}
query.split('&').any(|param| {
param
.split_once('=')
.map(|(key, value)| key == "clientToken" && !value.is_empty())
.unwrap_or(false)
})
}
fn is_empty_body_s3_path(method: &Method, uri: &http::Uri) -> bool {
*method == Method::DELETE && ConditionalCorsLayer::is_s3_path(uri.path())
}
@@ -1320,6 +1340,40 @@ mod tests {
assert!(headers.get(http::header::TRANSFER_ENCODING).is_none());
}
#[test]
fn admin_heal_status_query_without_content_length_is_normalized() {
let paths = [
format!("{ADMIN_PREFIX}/v3/heal/?clientToken=root-heal"),
format!("{ADMIN_PREFIX}/v3/heal/bucket?clientToken=bucket-heal"),
];
for path in paths {
let request = Request::builder()
.method(Method::POST)
.uri(path.clone())
.header(http::header::TRANSFER_ENCODING, "chunked")
.body(())
.expect("request");
assert!(
should_force_zero_content_length_for_empty_body_route(&request),
"{path} should force Content-Length: 0"
);
}
}
#[test]
fn admin_heal_start_without_status_token_is_not_normalized() {
let request = Request::builder()
.method(Method::POST)
.uri(format!("{ADMIN_PREFIX}/v3/heal/"))
.header(http::header::TRANSFER_ENCODING, "chunked")
.body(())
.expect("request");
assert!(!should_force_zero_content_length_for_empty_body_route(&request));
}
#[test]
fn s3_delete_object_version_without_content_length_is_normalized() {
let request = Request::builder()