Skip to content

Commit 371425d

Browse files
authored
fix(admin): align heal commands with RustFS API
1 parent b1878b0 commit 371425d

2 files changed

Lines changed: 224 additions & 11 deletions

File tree

crates/cli/src/commands/admin/heal.rs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,20 @@ impl From<&HealStatus> for HealStatusOutput {
102102
}
103103
}
104104

105+
fn has_heal_status_details(status: &HealStatus) -> bool {
106+
status.healing
107+
|| !status.heal_id.is_empty()
108+
|| !status.bucket.is_empty()
109+
|| !status.object.is_empty()
110+
|| status.items_scanned > 0
111+
|| status.items_healed > 0
112+
|| status.items_failed > 0
113+
|| status.bytes_scanned > 0
114+
|| status.bytes_healed > 0
115+
|| status.started.is_some()
116+
|| status.last_update.is_some()
117+
}
118+
105119
/// JSON output for heal operations
106120
#[derive(Serialize)]
107121
struct HealOperationOutput {
@@ -216,11 +230,14 @@ async fn execute_start(args: StartArgs, formatter: &Formatter) -> ExitCode {
216230

217231
match client.heal_start(request).await {
218232
Ok(status) => {
233+
let status_output =
234+
has_heal_status_details(&status).then(|| HealStatusOutput::from(&status));
235+
219236
if formatter.is_json() {
220237
let output = HealOperationOutput {
221238
success: true,
222239
message: "Heal operation started successfully".to_string(),
223-
status: Some(HealStatusOutput::from(&status)),
240+
status: status_output,
224241
};
225242
formatter.json(&output);
226243
} else {
@@ -229,8 +246,10 @@ async fn execute_start(args: StartArgs, formatter: &Formatter) -> ExitCode {
229246
} else {
230247
formatter.success("Heal operation started successfully.");
231248
}
232-
formatter.println("");
233-
print_heal_status(&status, formatter);
249+
if has_heal_status_details(&status) {
250+
formatter.println("");
251+
print_heal_status(&status, formatter);
252+
}
234253
}
235254
ExitCode::Success
236255
}
@@ -355,4 +374,19 @@ mod tests {
355374
assert_eq!(output.items_scanned, 1000);
356375
assert_eq!(output.items_healed, 50);
357376
}
377+
378+
#[test]
379+
fn test_has_heal_status_details() {
380+
assert!(!has_heal_status_details(&HealStatus::default()));
381+
382+
assert!(has_heal_status_details(&HealStatus {
383+
healing: true,
384+
..Default::default()
385+
}));
386+
387+
assert!(has_heal_status_details(&HealStatus {
388+
started: Some("2024-01-01T10:00:00Z".to_string()),
389+
..Default::default()
390+
}));
391+
}
358392
}

crates/s3/src/admin.rs

Lines changed: 187 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use aws_sigv4::http_request::{
1111
use aws_sigv4::sign::v4;
1212
use rc_core::admin::{
1313
AdminApi, BucketQuota, ClusterInfo, CreateServiceAccountRequest, Group, GroupStatus,
14-
HealStartRequest, HealStatus, Policy, PolicyEntity, PolicyInfo, ServiceAccount,
14+
HealScanMode, HealStartRequest, HealStatus, Policy, PolicyEntity, PolicyInfo, ServiceAccount,
1515
ServiceAccountCreateResponse, UpdateGroupMembersRequest, User, UserStatus,
1616
};
1717
use rc_core::{Alias, Error, Result};
@@ -296,7 +296,7 @@ impl AdminClient {
296296
StatusCode::NOT_FOUND => Error::NotFound(body.to_string()),
297297
StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => Error::Auth(body.to_string()),
298298
StatusCode::CONFLICT => Error::Conflict(body.to_string()),
299-
StatusCode::BAD_REQUEST => Error::InvalidPath(body.to_string()),
299+
StatusCode::BAD_REQUEST => Error::General(format!("Bad request: {body}")),
300300
_ => Error::Network(format!("HTTP {}: {}", status.as_u16(), body)),
301301
}
302302
}
@@ -361,6 +361,87 @@ struct ServiceAccountInfo {
361361
implied_policy: Option<bool>,
362362
}
363363

364+
#[derive(Debug, Deserialize)]
365+
#[serde(rename_all = "camelCase")]
366+
struct BackgroundHealStatusResponse {
367+
#[serde(default)]
368+
bitrot_start_time: Option<String>,
369+
}
370+
371+
impl From<BackgroundHealStatusResponse> for HealStatus {
372+
fn from(response: BackgroundHealStatusResponse) -> Self {
373+
Self {
374+
healing: response.bitrot_start_time.is_some(),
375+
started: response.bitrot_start_time,
376+
..Default::default()
377+
}
378+
}
379+
}
380+
381+
#[derive(Debug, Serialize)]
382+
struct RustfsHealOptions {
383+
recursive: bool,
384+
#[serde(rename = "dryRun")]
385+
dry_run: bool,
386+
remove: bool,
387+
recreate: bool,
388+
#[serde(rename = "scanMode")]
389+
scan_mode: u8,
390+
#[serde(rename = "updateParity")]
391+
update_parity: bool,
392+
#[serde(rename = "nolock")]
393+
no_lock: bool,
394+
}
395+
396+
impl From<&HealStartRequest> for RustfsHealOptions {
397+
fn from(request: &HealStartRequest) -> Self {
398+
Self {
399+
recursive: false,
400+
dry_run: request.dry_run,
401+
remove: request.remove,
402+
recreate: request.recreate,
403+
scan_mode: rustfs_heal_scan_mode(request.scan_mode),
404+
update_parity: false,
405+
no_lock: false,
406+
}
407+
}
408+
}
409+
410+
fn rustfs_heal_scan_mode(scan_mode: HealScanMode) -> u8 {
411+
match scan_mode {
412+
HealScanMode::Normal => 1,
413+
HealScanMode::Deep => 2,
414+
}
415+
}
416+
417+
fn rustfs_heal_path(request: &HealStartRequest) -> Result<String> {
418+
let bucket = request
419+
.bucket
420+
.as_deref()
421+
.filter(|bucket| !bucket.is_empty());
422+
let prefix = request
423+
.prefix
424+
.as_deref()
425+
.filter(|prefix| !prefix.is_empty());
426+
427+
match (bucket, prefix) {
428+
(None, None) => Ok("/heal/".to_string()),
429+
(Some(bucket), None) => Ok(format!("/heal/{}", urlencoding::encode(bucket))),
430+
(Some(bucket), Some(prefix)) => Ok(format!(
431+
"/heal/{}/{}",
432+
urlencoding::encode(bucket),
433+
urlencoding::encode(prefix)
434+
)),
435+
(None, Some(_)) => Err(Error::InvalidPath(
436+
"heal prefix requires a bucket target".to_string(),
437+
)),
438+
}
439+
}
440+
441+
fn rustfs_heal_body(request: &HealStartRequest) -> Result<Vec<u8>> {
442+
serde_json::to_vec(&RustfsHealOptions::from(request)).map_err(Error::Json)
443+
}
444+
364445
/// Request body for setting bucket quota
365446
#[derive(Debug, Serialize)]
366447
#[serde(rename_all = "camelCase")]
@@ -378,18 +459,29 @@ impl AdminApi for AdminClient {
378459
}
379460

380461
async fn heal_status(&self) -> Result<HealStatus> {
381-
self.request(Method::GET, "/heal/status", None, None).await
462+
let response: BackgroundHealStatusResponse = self
463+
.request(Method::POST, "/background-heal/status", None, None)
464+
.await?;
465+
Ok(response.into())
382466
}
383467

384468
async fn heal_start(&self, request: HealStartRequest) -> Result<HealStatus> {
385-
let body = serde_json::to_vec(&request).map_err(Error::Json)?;
386-
self.request(Method::POST, "/heal/start", None, Some(&body))
387-
.await
469+
let path = rustfs_heal_path(&request)?;
470+
let body = rustfs_heal_body(&request)?;
471+
self.request_no_response(Method::POST, &path, None, Some(&body))
472+
.await?;
473+
Ok(HealStatus::default())
388474
}
389475

390476
async fn heal_stop(&self) -> Result<()> {
391-
self.request_no_response(Method::POST, "/heal/stop", None, None)
392-
.await
477+
let body = rustfs_heal_body(&HealStartRequest::default())?;
478+
self.request_no_response(
479+
Method::POST,
480+
"/heal/",
481+
Some(&[("forceStop", "true")]),
482+
Some(&body),
483+
)
484+
.await
393485
}
394486

395487
// ==================== User Operations ====================
@@ -846,6 +938,93 @@ mod tests {
846938
);
847939
}
848940

941+
#[test]
942+
fn test_rustfs_heal_path_matches_admin_routes() {
943+
assert_eq!(
944+
rustfs_heal_path(&HealStartRequest::default()).expect("root path"),
945+
"/heal/"
946+
);
947+
948+
let bucket_request = HealStartRequest {
949+
bucket: Some("photos".to_string()),
950+
..Default::default()
951+
};
952+
assert_eq!(
953+
rustfs_heal_path(&bucket_request).expect("bucket path"),
954+
"/heal/photos"
955+
);
956+
957+
let prefix_request = HealStartRequest {
958+
bucket: Some("photos".to_string()),
959+
prefix: Some("2026/raw".to_string()),
960+
..Default::default()
961+
};
962+
assert_eq!(
963+
rustfs_heal_path(&prefix_request).expect("prefix path"),
964+
"/heal/photos/2026%2Fraw"
965+
);
966+
967+
let invalid_request = HealStartRequest {
968+
prefix: Some("2026/raw".to_string()),
969+
..Default::default()
970+
};
971+
assert!(matches!(
972+
rustfs_heal_path(&invalid_request),
973+
Err(Error::InvalidPath(_))
974+
));
975+
}
976+
977+
#[test]
978+
fn test_rustfs_heal_body_matches_server_heal_options() {
979+
let request = HealStartRequest {
980+
scan_mode: HealScanMode::Deep,
981+
remove: true,
982+
recreate: true,
983+
dry_run: true,
984+
..Default::default()
985+
};
986+
987+
let body = rustfs_heal_body(&request).expect("heal options should serialize");
988+
let value: serde_json::Value =
989+
serde_json::from_slice(&body).expect("heal options body should be JSON");
990+
991+
assert_eq!(value["recursive"], false);
992+
assert_eq!(value["dryRun"], true);
993+
assert_eq!(value["remove"], true);
994+
assert_eq!(value["recreate"], true);
995+
assert_eq!(value["scanMode"], 2);
996+
assert_eq!(value["updateParity"], false);
997+
assert_eq!(value["nolock"], false);
998+
assert!(value.get("bucket").is_none());
999+
assert!(value.get("prefix").is_none());
1000+
}
1001+
1002+
#[test]
1003+
fn test_background_heal_status_response_maps_to_heal_status() {
1004+
let status = HealStatus::from(BackgroundHealStatusResponse {
1005+
bitrot_start_time: Some("2026-04-19T10:00:00Z".to_string()),
1006+
});
1007+
1008+
assert!(status.healing);
1009+
assert_eq!(status.started.as_deref(), Some("2026-04-19T10:00:00Z"));
1010+
1011+
let idle = HealStatus::from(BackgroundHealStatusResponse {
1012+
bitrot_start_time: None,
1013+
});
1014+
assert!(!idle.healing);
1015+
assert!(idle.started.is_none());
1016+
}
1017+
1018+
#[test]
1019+
fn test_bad_request_maps_to_general_admin_error() {
1020+
let alias = Alias::new("test", "http://localhost:9000", "access", "secret");
1021+
let client = AdminClient::new(&alias).expect("admin client should build");
1022+
1023+
let error = client.map_error(StatusCode::BAD_REQUEST, "err request body parse");
1024+
assert!(matches!(error, Error::General(_)));
1025+
assert_eq!(error.to_string(), "Bad request: err request body parse");
1026+
}
1027+
8491028
#[test]
8501029
fn test_admin_client_invalid_ca_bundle_path_surfaces_error() {
8511030
let mut alias = Alias::new("test", "https://localhost:9000", "access", "secret");

0 commit comments

Comments
 (0)