mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-27 00:38:16 +00:00
fix(heal): coordinate cluster-wide control operations (#5003)
This commit is contained in:
@@ -99,6 +99,11 @@ fn validate_heal_control_capability_proof(canonical_ack: &[u8], proof: &[u8]) ->
|
||||
.map_err(|_| Error::other("peer returned an invalid heal control capability proof"))
|
||||
}
|
||||
|
||||
fn validate_heal_control_response_proof(canonical_response: &[u8], proof: &[u8]) -> Result<()> {
|
||||
verify_tonic_rpc_response_proof(canonical_response, proof)
|
||||
.map_err(|_| Error::other("peer returned an invalid heal control response proof"))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PeerLiveEventsBatch {
|
||||
pub events: Vec<u8>,
|
||||
@@ -737,6 +742,7 @@ impl PeerRestClient {
|
||||
if command.len() > HEAL_CONTROL_PAYLOAD_MAX_SIZE {
|
||||
return Err(Error::other("heal control command exceeds size limit"));
|
||||
}
|
||||
let capability_probe = rustfs_protos::is_heal_control_capability_probe(&command);
|
||||
self.finalize_result(
|
||||
async {
|
||||
let mut client = self
|
||||
@@ -748,9 +754,10 @@ impl PeerRestClient {
|
||||
.map_err(|_| Error::other("heal control request length cannot be represented"))?;
|
||||
let mut request = Request::new(HealControlRequest {
|
||||
version,
|
||||
topology_fingerprint,
|
||||
command: command.into(),
|
||||
topology_fingerprint: topology_fingerprint.clone(),
|
||||
command: command.clone().into(),
|
||||
});
|
||||
request.set_timeout(rustfs_protos::heal_control_execution_timeout());
|
||||
set_tonic_canonical_body_digest(&mut request, &canonical_body)?;
|
||||
let response = client.heal_control(request).await?.into_inner();
|
||||
if !response.success {
|
||||
@@ -760,6 +767,16 @@ impl PeerRestClient {
|
||||
.unwrap_or_else(|| "peer heal control failed without an error".to_string()),
|
||||
));
|
||||
}
|
||||
if !capability_probe {
|
||||
let canonical_response = rustfs_protos::canonical_heal_control_response_body(
|
||||
version,
|
||||
&topology_fingerprint,
|
||||
&command,
|
||||
&response.result,
|
||||
)
|
||||
.map_err(|_| Error::other("heal control response length cannot be represented"))?;
|
||||
validate_heal_control_response_proof(&canonical_response, &response.response_proof)?;
|
||||
}
|
||||
Ok(response.result.to_vec())
|
||||
}
|
||||
.await,
|
||||
@@ -767,7 +784,8 @@ impl PeerRestClient {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Confirms that a peer supports heal-control v1 and has the same storage
|
||||
/// Confirms that a peer supports the current heal-control coordination
|
||||
/// contract and has the same storage
|
||||
/// topology. Every non-success response is an error so old or divergent
|
||||
/// peers cannot be mistaken for compatible ones.
|
||||
pub async fn probe_heal_control(&self, topology_fingerprint: String) -> Result<()> {
|
||||
@@ -1407,6 +1425,24 @@ mod tests {
|
||||
assert!(err.to_string().contains("invalid heal control capability proof"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_control_response_proof_binds_command_and_result() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let canonical = rustfs_protos::canonical_heal_control_response_body(2, "fingerprint", b"query", b"result")
|
||||
.expect("small response should encode");
|
||||
let proof = crate::cluster::rpc::sign_tonic_rpc_response_proof(&canonical).expect("test proof should sign");
|
||||
assert!(validate_heal_control_response_proof(&canonical, &proof).is_ok());
|
||||
|
||||
for tampered in [
|
||||
rustfs_protos::canonical_heal_control_response_body(2, "fingerprint", b"cancel", b"result").unwrap(),
|
||||
rustfs_protos::canonical_heal_control_response_body(2, "fingerprint", b"query", b"tampered").unwrap(),
|
||||
] {
|
||||
let err = validate_heal_control_response_proof(&tampered, &proof)
|
||||
.expect_err("proof must not authenticate a different command or result");
|
||||
assert!(err.to_string().contains("invalid heal control response proof"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn peer_rest_client_prepare_retry_clears_offline_gate() {
|
||||
// finalize_result sets the offline gate on a network error; without
|
||||
|
||||
+188
-16
@@ -34,6 +34,7 @@ const LOG_SUBSYSTEM_CHANNEL: &str = "channel";
|
||||
const EVENT_HEAL_CHANNEL_STATE: &str = "heal_channel_state";
|
||||
const EVENT_HEAL_CHANNEL_REQUEST: &str = "heal_channel_request";
|
||||
const EVENT_HEAL_CHANNEL_RESPONSE: &str = "heal_channel_response";
|
||||
const MAX_HEAL_STATUS_PAYLOAD_SIZE: usize = 8 * 1024 * 1024;
|
||||
|
||||
fn admission_response(request_id: String, admission: HealAdmissionResult) -> HealChannelResponse {
|
||||
let (success, error) = match admission {
|
||||
@@ -61,11 +62,56 @@ pub struct HealChannelProcessor {
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HealTaskStatusPayload {
|
||||
summary: String,
|
||||
items: Vec<HealResultItem>,
|
||||
struct HealTaskStatusPayload<'a> {
|
||||
summary: &'a str,
|
||||
items: &'a [HealResultItem],
|
||||
truncated: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
progress: Option<HealProgress>,
|
||||
progress: Option<&'a HealProgress>,
|
||||
}
|
||||
|
||||
fn encode_heal_task_status_payload(
|
||||
summary: &str,
|
||||
mut items: Vec<HealResultItem>,
|
||||
progress: Option<&HealProgress>,
|
||||
mut truncated: bool,
|
||||
) -> Result<(Vec<u8>, bool)> {
|
||||
loop {
|
||||
let data = serde_json::to_vec(&HealTaskStatusPayload {
|
||||
summary,
|
||||
items: &items,
|
||||
truncated,
|
||||
progress,
|
||||
})
|
||||
.map_err(|e| Error::Serialization(format!("failed to serialize heal task status: {e}")))?;
|
||||
if data.len() <= MAX_HEAL_STATUS_PAYLOAD_SIZE {
|
||||
return Ok((data, truncated));
|
||||
}
|
||||
if items.is_empty() {
|
||||
return Err(Error::Serialization("heal task status metadata exceeds size limit".to_string()));
|
||||
}
|
||||
truncated = true;
|
||||
items.truncate(items.len() / 2);
|
||||
}
|
||||
}
|
||||
|
||||
fn heal_status_detail(detail: Option<String>, truncated: bool) -> Option<String> {
|
||||
if !truncated {
|
||||
return detail;
|
||||
}
|
||||
let truncation = "heal result items were truncated";
|
||||
Some(detail.map_or_else(|| truncation.to_string(), |detail| format!("{detail}; {truncation}")))
|
||||
}
|
||||
|
||||
fn encode_heal_status_response(
|
||||
summary: &str,
|
||||
items: Vec<HealResultItem>,
|
||||
progress: Option<&HealProgress>,
|
||||
detail: Option<String>,
|
||||
truncated: bool,
|
||||
) -> Result<(Vec<u8>, Option<String>)> {
|
||||
let (data, truncated) = encode_heal_task_status_payload(summary, items, progress, truncated)?;
|
||||
Ok((data, heal_status_detail(detail, truncated)))
|
||||
}
|
||||
|
||||
impl HealChannelProcessor {
|
||||
@@ -79,6 +125,37 @@ impl HealChannelProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a start directly against the manager without entering the
|
||||
/// process-global unbounded command queue.
|
||||
pub async fn execute_start_request(&self, request: HealChannelRequest) -> Result<HealAdmissionReceipt> {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
self.process_start_request(request, false, true, response_tx).await?;
|
||||
response_rx
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("heal receipt channel closed: {err}")))?
|
||||
.map_err(Error::other)
|
||||
}
|
||||
|
||||
/// Execute a token query directly against the manager.
|
||||
pub async fn execute_query_request(&self, heal_path: String, client_token: String) -> Result<HealChannelResponse> {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
self.process_query_request(heal_path, client_token, response_tx).await?;
|
||||
response_rx
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("heal query channel closed: {err}")))?
|
||||
.map_err(Error::other)
|
||||
}
|
||||
|
||||
/// Execute cancellation directly against the manager.
|
||||
pub async fn execute_cancel_request(&self, heal_path: String, client_token: String) -> Result<HealChannelResponse> {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
self.process_cancel_request(heal_path, client_token, response_tx).await?;
|
||||
response_rx
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("heal cancel channel closed: {err}")))?
|
||||
.map_err(Error::other)
|
||||
}
|
||||
|
||||
/// Start processing legacy heal channel requests.
|
||||
pub async fn start(&mut self, receiver: HealChannelReceiver) -> Result<()> {
|
||||
let (receipt_sender, receipt_receiver) = mpsc::unbounded_channel();
|
||||
@@ -326,46 +403,66 @@ impl HealChannelProcessor {
|
||||
self.heal_manager.get_task_report_for_path(&heal_path, &client_token).await
|
||||
};
|
||||
|
||||
let (summary, detail, items, progress) = match report {
|
||||
let (summary, detail, items, truncated, progress) = match report {
|
||||
Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Pending | HealTaskStatus::Running,
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
}) => ("running".to_string(), None, result_items, progress),
|
||||
}) => ("running".to_string(), None, result_items, result_items_truncated, progress),
|
||||
Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Retrying { error, retry_attempt },
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
}) => (
|
||||
"running".to_string(),
|
||||
Some(format!("heal task retrying after recoverable failure, attempt {retry_attempt}: {error}")),
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
),
|
||||
Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Completed,
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
}) => ("finished".to_string(), None, result_items, progress),
|
||||
}) => ("finished".to_string(), None, result_items, result_items_truncated, progress),
|
||||
Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Cancelled,
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
}) => ("stopped".to_string(), Some("heal task cancelled".to_string()), result_items, progress),
|
||||
}) => (
|
||||
"stopped".to_string(),
|
||||
Some("heal task cancelled".to_string()),
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
),
|
||||
Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Timeout,
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
}) => ("stopped".to_string(), Some("heal task timed out".to_string()), result_items, progress),
|
||||
}) => (
|
||||
"stopped".to_string(),
|
||||
Some("heal task timed out".to_string()),
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
),
|
||||
Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Failed { error },
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
}) => ("stopped".to_string(), Some(error), result_items, progress),
|
||||
}) => ("stopped".to_string(), Some(error), result_items, result_items_truncated, progress),
|
||||
Err(crate::Error::TaskNotFound { .. }) => (
|
||||
"notFound".to_string(),
|
||||
Some("heal task not found or expired".to_string()),
|
||||
Vec::new(),
|
||||
false,
|
||||
None,
|
||||
),
|
||||
Err(crate::Error::InvalidClientToken) => {
|
||||
@@ -393,12 +490,7 @@ impl HealChannelProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
let data = serde_json::to_vec(&HealTaskStatusPayload {
|
||||
summary,
|
||||
items,
|
||||
progress,
|
||||
})
|
||||
.map_err(|e| crate::Error::Serialization(format!("failed to serialize heal task status: {e}")))?;
|
||||
let (data, detail) = encode_heal_status_response(&summary, items, progress.as_ref(), detail, truncated)?;
|
||||
|
||||
let response = HealChannelResponse {
|
||||
request_id: client_token,
|
||||
@@ -697,6 +789,22 @@ mod tests {
|
||||
// If we can get the sender, processor was created correctly
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_status_items_are_truncated_before_transport() {
|
||||
let items = vec![HealResultItem {
|
||||
detail: "x".repeat(MAX_HEAL_STATUS_PAYLOAD_SIZE + 1),
|
||||
..Default::default()
|
||||
}];
|
||||
|
||||
let (data, detail) = encode_heal_status_response("running", items, None, None, false).unwrap();
|
||||
|
||||
assert!(data.len() <= MAX_HEAL_STATUS_PAYLOAD_SIZE);
|
||||
let payload: serde_json::Value = serde_json::from_slice(&data).unwrap();
|
||||
assert_eq!(payload["truncated"], true);
|
||||
assert!(payload["items"].as_array().unwrap().is_empty());
|
||||
assert_eq!(detail.as_deref(), Some("heal result items were truncated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_response_preserves_all_admission_outcomes() {
|
||||
let cases = [
|
||||
@@ -1339,6 +1447,70 @@ mod tests {
|
||||
.expect("receipt processor should stop cleanly");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_control_execution_preserves_target_dedup_and_token_ownership() {
|
||||
let manager = create_test_heal_manager();
|
||||
let processor = HealChannelProcessor::new(manager);
|
||||
let original_id = uuid::Uuid::new_v4().to_string();
|
||||
let request = HealChannelRequest {
|
||||
id: original_id.clone(),
|
||||
bucket: "bucket-a".to_string(),
|
||||
object_prefix: Some("object".to_string()),
|
||||
priority: HealChannelPriority::High,
|
||||
source: HealRequestSource::Admin,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let accepted = processor
|
||||
.execute_start_request(request.clone())
|
||||
.await
|
||||
.expect("first direct start should be admitted");
|
||||
assert_eq!(accepted.result, HealAdmissionResult::Accepted);
|
||||
assert_eq!(accepted.task_id, original_id);
|
||||
|
||||
let mut duplicate = request;
|
||||
duplicate.id = uuid::Uuid::new_v4().to_string();
|
||||
let merged = processor
|
||||
.execute_start_request(duplicate)
|
||||
.await
|
||||
.expect("duplicate direct start should merge");
|
||||
assert_eq!(merged.result, HealAdmissionResult::Merged);
|
||||
assert_eq!(merged.task_id, accepted.task_id);
|
||||
|
||||
let other = processor
|
||||
.execute_start_request(HealChannelRequest {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
bucket: "bucket-b".to_string(),
|
||||
object_prefix: Some("object".to_string()),
|
||||
priority: HealChannelPriority::High,
|
||||
source: HealRequestSource::Admin,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("different target should be admitted independently");
|
||||
assert_eq!(other.result, HealAdmissionResult::Accepted);
|
||||
assert_ne!(other.task_id, accepted.task_id);
|
||||
|
||||
let status = processor
|
||||
.execute_query_request(String::new(), accepted.task_id.clone())
|
||||
.await
|
||||
.expect("canonical token should be queryable");
|
||||
assert!(status.success);
|
||||
let cancelled = processor
|
||||
.execute_cancel_request(String::new(), accepted.task_id.clone())
|
||||
.await
|
||||
.expect("canonical token should be cancellable");
|
||||
assert!(cancelled.success);
|
||||
let stopped = processor
|
||||
.execute_query_request(String::new(), accepted.task_id)
|
||||
.await
|
||||
.expect("cancelled task status should remain queryable");
|
||||
assert!(stopped.success);
|
||||
assert_eq!(stopped.error.as_deref(), Some("heal task not found or expired"));
|
||||
let status: serde_json::Value = serde_json::from_slice(stopped.data.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(status["summary"], "notFound");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_process_start_request_returns_error_on_invalid_request() {
|
||||
let heal_manager = create_test_heal_manager();
|
||||
|
||||
@@ -179,6 +179,7 @@ struct CompletedHealStatus {
|
||||
heal_type: HealType,
|
||||
status: HealTaskStatus,
|
||||
result_items: Vec<HealResultItem>,
|
||||
result_items_truncated: bool,
|
||||
completed_at: SystemTime,
|
||||
}
|
||||
|
||||
@@ -198,6 +199,7 @@ struct RetryingHeal {
|
||||
pub struct HealTaskReport {
|
||||
pub status: HealTaskStatus,
|
||||
pub result_items: Vec<HealResultItem>,
|
||||
pub result_items_truncated: bool,
|
||||
pub progress: Option<HealProgress>,
|
||||
}
|
||||
|
||||
@@ -1600,6 +1602,7 @@ impl HealManager {
|
||||
return Ok(HealTaskReport {
|
||||
status: task.get_status().await,
|
||||
result_items: task.get_result_items().await,
|
||||
result_items_truncated: task.result_items_truncated(),
|
||||
progress: Some(task.get_progress().await),
|
||||
});
|
||||
}
|
||||
@@ -1611,6 +1614,7 @@ impl HealManager {
|
||||
return Ok(HealTaskReport {
|
||||
status: retrying.status(),
|
||||
result_items: Vec::new(),
|
||||
result_items_truncated: false,
|
||||
progress: None,
|
||||
});
|
||||
}
|
||||
@@ -1625,6 +1629,7 @@ impl HealManager {
|
||||
return Ok(HealTaskReport {
|
||||
status: completed.status.clone(),
|
||||
result_items: completed.result_items.clone(),
|
||||
result_items_truncated: completed.result_items_truncated,
|
||||
progress: None,
|
||||
});
|
||||
}
|
||||
@@ -1636,6 +1641,7 @@ impl HealManager {
|
||||
return Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Pending,
|
||||
result_items: Vec::new(),
|
||||
result_items_truncated: false,
|
||||
progress: None,
|
||||
});
|
||||
}
|
||||
@@ -1647,6 +1653,7 @@ impl HealManager {
|
||||
return Ok(HealTaskReport {
|
||||
status: completed.status.clone(),
|
||||
result_items: completed.result_items.clone(),
|
||||
result_items_truncated: completed.result_items_truncated,
|
||||
progress: None,
|
||||
});
|
||||
}
|
||||
@@ -1666,6 +1673,7 @@ impl HealManager {
|
||||
return Ok(HealTaskReport {
|
||||
status: task.get_status().await,
|
||||
result_items: task.get_result_items().await,
|
||||
result_items_truncated: task.result_items_truncated(),
|
||||
progress: Some(task.get_progress().await),
|
||||
});
|
||||
}
|
||||
@@ -1679,6 +1687,7 @@ impl HealManager {
|
||||
return Ok(HealTaskReport {
|
||||
status: retrying.status(),
|
||||
result_items: Vec::new(),
|
||||
result_items_truncated: false,
|
||||
progress: None,
|
||||
});
|
||||
}
|
||||
@@ -1694,6 +1703,7 @@ impl HealManager {
|
||||
return Ok(HealTaskReport {
|
||||
status: completed.status.clone(),
|
||||
result_items: completed.result_items.clone(),
|
||||
result_items_truncated: completed.result_items_truncated,
|
||||
progress: None,
|
||||
});
|
||||
}
|
||||
@@ -1705,6 +1715,7 @@ impl HealManager {
|
||||
return Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Pending,
|
||||
result_items: Vec::new(),
|
||||
result_items_truncated: false,
|
||||
progress: None,
|
||||
});
|
||||
}
|
||||
@@ -1719,6 +1730,7 @@ impl HealManager {
|
||||
return Ok(HealTaskReport {
|
||||
status: completed.status.clone(),
|
||||
result_items: completed.result_items.clone(),
|
||||
result_items_truncated: completed.result_items_truncated,
|
||||
progress: None,
|
||||
});
|
||||
}
|
||||
@@ -2618,6 +2630,7 @@ impl HealManager {
|
||||
heal_type: completed_task.heal_type.clone(),
|
||||
status: completed_status.clone(),
|
||||
result_items: completed_task.get_result_items().await,
|
||||
result_items_truncated: completed_task.result_items_truncated(),
|
||||
completed_at: SystemTime::now(),
|
||||
};
|
||||
let mut completed_heals_guard = completed_heals_clone.lock().await;
|
||||
@@ -3848,6 +3861,7 @@ mod tests {
|
||||
retry_attempt: request.retry_attempts,
|
||||
},
|
||||
result_items: Vec::new(),
|
||||
result_items_truncated: false,
|
||||
completed_at: SystemTime::now(),
|
||||
},
|
||||
);
|
||||
@@ -4411,6 +4425,7 @@ mod tests {
|
||||
},
|
||||
status: HealTaskStatus::Completed,
|
||||
result_items: Vec::new(),
|
||||
result_items_truncated: false,
|
||||
completed_at: SystemTime::now(),
|
||||
},
|
||||
);
|
||||
@@ -4444,6 +4459,7 @@ mod tests {
|
||||
object_size: 1024,
|
||||
..Default::default()
|
||||
}],
|
||||
result_items_truncated: true,
|
||||
completed_at: SystemTime::now(),
|
||||
},
|
||||
);
|
||||
@@ -4452,6 +4468,7 @@ mod tests {
|
||||
.get_task_report_for_path("bucket/object", "completed-token")
|
||||
.await
|
||||
.expect("recent completed task report should be queryable");
|
||||
assert!(report.result_items_truncated);
|
||||
|
||||
assert_eq!(report.status, HealTaskStatus::Completed);
|
||||
assert_eq!(report.result_items.len(), 1);
|
||||
|
||||
@@ -43,6 +43,7 @@ const LOG_SUBSYSTEM_OBJECT: &str = "object";
|
||||
const EVENT_HEAL_TASK_STATE: &str = "heal_task_state";
|
||||
const EVENT_HEAL_OBJECT_STAGE: &str = "heal_object_stage";
|
||||
const EVENT_HEAL_OBJECT_MISSING: &str = "heal_object_missing";
|
||||
const MAX_RETAINED_HEAL_RESULT_ITEMS: usize = 1024;
|
||||
const EVENT_HEAL_OBJECT_RESULT: &str = "heal_object_result";
|
||||
const MAX_BUCKET_OBJECT_HEAL_RETRIES: u32 = 3;
|
||||
const MAX_BUCKET_FAILURE_LOG_SAMPLES: u64 = 5;
|
||||
@@ -306,6 +307,7 @@ pub struct HealTask {
|
||||
pub progress: Arc<RwLock<HealProgress>>,
|
||||
/// Result items collected from storage heal calls.
|
||||
pub result_items: Arc<RwLock<Vec<HealResultItem>>>,
|
||||
result_items_truncated: Arc<AtomicBool>,
|
||||
batch_failure: Arc<RwLock<Option<BatchHealFailure>>>,
|
||||
batch_failure_recorded: Arc<AtomicBool>,
|
||||
/// Created time
|
||||
@@ -337,6 +339,7 @@ impl HealTask {
|
||||
status: Arc::new(RwLock::new(HealTaskStatus::Pending)),
|
||||
progress: Arc::new(RwLock::new(HealProgress::new())),
|
||||
result_items: Arc::new(RwLock::new(Vec::new())),
|
||||
result_items_truncated: Arc::new(AtomicBool::new(false)),
|
||||
batch_failure: Arc::new(RwLock::new(None)),
|
||||
batch_failure_recorded: Arc::new(AtomicBool::new(false)),
|
||||
created_at: request.created_at,
|
||||
@@ -741,8 +744,17 @@ impl HealTask {
|
||||
self.result_items.read().await.clone()
|
||||
}
|
||||
|
||||
pub fn result_items_truncated(&self) -> bool {
|
||||
self.result_items_truncated.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
async fn record_result_item(&self, result: HealResultItem) {
|
||||
self.result_items.write().await.push(result);
|
||||
let mut result_items = self.result_items.write().await;
|
||||
if result_items.len() < MAX_RETAINED_HEAL_RESULT_ITEMS {
|
||||
result_items.push(result);
|
||||
} else {
|
||||
self.result_items_truncated.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
// specific heal implementation method
|
||||
@@ -2656,6 +2668,19 @@ mod tests {
|
||||
assert_eq!(result_items.iter().filter(|item| item.object_size == 1).count(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn result_items_are_bounded_and_report_truncation() {
|
||||
let storage = Arc::new(MockStorage::default());
|
||||
let task = HealTask::from_request(HealRequest::bucket("bucket-a".to_string()), storage);
|
||||
|
||||
for _ in 0..=MAX_RETAINED_HEAL_RESULT_ITEMS {
|
||||
task.record_result_item(HealResultItem::default()).await;
|
||||
}
|
||||
|
||||
assert_eq!(task.get_result_items().await.len(), MAX_RETAINED_HEAL_RESULT_ITEMS);
|
||||
assert!(task.result_items_truncated());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recursive_bucket_heal_skips_object_dir_candidates() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
|
||||
@@ -1114,6 +1114,8 @@ pub struct HealControlResponse {
|
||||
pub result: ::prost::bytes::Bytes,
|
||||
#[prost(string, optional, tag = "3")]
|
||||
pub error_info: ::core::option::Option<::prost::alloc::string::String>,
|
||||
#[prost(bytes = "bytes", tag = "4")]
|
||||
pub response_proof: ::prost::bytes::Bytes,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct GetMetacacheListingRequest {
|
||||
|
||||
@@ -29,8 +29,10 @@ use std::{fmt, io::Cursor, io::Write};
|
||||
|
||||
const ENVELOPE_VERSION: u8 = 1;
|
||||
pub const ENVELOPE_MAX_SIZE: usize = 64 * 1024;
|
||||
pub const RESULT_MAX_SIZE: usize = 16 * 1024 * 1024;
|
||||
pub const NONCE_SIZE: usize = 16;
|
||||
pub const MAX_LIFETIME_MS: i64 = 30_000;
|
||||
const MAX_CLOCK_SKEW_MS: i64 = 5_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RequestMetadata {
|
||||
@@ -136,6 +138,38 @@ impl TryFrom<HealChannelRequest> for StartCommand {
|
||||
}
|
||||
}
|
||||
|
||||
impl StartCommand {
|
||||
fn into_channel_request(self, request_id: String) -> Result<HealChannelRequest, String> {
|
||||
Ok(HealChannelRequest {
|
||||
id: request_id,
|
||||
disk: self.disk,
|
||||
bucket: self.bucket,
|
||||
object_prefix: self.object_prefix,
|
||||
object_version_id: self.object_version_id,
|
||||
force_start: self.force_start,
|
||||
priority: self.priority.into(),
|
||||
pool_index: self
|
||||
.pool_index
|
||||
.map(usize::try_from)
|
||||
.transpose()
|
||||
.map_err(|_| "heal pool index exceeds platform range".to_string())?,
|
||||
set_index: self
|
||||
.set_index
|
||||
.map(usize::try_from)
|
||||
.transpose()
|
||||
.map_err(|_| "heal set index exceeds platform range".to_string())?,
|
||||
scan_mode: self.scan_mode,
|
||||
remove_corrupted: self.remove_corrupted,
|
||||
recreate_missing: self.recreate_missing,
|
||||
update_parity: self.update_parity,
|
||||
recursive: self.recursive,
|
||||
dry_run: self.dry_run,
|
||||
timeout_seconds: self.timeout_seconds,
|
||||
source: self.source,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Command {
|
||||
@@ -144,6 +178,13 @@ pub enum Command {
|
||||
Cancel { heal_path: String, client_token: String },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ExecutableCommand {
|
||||
Start { request: HealChannelRequest },
|
||||
Query { heal_path: String, client_token: String },
|
||||
Cancel { heal_path: String, client_token: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct Envelope {
|
||||
@@ -214,13 +255,42 @@ impl Envelope {
|
||||
}
|
||||
match &self.command {
|
||||
Command::Start { .. } => {}
|
||||
Command::Query { client_token, .. } | Command::Cancel { client_token, .. } if client_token.is_empty() => {
|
||||
Command::Query { client_token, .. } if client_token.is_empty() => {
|
||||
return Err("heal control client token is empty".to_string());
|
||||
}
|
||||
Command::Query { .. } | Command::Cancel { .. } => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_execution(&self, now_unix_ms: i64, expected_coordinator_epoch: u64) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
if self.coordinator_epoch != expected_coordinator_epoch {
|
||||
return Err("heal control coordinator epoch does not match".to_string());
|
||||
}
|
||||
if self.issued_at_unix_ms > now_unix_ms.saturating_add(MAX_CLOCK_SKEW_MS) {
|
||||
return Err("heal control request was issued in the future".to_string());
|
||||
}
|
||||
if self.expires_at_unix_ms <= now_unix_ms {
|
||||
return Err("heal control request expired".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn into_execution(self) -> Result<(String, u64, ExecutableCommand), String> {
|
||||
let command = match self.command {
|
||||
Command::Start { request } => ExecutableCommand::Start {
|
||||
request: request.into_channel_request(self.request_id.clone())?,
|
||||
},
|
||||
Command::Query { heal_path, client_token } => ExecutableCommand::Query { heal_path, client_token },
|
||||
Command::Cancel { heal_path, client_token } => ExecutableCommand::Cancel { heal_path, client_token },
|
||||
};
|
||||
Ok((self.request_id, self.coordinator_epoch, command))
|
||||
}
|
||||
|
||||
pub const fn expires_at_unix_ms(&self) -> i64 {
|
||||
self.expires_at_unix_ms
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -249,6 +319,16 @@ impl Admission {
|
||||
pub const fn is_admitted(self) -> bool {
|
||||
matches!(self, Self::Accepted | Self::Merged)
|
||||
}
|
||||
|
||||
pub const fn into_heal_admission_result(self) -> HealAdmissionResult {
|
||||
match self {
|
||||
Self::Accepted => HealAdmissionResult::Accepted,
|
||||
Self::Merged => HealAdmissionResult::Merged,
|
||||
Self::Full => HealAdmissionResult::Full,
|
||||
Self::DroppedQueueFull => HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull),
|
||||
Self::DroppedPolicy => HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -274,20 +354,20 @@ impl<'de> Visitor<'de> for BoundedBytesVisitor {
|
||||
type Value = BoundedBytes;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(formatter, "at most {ENVELOPE_MAX_SIZE} bytes")
|
||||
write!(formatter, "at most {RESULT_MAX_SIZE} bytes")
|
||||
}
|
||||
|
||||
fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: SeqAccess<'de>,
|
||||
{
|
||||
if sequence.size_hint().is_some_and(|length| length > ENVELOPE_MAX_SIZE) {
|
||||
if sequence.size_hint().is_some_and(|length| length > RESULT_MAX_SIZE) {
|
||||
return Err(serde::de::Error::custom("heal control response data exceeds size limit"));
|
||||
}
|
||||
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(byte) = sequence.next_element()? {
|
||||
if bytes.len() == ENVELOPE_MAX_SIZE {
|
||||
if bytes.len() == RESULT_MAX_SIZE {
|
||||
return Err(serde::de::Error::custom("heal control response data exceeds size limit"));
|
||||
}
|
||||
bytes.push(byte);
|
||||
@@ -358,13 +438,6 @@ impl ResultEnvelope {
|
||||
validate_uuid(&self.request_id, "result request")?;
|
||||
match &self.outcome {
|
||||
Outcome::Start { task_id, .. } => validate_uuid(task_id, "result task")?,
|
||||
Outcome::Channel {
|
||||
success: true,
|
||||
error: Some(_),
|
||||
..
|
||||
} => {
|
||||
return Err("successful heal control result contains an error".to_string());
|
||||
}
|
||||
Outcome::Channel {
|
||||
success: false,
|
||||
error: None,
|
||||
@@ -390,6 +463,17 @@ impl ResultEnvelope {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn into_outcome(self, expected_request_id: &str, expected_epoch: u64) -> Result<Outcome, String> {
|
||||
self.validate()?;
|
||||
if self.request_id != expected_request_id {
|
||||
return Err("heal control result request ID does not match".to_string());
|
||||
}
|
||||
if self.coordinator_epoch != expected_epoch {
|
||||
return Err("heal control result coordinator epoch does not match".to_string());
|
||||
}
|
||||
Ok(self.outcome)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_uuid(value: &str, field: &str) -> Result<(), String> {
|
||||
@@ -400,8 +484,8 @@ fn validate_uuid(value: &str, field: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode<T: for<'de> Deserialize<'de>>(data: &[u8], value_name: &str) -> Result<T, String> {
|
||||
if data.len() > ENVELOPE_MAX_SIZE {
|
||||
fn decode<T: for<'de> Deserialize<'de>>(data: &[u8], value_name: &str, max_size: usize) -> Result<T, String> {
|
||||
if data.len() > max_size {
|
||||
return Err(format!("{value_name} exceeds size limit"));
|
||||
}
|
||||
let mut deserializer = Deserializer::new(Cursor::new(data));
|
||||
@@ -414,35 +498,36 @@ fn decode<T: for<'de> Deserialize<'de>>(data: &[u8], value_name: &str) -> Result
|
||||
|
||||
pub fn encode_envelope(envelope: &Envelope) -> Result<Vec<u8>, String> {
|
||||
envelope.validate()?;
|
||||
encode_bounded(envelope, "heal control envelope")
|
||||
encode_bounded(envelope, "heal control envelope", ENVELOPE_MAX_SIZE)
|
||||
}
|
||||
|
||||
pub fn decode_envelope(data: &[u8]) -> Result<Envelope, String> {
|
||||
let envelope: Envelope = decode(data, "heal control envelope")?;
|
||||
let envelope: Envelope = decode(data, "heal control envelope", ENVELOPE_MAX_SIZE)?;
|
||||
envelope.validate()?;
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
pub fn encode_result(result: &ResultEnvelope) -> Result<Vec<u8>, String> {
|
||||
result.validate()?;
|
||||
encode_bounded(result, "heal control result")
|
||||
encode_bounded(result, "heal control result", RESULT_MAX_SIZE)
|
||||
}
|
||||
|
||||
pub fn decode_result(data: &[u8]) -> Result<ResultEnvelope, String> {
|
||||
let result: ResultEnvelope = decode(data, "heal control result")?;
|
||||
let result: ResultEnvelope = decode(data, "heal control result", RESULT_MAX_SIZE)?;
|
||||
result.validate()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn encode_bounded(value: &impl Serialize, value_name: &str) -> Result<Vec<u8>, String> {
|
||||
fn encode_bounded(value: &impl Serialize, value_name: &str, max_size: usize) -> Result<Vec<u8>, String> {
|
||||
struct BoundedWriter {
|
||||
bytes: Vec<u8>,
|
||||
exceeded: bool,
|
||||
max_size: usize,
|
||||
}
|
||||
|
||||
impl Write for BoundedWriter {
|
||||
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
||||
let remaining = ENVELOPE_MAX_SIZE.saturating_sub(self.bytes.len());
|
||||
let remaining = self.max_size.saturating_sub(self.bytes.len());
|
||||
if data.len() > remaining {
|
||||
self.exceeded = true;
|
||||
return Err(std::io::Error::other("heal control value exceeds size limit"));
|
||||
@@ -459,6 +544,7 @@ fn encode_bounded(value: &impl Serialize, value_name: &str) -> Result<Vec<u8>, S
|
||||
let mut writer = BoundedWriter {
|
||||
bytes: Vec::with_capacity(1024),
|
||||
exceeded: false,
|
||||
max_size,
|
||||
};
|
||||
let result = value.serialize(&mut rmp_serde::Serializer::new(&mut writer).with_struct_map());
|
||||
if writer.exceeded {
|
||||
@@ -471,7 +557,8 @@ fn encode_bounded(value: &impl Serialize, value_name: &str) -> Result<Vec<u8>, S
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
Admission, ENVELOPE_MAX_SIZE, Envelope, Outcome, RequestMetadata, ResultEnvelope, decode_envelope, decode_result,
|
||||
Admission, ENVELOPE_MAX_SIZE, Envelope, Outcome, RESULT_MAX_SIZE, RequestMetadata, ResultEnvelope, decode_envelope,
|
||||
decode_result, encode_result,
|
||||
};
|
||||
use rustfs_common::heal_channel::{HealChannelRequest, HealChannelResponse, HealRequestSource};
|
||||
use serde::de::{DeserializeSeed, SeqAccess, Visitor, value::Error as ValueError};
|
||||
@@ -640,11 +727,7 @@ mod tests {
|
||||
assert!(Envelope::start(test_request(request_id.clone()), metadata(1, 0)).is_err());
|
||||
assert!(Envelope::start(test_request(request_id.clone()), RequestMetadata::new([1; 16], 1_000, 31_001, 7),).is_err());
|
||||
assert!(Envelope::query(request_id.clone(), metadata(1, 7), String::new(), String::new()).is_err());
|
||||
assert!(
|
||||
Envelope::cancel(request_id.clone(), metadata(1, 7), String::new(), String::new())
|
||||
.unwrap_err()
|
||||
.contains("token is empty")
|
||||
);
|
||||
assert!(Envelope::cancel(request_id.clone(), metadata(1, 7), String::new(), String::new()).is_ok());
|
||||
|
||||
let mut noncanonical_request = test_request(request_id.to_uppercase());
|
||||
assert!(Envelope::start(noncanonical_request.clone(), metadata(1, 7)).is_err());
|
||||
@@ -689,6 +772,15 @@ mod tests {
|
||||
let unknown = rmp_serde::to_vec_named(&unknown).unwrap();
|
||||
assert!(decode_envelope(&unknown).unwrap_err().contains("unknown field"));
|
||||
|
||||
let executable =
|
||||
Envelope::start(test_request(request_id.clone()), RequestMetadata::new([1; 16], 10_000, 20_000, 7)).unwrap();
|
||||
assert!(executable.validate_execution(15_000, 7).is_ok());
|
||||
assert!(executable.validate_execution(20_000, 7).unwrap_err().contains("expired"));
|
||||
assert!(executable.validate_execution(4_999, 7).unwrap_err().contains("future"));
|
||||
let wrong_epoch =
|
||||
Envelope::start(test_request(request_id.clone()), RequestMetadata::new([1; 16], 10_000, 20_000, 8)).unwrap();
|
||||
assert!(wrong_epoch.validate_execution(15_000, 7).unwrap_err().contains("epoch"));
|
||||
|
||||
assert!(
|
||||
ResultEnvelope::channel(
|
||||
uuid::Uuid::new_v4().to_string(),
|
||||
@@ -711,11 +803,25 @@ mod tests {
|
||||
Outcome::Channel {
|
||||
success: true,
|
||||
data: None,
|
||||
error: Some("unexpected".to_string()),
|
||||
error: Some("status detail".to_string()),
|
||||
},
|
||||
)
|
||||
.is_err()
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let large_result = ResultEnvelope::new(
|
||||
request_id.clone(),
|
||||
7,
|
||||
Outcome::Channel {
|
||||
success: true,
|
||||
data: Some(vec![7; ENVELOPE_MAX_SIZE + 1]),
|
||||
error: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let large_result = encode_result(&large_result).expect("status results may exceed the request envelope limit");
|
||||
assert!(large_result.len() > ENVELOPE_MAX_SIZE);
|
||||
assert!(decode_result(&large_result).is_ok());
|
||||
assert!(
|
||||
ResultEnvelope::new(
|
||||
request_id.clone(),
|
||||
@@ -823,7 +929,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> Option<usize> {
|
||||
Some(ENVELOPE_MAX_SIZE + 1)
|
||||
Some(RESULT_MAX_SIZE + 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+118
-11
@@ -97,10 +97,31 @@ fn internode_http2_keep_alive_timeout() -> Duration {
|
||||
}
|
||||
|
||||
fn internode_rpc_timeout() -> Duration {
|
||||
Duration::from_secs(rustfs_utils::get_env_u64(
|
||||
normalize_internode_rpc_timeout(Duration::from_secs(rustfs_utils::get_env_u64(
|
||||
rustfs_config::ENV_INTERNODE_RPC_TIMEOUT_SECS,
|
||||
rustfs_config::DEFAULT_INTERNODE_RPC_TIMEOUT_SECS,
|
||||
))
|
||||
)))
|
||||
}
|
||||
|
||||
fn normalize_internode_rpc_timeout(timeout: Duration) -> Duration {
|
||||
timeout.max(Duration::from_secs(1))
|
||||
}
|
||||
|
||||
/// Budget for one heal-control execution, kept below the transport timeout so
|
||||
/// the coordinator stops waiting for admission before the caller gives up.
|
||||
pub fn heal_control_execution_timeout() -> Duration {
|
||||
heal_control_execution_timeout_for(internode_rpc_timeout())
|
||||
}
|
||||
|
||||
fn heal_control_execution_timeout_for(transport_timeout: Duration) -> Duration {
|
||||
const TRANSPORT_GUARD: Duration = Duration::from_secs(1);
|
||||
let transport_timeout = transport_timeout.max(Duration::from_secs(1));
|
||||
transport_timeout
|
||||
.saturating_sub(TRANSPORT_GUARD.min(transport_timeout / 2))
|
||||
.max(Duration::from_millis(1))
|
||||
.min(Duration::from_millis(
|
||||
u64::try_from(heal_control::MAX_LIFETIME_MS).expect("positive heal control lifetime must fit u64"),
|
||||
))
|
||||
}
|
||||
|
||||
fn internode_rpc_tcp_nodelay() -> bool {
|
||||
@@ -141,9 +162,20 @@ pub fn internode_rpc_max_message_size() -> usize {
|
||||
rustfs_utils::get_env_usize(rustfs_config::ENV_INTERNODE_RPC_MAX_MESSAGE_SIZE, DEFAULT_GRPC_SERVER_MESSAGE_LEN)
|
||||
}
|
||||
|
||||
pub const HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE: usize = 65 * 1024;
|
||||
pub const HEAL_CONTROL_PROTOCOL_VERSION: u32 = 1;
|
||||
pub const HEAL_CONTROL_CAPABILITY_PROBE_PREFIX: &[u8] = b"rustfs-heal-control-capability-v1\0";
|
||||
pub const HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE: usize = heal_control::RESULT_MAX_SIZE + 1024;
|
||||
pub const HEAL_CONTROL_PROTOCOL_VERSION: u32 = 2;
|
||||
pub const HEAL_CONTROL_CAPABILITY_PROBE_PREFIX: &[u8] = b"rustfs-heal-control-capability-v2\0";
|
||||
|
||||
pub fn heal_control_coordinator_epoch(topology_fingerprint: &str) -> Result<u64, &'static str> {
|
||||
let prefix = topology_fingerprint
|
||||
.get(..16)
|
||||
.ok_or("heal control topology fingerprint is too short")?;
|
||||
let epoch = u64::from_str_radix(prefix, 16).map_err(|_| "heal control topology fingerprint is not hexadecimal")?;
|
||||
if epoch == 0 {
|
||||
return Err("heal control topology epoch is zero");
|
||||
}
|
||||
Ok(epoch)
|
||||
}
|
||||
|
||||
pub fn heal_control_capability_probe(nonce: &[u8; 16]) -> Vec<u8> {
|
||||
let mut probe = Vec::with_capacity(HEAL_CONTROL_CAPABILITY_PROBE_PREFIX.len() + nonce.len());
|
||||
@@ -165,7 +197,7 @@ pub fn canonical_heal_control_request_body(
|
||||
topology_fingerprint: &str,
|
||||
command: &[u8],
|
||||
) -> Result<Vec<u8>, std::num::TryFromIntError> {
|
||||
const DOMAIN: &[u8] = b"rustfs-heal-control-v1\0";
|
||||
const DOMAIN: &[u8] = b"rustfs-heal-control-v2\0";
|
||||
|
||||
let fingerprint = topology_fingerprint.as_bytes();
|
||||
let mut body = Vec::with_capacity(DOMAIN.len() + 4 + 8 + fingerprint.len() + 8 + command.len());
|
||||
@@ -185,7 +217,7 @@ pub fn canonical_heal_control_capability_ack(
|
||||
topology_fingerprint: &str,
|
||||
probe: &[u8],
|
||||
) -> Result<Vec<u8>, std::num::TryFromIntError> {
|
||||
const DOMAIN: &[u8] = b"rustfs-heal-control-capability-ack-v1\0";
|
||||
const DOMAIN: &[u8] = b"rustfs-heal-control-capability-ack-v2\0";
|
||||
|
||||
let fingerprint = topology_fingerprint.as_bytes();
|
||||
let mut body = Vec::with_capacity(DOMAIN.len() + 4 + 8 + fingerprint.len() + 8 + probe.len());
|
||||
@@ -198,17 +230,42 @@ pub fn canonical_heal_control_capability_ack(
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub fn canonical_heal_control_response_body(
|
||||
version: u32,
|
||||
topology_fingerprint: &str,
|
||||
command: &[u8],
|
||||
result: &[u8],
|
||||
) -> Result<Vec<u8>, std::num::TryFromIntError> {
|
||||
const DOMAIN: &[u8] = b"rustfs-heal-control-response-v2\0";
|
||||
|
||||
let fingerprint = topology_fingerprint.as_bytes();
|
||||
let mut body = Vec::with_capacity(DOMAIN.len() + 4 + 8 + fingerprint.len() + 8 + command.len() + 8 + result.len());
|
||||
body.extend_from_slice(DOMAIN);
|
||||
body.extend_from_slice(&version.to_be_bytes());
|
||||
body.extend_from_slice(&u64::try_from(fingerprint.len())?.to_be_bytes());
|
||||
body.extend_from_slice(fingerprint);
|
||||
body.extend_from_slice(&u64::try_from(command.len())?.to_be_bytes());
|
||||
body.extend_from_slice(command);
|
||||
body.extend_from_slice(&u64::try_from(result.len())?.to_be_bytes());
|
||||
body.extend_from_slice(result);
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod heal_control_tests {
|
||||
use super::{
|
||||
HEAL_CONTROL_CAPABILITY_PROBE_PREFIX, canonical_heal_control_capability_ack, canonical_heal_control_request_body,
|
||||
heal_control_capability_probe, is_heal_control_capability_probe,
|
||||
HEAL_CONTROL_CAPABILITY_PROBE_PREFIX, HEAL_CONTROL_PROTOCOL_VERSION, canonical_heal_control_capability_ack,
|
||||
canonical_heal_control_request_body, canonical_heal_control_response_body, heal_control_capability_probe,
|
||||
heal_control_coordinator_epoch, heal_control_execution_timeout, heal_control_execution_timeout_for,
|
||||
internode_rpc_timeout, is_heal_control_capability_probe, normalize_internode_rpc_timeout,
|
||||
};
|
||||
use crate::heal_control;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn canonical_heal_control_body_binds_every_field_and_boundary() {
|
||||
let baseline = canonical_heal_control_request_body(1, "ab", b"c").expect("small request should encode");
|
||||
let mut golden = b"rustfs-heal-control-v1\0".to_vec();
|
||||
let mut golden = b"rustfs-heal-control-v2\0".to_vec();
|
||||
golden.extend_from_slice(&1_u32.to_be_bytes());
|
||||
golden.extend_from_slice(&2_u64.to_be_bytes());
|
||||
golden.extend_from_slice(b"ab");
|
||||
@@ -236,9 +293,11 @@ mod heal_control_tests {
|
||||
|
||||
#[test]
|
||||
fn canonical_capability_ack_binds_version_and_topology() {
|
||||
assert_eq!(HEAL_CONTROL_PROTOCOL_VERSION, 2);
|
||||
assert!(HEAL_CONTROL_CAPABILITY_PROBE_PREFIX.starts_with(b"rustfs-heal-control-capability-v2"));
|
||||
let probe = heal_control_capability_probe(&[7; 16]);
|
||||
let ack = canonical_heal_control_capability_ack(1, "ab", &probe).expect("small acknowledgement should encode");
|
||||
let mut golden = b"rustfs-heal-control-capability-ack-v1\0".to_vec();
|
||||
let mut golden = b"rustfs-heal-control-capability-ack-v2\0".to_vec();
|
||||
golden.extend_from_slice(&1_u32.to_be_bytes());
|
||||
golden.extend_from_slice(&2_u64.to_be_bytes());
|
||||
golden.extend_from_slice(b"ab");
|
||||
@@ -254,6 +313,54 @@ mod heal_control_tests {
|
||||
assert!(is_heal_control_capability_probe(&probe));
|
||||
assert!(!is_heal_control_capability_probe(HEAL_CONTROL_CAPABILITY_PROBE_PREFIX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_response_binds_request_and_result() {
|
||||
let baseline = canonical_heal_control_response_body(2, "abcdef", b"query", b"result").unwrap();
|
||||
assert_ne!(baseline, canonical_heal_control_response_body(1, "abcdef", b"query", b"result").unwrap());
|
||||
assert_ne!(baseline, canonical_heal_control_response_body(2, "bbcdef", b"query", b"result").unwrap());
|
||||
assert_ne!(baseline, canonical_heal_control_response_body(2, "abcdef", b"cancel", b"result").unwrap());
|
||||
assert_ne!(
|
||||
baseline,
|
||||
canonical_heal_control_response_body(2, "abcdef", b"query", b"tampered").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinator_epoch_is_stable_and_rejects_invalid_fingerprints() {
|
||||
assert_eq!(heal_control_coordinator_epoch("0123456789abcdefextra"), Ok(0x0123_4567_89ab_cdef));
|
||||
assert_eq!(
|
||||
heal_control_coordinator_epoch("0000000000000000"),
|
||||
Err("heal control topology epoch is zero")
|
||||
);
|
||||
assert_eq!(
|
||||
heal_control_coordinator_epoch("short"),
|
||||
Err("heal control topology fingerprint is too short")
|
||||
);
|
||||
assert_eq!(
|
||||
heal_control_coordinator_epoch("not-hex-value!!!!"),
|
||||
Err("heal control topology fingerprint is not hexadecimal")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_budget_precedes_transport_timeout() {
|
||||
let execution = heal_control_execution_timeout();
|
||||
assert!(!execution.is_zero());
|
||||
assert!(execution < internode_rpc_timeout());
|
||||
assert!(execution <= std::time::Duration::from_millis(heal_control::MAX_LIFETIME_MS as u64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_budget_is_nonzero_for_zero_transport_configuration() {
|
||||
let normalized_transport = Duration::from_secs(1);
|
||||
assert_eq!(normalize_internode_rpc_timeout(Duration::ZERO), normalized_transport);
|
||||
for configured_transport in [Duration::ZERO, normalized_transport] {
|
||||
let execution = heal_control_execution_timeout_for(configured_transport);
|
||||
assert!(execution > Duration::ZERO);
|
||||
assert!(execution < normalized_transport);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether internode metadata RPCs should send only the msgpack `_bin` payloads and leave the JSON
|
||||
|
||||
@@ -783,6 +783,7 @@ message HealControlResponse {
|
||||
bool success = 1;
|
||||
bytes result = 2;
|
||||
optional string error_info = 3;
|
||||
bytes response_proof = 4;
|
||||
}
|
||||
|
||||
message GetMetacacheListingRequest {
|
||||
|
||||
+457
-177
@@ -15,21 +15,23 @@
|
||||
use crate::admin::auth::{authenticate_request, validate_admin_request};
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::admin::runtime_sources::{app_context_from_req, object_store_from_extensions};
|
||||
use crate::admin::storage_api::access::spawn_traced;
|
||||
use crate::admin::storage_api::bucket::is_reserved_or_invalid_bucket;
|
||||
use crate::admin::storage_api::bucket::utils::is_valid_object_prefix;
|
||||
use crate::admin::storage_api::contract::heal::HealOperations as _;
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use crate::server::RemoteAddr;
|
||||
use crate::storage::rpc::node_service::heal::{
|
||||
NodeHealProgress, NodeHealStatusSnapshot, capture_node_heal_status, decode_node_heal_status,
|
||||
HealControlCoordinator, NodeHealProgress, NodeHealStatusSnapshot, capture_node_heal_status, decode_node_heal_status,
|
||||
heal_control_coordinator, heal_topology_fingerprint,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use futures_util::future::join_all;
|
||||
use http::{HeaderMap, HeaderValue, Uri};
|
||||
use hyper::{Method, StatusCode};
|
||||
use matchit::Params;
|
||||
use rustfs_common::heal_channel::{HealChannelPriority, HealChannelRequest, HealOpts, HealRequestSource, HealScanMode};
|
||||
use rustfs_common::heal_channel::{
|
||||
HealAdmissionReceipt, HealChannelPriority, HealChannelRequest, HealOpts, HealRequestSource, HealScanMode,
|
||||
};
|
||||
use rustfs_config::MAX_HEAL_REQUEST_SIZE;
|
||||
use rustfs_heal::heal::utils::format_set_disk_id;
|
||||
use rustfs_policy::policy::action::{Action, AdminAction};
|
||||
@@ -39,9 +41,10 @@ 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::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{Duration, timeout};
|
||||
use tracing::{info, warn};
|
||||
|
||||
@@ -175,6 +178,7 @@ pub fn register_heal_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Default)]
|
||||
struct HealResp {
|
||||
resp_bytes: Vec<u8>,
|
||||
@@ -200,6 +204,8 @@ struct HealTaskStatus {
|
||||
heal_settings: HealOpts,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
items: Vec<rustfs_madmin::heal_commands::HealResultItem>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
truncated: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
progress: Option<serde_json::Value>,
|
||||
}
|
||||
@@ -479,15 +485,281 @@ async fn read_cluster_heal_status(
|
||||
merge_peer_heal_statuses(snapshots, peer_statuses, expected_nodes)
|
||||
}
|
||||
|
||||
fn cluster_heal_control_unavailable(reason: &str) -> s3s::S3Error {
|
||||
warn!(
|
||||
event = EVENT_ADMIN_REQUEST_FAILED,
|
||||
component = LOG_COMPONENT_ADMIN_API,
|
||||
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
||||
operation = "heal_control",
|
||||
result = "failed",
|
||||
reason,
|
||||
"cluster heal coordination unavailable"
|
||||
);
|
||||
s3_error!(InternalError, "cluster heal coordination unavailable")
|
||||
}
|
||||
|
||||
struct PreparedHealControlRoute {
|
||||
remote_grid_hosts: Vec<String>,
|
||||
fingerprint: String,
|
||||
coordinator_epoch: u64,
|
||||
coordinator: HealControlCoordinator,
|
||||
}
|
||||
|
||||
fn prepare_heal_control_route(context: &crate::admin::runtime_sources::AppContext) -> S3Result<PreparedHealControlRoute> {
|
||||
let endpoints = context
|
||||
.endpoints()
|
||||
.handle()
|
||||
.ok_or_else(|| cluster_heal_control_unavailable("endpoint_topology_unavailable"))?;
|
||||
let fingerprint = heal_topology_fingerprint(&endpoints)
|
||||
.map_err(|_| cluster_heal_control_unavailable("topology_fingerprint_unavailable"))?;
|
||||
let coordinator_epoch = rustfs_protos::heal_control_coordinator_epoch(&fingerprint)
|
||||
.map_err(|_| cluster_heal_control_unavailable("topology_epoch_unavailable"))?;
|
||||
let coordinator =
|
||||
heal_control_coordinator(&endpoints).map_err(|_| cluster_heal_control_unavailable("coordinator_unavailable"))?;
|
||||
let remote_grid_hosts = endpoints
|
||||
.get_nodes()
|
||||
.into_iter()
|
||||
.filter(|node| !node.is_local)
|
||||
.map(|node| node.grid_host)
|
||||
.collect();
|
||||
Ok(PreparedHealControlRoute {
|
||||
remote_grid_hosts,
|
||||
fingerprint,
|
||||
coordinator_epoch,
|
||||
coordinator,
|
||||
})
|
||||
}
|
||||
|
||||
fn new_heal_control_metadata(route: &PreparedHealControlRoute) -> S3Result<rustfs_protos::heal_control::RequestMetadata> {
|
||||
let now = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000;
|
||||
let now = i64::try_from(now).map_err(|_| cluster_heal_control_unavailable("clock_out_of_range"))?;
|
||||
let lifetime_ms = i64::try_from(rustfs_protos::heal_control_execution_timeout().as_millis())
|
||||
.map_err(|_| cluster_heal_control_unavailable("execution_timeout_out_of_range"))?;
|
||||
Ok(rustfs_protos::heal_control::RequestMetadata::new(
|
||||
*uuid::Uuid::new_v4().as_bytes(),
|
||||
now,
|
||||
now.saturating_add(lifetime_ms),
|
||||
route.coordinator_epoch,
|
||||
))
|
||||
}
|
||||
|
||||
async fn require_cluster_heal_control_capability(
|
||||
context: &crate::admin::runtime_sources::AppContext,
|
||||
route: &PreparedHealControlRoute,
|
||||
) -> S3Result<()> {
|
||||
if route.remote_grid_hosts.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let notification_system = context
|
||||
.notification_system()
|
||||
.handle()
|
||||
.ok_or_else(|| cluster_heal_control_unavailable("notification_system_unavailable"))?;
|
||||
let clients = route
|
||||
.remote_grid_hosts
|
||||
.iter()
|
||||
.map(|grid_host| {
|
||||
notification_system
|
||||
.peer_client_for_grid_host(grid_host)
|
||||
.ok_or_else(|| cluster_heal_control_unavailable("peer_capability_client_unavailable"))
|
||||
})
|
||||
.collect::<S3Result<Vec<_>>>()?;
|
||||
let results = join_all(clients.into_iter().map(|client| {
|
||||
let fingerprint = route.fingerprint.clone();
|
||||
async move { client.probe_heal_control(fingerprint).await }
|
||||
}))
|
||||
.await;
|
||||
if let Some(err) = results.into_iter().find_map(Result::err) {
|
||||
warn!(
|
||||
event = EVENT_ADMIN_REQUEST_FAILED,
|
||||
component = LOG_COMPONENT_ADMIN_API,
|
||||
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
||||
operation = "heal_control",
|
||||
result = "failed",
|
||||
reason = "cluster_capability_unavailable",
|
||||
error = %err,
|
||||
"cluster heal coordination failed"
|
||||
);
|
||||
return Err(cluster_heal_control_unavailable("cluster_capability_unavailable"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn route_cluster_heal_control(
|
||||
context: &crate::admin::runtime_sources::AppContext,
|
||||
route: &PreparedHealControlRoute,
|
||||
envelope: rustfs_protos::heal_control::Envelope,
|
||||
request_id: &str,
|
||||
coordinator_capability_verified: bool,
|
||||
) -> S3Result<rustfs_protos::heal_control::Outcome> {
|
||||
let response = if route.coordinator.is_local {
|
||||
crate::storage::rpc::node_service::execute_heal_control_envelope(envelope, route.coordinator_epoch)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(
|
||||
event = EVENT_ADMIN_REQUEST_FAILED,
|
||||
component = LOG_COMPONENT_ADMIN_API,
|
||||
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
||||
operation = "heal_control",
|
||||
result = "failed",
|
||||
reason = "local_coordinator_failed",
|
||||
error = %err,
|
||||
"cluster heal coordination failed"
|
||||
);
|
||||
cluster_heal_control_unavailable("local_coordinator_failed")
|
||||
})?
|
||||
} else {
|
||||
let notification_system = context
|
||||
.notification_system()
|
||||
.handle()
|
||||
.ok_or_else(|| cluster_heal_control_unavailable("notification_system_unavailable"))?;
|
||||
let client = notification_system
|
||||
.peer_client_for_grid_host(&route.coordinator.grid_host)
|
||||
.ok_or_else(|| cluster_heal_control_unavailable("coordinator_client_unavailable"))?;
|
||||
if !coordinator_capability_verified {
|
||||
client.probe_heal_control(route.fingerprint.clone()).await.map_err(|err| {
|
||||
warn!(
|
||||
event = EVENT_ADMIN_REQUEST_FAILED,
|
||||
component = LOG_COMPONENT_ADMIN_API,
|
||||
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
||||
operation = "heal_control",
|
||||
result = "failed",
|
||||
reason = "coordinator_capability_unavailable",
|
||||
error = %err,
|
||||
"cluster heal coordination failed"
|
||||
);
|
||||
cluster_heal_control_unavailable("coordinator_capability_unavailable")
|
||||
})?;
|
||||
}
|
||||
let command = rustfs_protos::heal_control::encode_envelope(&envelope)
|
||||
.map_err(|err| s3_error!(InternalError, "encode heal control request failed: {err}"))?;
|
||||
client
|
||||
.heal_control(rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION, route.fingerprint.clone(), command)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(
|
||||
event = EVENT_ADMIN_REQUEST_FAILED,
|
||||
component = LOG_COMPONENT_ADMIN_API,
|
||||
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
||||
operation = "heal_control",
|
||||
result = "failed",
|
||||
reason = "coordinator_request_failed",
|
||||
error = %err,
|
||||
"cluster heal coordination failed"
|
||||
);
|
||||
cluster_heal_control_unavailable("coordinator_request_failed")
|
||||
})?
|
||||
};
|
||||
rustfs_protos::heal_control::decode_result(&response)
|
||||
.and_then(|result| result.into_outcome(request_id, route.coordinator_epoch))
|
||||
.map_err(|err| {
|
||||
warn!(
|
||||
event = EVENT_ADMIN_REQUEST_FAILED,
|
||||
component = LOG_COMPONENT_ADMIN_API,
|
||||
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
||||
operation = "heal_control",
|
||||
result = "failed",
|
||||
reason = "coordinator_response_invalid",
|
||||
error = %err,
|
||||
"cluster heal coordination failed"
|
||||
);
|
||||
cluster_heal_control_unavailable("coordinator_response_invalid")
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_after_heal_control_capability<P, PF, E, EF, T>(probe: P, execute: E) -> S3Result<T>
|
||||
where
|
||||
P: FnOnce() -> PF,
|
||||
PF: Future<Output = S3Result<()>>,
|
||||
E: FnOnce() -> EF,
|
||||
EF: Future<Output = S3Result<T>>,
|
||||
{
|
||||
probe().await?;
|
||||
execute().await
|
||||
}
|
||||
|
||||
async fn submit_cluster_heal_start(
|
||||
context: Arc<crate::admin::runtime_sources::AppContext>,
|
||||
hip: &HealInitParams,
|
||||
) -> S3Result<HealAdmissionReceipt> {
|
||||
let route = prepare_heal_control_route(&context)?;
|
||||
let result = execute_after_heal_control_capability(
|
||||
|| require_cluster_heal_control_capability(&context, &route),
|
||||
|| async {
|
||||
let heal_request = build_heal_channel_request(hip);
|
||||
let request_id = heal_request.id.clone();
|
||||
let envelope = rustfs_protos::heal_control::Envelope::start(heal_request, new_heal_control_metadata(&route)?)
|
||||
.map_err(|err| s3_error!(InternalError, "encode heal control request failed: {err}"))?;
|
||||
route_cluster_heal_control(&context, &route, envelope, &request_id, true).await
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
match result {
|
||||
rustfs_protos::heal_control::Outcome::Start { task_id, admission } => Ok(HealAdmissionReceipt {
|
||||
result: admission.into_heal_admission_result(),
|
||||
task_id,
|
||||
}),
|
||||
rustfs_protos::heal_control::Outcome::Channel { .. } => {
|
||||
Err(s3_error!(InternalError, "coordinator returned an unexpected heal result"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_heal_admission(result: rustfs_common::heal_channel::HealAdmissionResult) -> s3s::S3Error {
|
||||
use rustfs_common::heal_channel::{HealAdmissionDropReason, HealAdmissionResult};
|
||||
|
||||
match result {
|
||||
HealAdmissionResult::Full | HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull) => s3_error!(
|
||||
SlowDown,
|
||||
"heal request not admitted: admission={}, reason={}",
|
||||
result.result_label(),
|
||||
result.reason_label()
|
||||
),
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped) => s3_error!(
|
||||
OperationAborted,
|
||||
"heal request not admitted: admission={}, reason={}",
|
||||
result.result_label(),
|
||||
result.reason_label()
|
||||
),
|
||||
HealAdmissionResult::Accepted | HealAdmissionResult::Merged => {
|
||||
s3_error!(InternalError, "admitted heal request was rejected unexpectedly")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn submit_cluster_heal_channel_command(
|
||||
context: Arc<crate::admin::runtime_sources::AppContext>,
|
||||
route: PreparedHealControlRoute,
|
||||
envelope: rustfs_protos::heal_control::Envelope,
|
||||
request_id: &str,
|
||||
response_id: String,
|
||||
) -> S3Result<rustfs_common::heal_channel::HealChannelResponse> {
|
||||
match route_cluster_heal_control(&context, &route, envelope, request_id, false).await? {
|
||||
rustfs_protos::heal_control::Outcome::Channel { success, data, error } => {
|
||||
Ok(rustfs_common::heal_channel::HealChannelResponse {
|
||||
request_id: response_id,
|
||||
success,
|
||||
data,
|
||||
error,
|
||||
})
|
||||
}
|
||||
rustfs_protos::heal_control::Outcome::Start { .. } => {
|
||||
Err(s3_error!(InternalError, "coordinator returned an unexpected heal result"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HealTaskStatusPayload {
|
||||
summary: String,
|
||||
#[serde(default)]
|
||||
items: Vec<rustfs_madmin::heal_commands::HealResultItem>,
|
||||
#[serde(default)]
|
||||
truncated: bool,
|
||||
#[serde(default)]
|
||||
progress: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn map_heal_response(result: Option<HealResp>) -> S3Result<(StatusCode, Vec<u8>)> {
|
||||
match result {
|
||||
Some(result) => {
|
||||
@@ -528,6 +800,7 @@ fn encode_heal_task_status(
|
||||
failure_detail: String,
|
||||
heal_settings: HealOpts,
|
||||
items: Vec<rustfs_madmin::heal_commands::HealResultItem>,
|
||||
truncated: bool,
|
||||
progress: Option<serde_json::Value>,
|
||||
) -> S3Result<Vec<u8>> {
|
||||
encode_json(&HealTaskStatus {
|
||||
@@ -536,6 +809,7 @@ fn encode_heal_task_status(
|
||||
start_time: current_rfc3339_time()?,
|
||||
heal_settings,
|
||||
items,
|
||||
truncated,
|
||||
progress,
|
||||
})
|
||||
}
|
||||
@@ -577,15 +851,15 @@ fn build_heal_channel_request(hip: &HealInitParams) -> HealChannelRequest {
|
||||
|
||||
fn heal_channel_response_status(
|
||||
response: &rustfs_common::heal_channel::HealChannelResponse,
|
||||
) -> (String, Vec<rustfs_madmin::heal_commands::HealResultItem>, Option<serde_json::Value>) {
|
||||
) -> (String, Vec<rustfs_madmin::heal_commands::HealResultItem>, bool, Option<serde_json::Value>) {
|
||||
let Some(data) = response.data.as_deref() else {
|
||||
return ("running".to_string(), Vec::new(), None);
|
||||
return ("running".to_string(), Vec::new(), false, None);
|
||||
};
|
||||
|
||||
if let Ok(payload) = serde_json::from_slice::<HealTaskStatusPayload>(data)
|
||||
&& !payload.summary.is_empty()
|
||||
{
|
||||
return (payload.summary, payload.items, payload.progress);
|
||||
return (payload.summary, payload.items, payload.truncated, payload.progress);
|
||||
}
|
||||
|
||||
let summary = std::str::from_utf8(data)
|
||||
@@ -593,7 +867,7 @@ fn heal_channel_response_status(
|
||||
.filter(|summary| !summary.is_empty())
|
||||
.unwrap_or("running")
|
||||
.to_string();
|
||||
(summary, Vec::new(), None)
|
||||
(summary, Vec::new(), false, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -610,7 +884,7 @@ fn heal_channel_response_items(
|
||||
|
||||
#[cfg(test)]
|
||||
fn heal_channel_response_progress(response: &rustfs_common::heal_channel::HealChannelResponse) -> Option<serde_json::Value> {
|
||||
heal_channel_response_status(response).2
|
||||
heal_channel_response_status(response).3
|
||||
}
|
||||
|
||||
fn encode_background_heal_status(
|
||||
@@ -732,6 +1006,7 @@ impl Operation for HealHandler {
|
||||
.get::<Option<RemoteAddr>>()
|
||||
.and_then(|opt| opt.map(|addr| addr.0.to_string()))
|
||||
.unwrap_or_default();
|
||||
let app_context = app_context_from_req(&req);
|
||||
let mut input = req.input;
|
||||
let bytes = match input.store_all_limited(MAX_HEAL_REQUEST_SIZE).await {
|
||||
Ok(b) => b,
|
||||
@@ -804,179 +1079,120 @@ impl Operation for HealHandler {
|
||||
};
|
||||
|
||||
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, progress) = heal_channel_response_status(&response);
|
||||
let resp_bytes = encode_heal_task_status(
|
||||
summary,
|
||||
response.error.unwrap_or_default(),
|
||||
HealOpts::default(),
|
||||
items,
|
||||
progress,
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let context = app_context
|
||||
.clone()
|
||||
.ok_or_else(|| cluster_heal_control_unavailable("app_context_unavailable"))?;
|
||||
let route = prepare_heal_control_route(&context)?;
|
||||
let envelope = rustfs_protos::heal_control::Envelope::query(
|
||||
request_id.clone(),
|
||||
new_heal_control_metadata(&route)?,
|
||||
heal_path_str,
|
||||
client_token.clone(),
|
||||
)
|
||||
.map_err(|err| s3_error!(InternalError, "encode heal control query failed: {err}"))?;
|
||||
let response = submit_cluster_heal_channel_command(context, route, envelope, &request_id, client_token).await?;
|
||||
if !response.success {
|
||||
return Err(s3_error!(
|
||||
InternalError,
|
||||
"{}",
|
||||
response.error.unwrap_or_else(|| "query heal status failed".to_string())
|
||||
));
|
||||
}
|
||||
let (summary, items, truncated, progress) = heal_channel_response_status(&response);
|
||||
let body = encode_heal_task_status(
|
||||
summary,
|
||||
response.error.unwrap_or_default(),
|
||||
HealOpts::default(),
|
||||
items,
|
||||
truncated,
|
||||
progress,
|
||||
)?;
|
||||
info!(
|
||||
event = EVENT_ADMIN_RESPONSE_EMITTED,
|
||||
component = LOG_COMPONENT_ADMIN_API,
|
||||
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
||||
operation = response_operation,
|
||||
result = "success",
|
||||
status_code = StatusCode::OK.as_u16(),
|
||||
"admin response emitted"
|
||||
);
|
||||
return Ok(json_response(StatusCode::OK, body));
|
||||
} 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, progress) = heal_channel_response_status(&response);
|
||||
encode_heal_task_status(summary, response.error.unwrap_or_default(), heal_settings, items, progress)
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let context = app_context
|
||||
.clone()
|
||||
.ok_or_else(|| cluster_heal_control_unavailable("app_context_unavailable"))?;
|
||||
let route = prepare_heal_control_route(&context)?;
|
||||
let envelope = rustfs_protos::heal_control::Envelope::cancel(
|
||||
request_id.clone(),
|
||||
new_heal_control_metadata(&route)?,
|
||||
heal_path_str,
|
||||
client_token.clone(),
|
||||
)
|
||||
.map_err(|err| s3_error!(InternalError, "encode heal control cancel failed: {err}"))?;
|
||||
let response = submit_cluster_heal_channel_command(
|
||||
context,
|
||||
route,
|
||||
envelope,
|
||||
&request_id,
|
||||
if client_token.is_empty() {
|
||||
heal_path.to_string_lossy().into_owned()
|
||||
} else {
|
||||
client_token.clone()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if !response.success {
|
||||
return Err(s3_error!(
|
||||
InternalError,
|
||||
"{}",
|
||||
response.error.unwrap_or_else(|| "cancel heal task failed".to_string())
|
||||
));
|
||||
}
|
||||
let body = if client_token.is_empty() {
|
||||
encode_heal_start_success(response.request_id, client_address)?
|
||||
} else {
|
||||
let (summary, items, truncated, progress) = heal_channel_response_status(&response);
|
||||
encode_heal_task_status(summary, response.error.unwrap_or_default(), hip.hs, items, truncated, progress)?
|
||||
};
|
||||
info!(
|
||||
event = EVENT_ADMIN_RESPONSE_EMITTED,
|
||||
component = LOG_COMPONENT_ADMIN_API,
|
||||
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
||||
operation = response_operation,
|
||||
result = "success",
|
||||
status_code = StatusCode::OK.as_u16(),
|
||||
"admin response emitted"
|
||||
);
|
||||
return Ok(json_response(StatusCode::OK, body));
|
||||
} 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);
|
||||
match rustfs_common::heal_channel::send_heal_request_with_receipt(heal_request).await {
|
||||
Ok(receipt) if receipt.result.is_admitted() => {
|
||||
let resp_bytes = encode_heal_start_success(receipt.task_id, 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(receipt) => {
|
||||
let _ = tx_clone
|
||||
.send(HealResp {
|
||||
api_err: Some(format!(
|
||||
"heal request not admitted: admission={}, reason={}",
|
||||
receipt.result.result_label(),
|
||||
receipt.result.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 Some(context) = app_context else {
|
||||
return Err(cluster_heal_control_unavailable("app_context_unavailable"));
|
||||
};
|
||||
let receipt = submit_cluster_heal_start(context, &hip).await?;
|
||||
if !receipt.result.is_admitted() {
|
||||
return Err(reject_heal_admission(receipt.result));
|
||||
}
|
||||
let body = encode_heal_start_success(receipt.task_id, client_address)?;
|
||||
info!(
|
||||
event = EVENT_ADMIN_RESPONSE_EMITTED,
|
||||
component = LOG_COMPONENT_ADMIN_API,
|
||||
subsystem = LOG_SUBSYSTEM_HEAL_ADMIN,
|
||||
operation = response_operation,
|
||||
result = "success",
|
||||
status_code = StatusCode::OK.as_u16(),
|
||||
"admin response emitted"
|
||||
);
|
||||
return Ok(json_response(StatusCode::OK, body));
|
||||
}
|
||||
|
||||
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))
|
||||
Err(s3_error!(InvalidRequest, "invalid heal control request"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1037,9 +1253,10 @@ mod tests {
|
||||
use super::{
|
||||
BackgroundHealProgress, HealInitParams, HealResp, HealRuntimeState, aggregate_cluster_heal_status,
|
||||
background_heal_runtime_state, build_heal_channel_request, encode_background_heal_status, encode_heal_start_success,
|
||||
encode_heal_task_status, heal_channel_response_items, heal_channel_response_progress, heal_channel_response_summary,
|
||||
json_response, map_heal_response, map_root_heal_status, merge_peer_heal_statuses, peer_topology_complete,
|
||||
query_peer_heal_status, should_handle_root_heal_directly, validate_heal_request_mode, validate_heal_target,
|
||||
encode_heal_task_status, execute_after_heal_control_capability, heal_channel_response_items,
|
||||
heal_channel_response_progress, heal_channel_response_summary, json_response, map_heal_response, map_root_heal_status,
|
||||
merge_peer_heal_statuses, peer_topology_complete, query_peer_heal_status, reject_heal_admission,
|
||||
should_handle_root_heal_directly, validate_heal_request_mode, validate_heal_target,
|
||||
};
|
||||
use crate::admin::storage_api::error::StorageError;
|
||||
use crate::storage::rpc::node_service::heal::{NodeHealProgress, NodeHealStatusSnapshot};
|
||||
@@ -1047,17 +1264,61 @@ mod tests {
|
||||
use http::StatusCode;
|
||||
use http::Uri;
|
||||
use matchit::Router;
|
||||
use rustfs_common::heal_channel::{HealChannelPriority, HealOpts, HealRequestSource, HealScanMode};
|
||||
use rustfs_common::heal_channel::{
|
||||
HealAdmissionDropReason, HealAdmissionResult, HealChannelPriority, HealOpts, HealRequestSource, HealScanMode,
|
||||
};
|
||||
use rustfs_scanner::scanner::BackgroundHealInfo;
|
||||
use s3s::{
|
||||
S3ErrorCode,
|
||||
header::{CONTENT_LENGTH, CONTENT_TYPE},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn cluster_capability_gate_runs_before_execution() {
|
||||
let executed = AtomicBool::new(false);
|
||||
let rejected = execute_after_heal_control_capability(
|
||||
|| async { Err(super::cluster_heal_control_unavailable("test_capability_failure")) },
|
||||
|| async {
|
||||
executed.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(rejected.is_err());
|
||||
assert!(!executed.load(Ordering::SeqCst));
|
||||
|
||||
execute_after_heal_control_capability(
|
||||
|| async { Ok(()) },
|
||||
|| async {
|
||||
executed.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(executed.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_heal_admission_preserves_retry_semantics() {
|
||||
for admission in [
|
||||
HealAdmissionResult::Full,
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull),
|
||||
] {
|
||||
assert_eq!(reject_heal_admission(admission).code(), &S3ErrorCode::SlowDown);
|
||||
}
|
||||
assert_eq!(
|
||||
reject_heal_admission(HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped)).code(),
|
||||
&S3ErrorCode::OperationAborted
|
||||
);
|
||||
assert_eq!(reject_heal_admission(HealAdmissionResult::Accepted).code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heal_opts_serialization() {
|
||||
// Test that HealOpts can be properly deserialized
|
||||
@@ -1775,6 +2036,7 @@ mod tests {
|
||||
String::new(),
|
||||
HealOpts::default(),
|
||||
Vec::new(),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("status response should serialize");
|
||||
@@ -1783,11 +2045,28 @@ mod tests {
|
||||
assert_eq!(json["summary"], "Heal status query accepted");
|
||||
assert_eq!(json["detail"], "");
|
||||
assert!(json["items"].is_null());
|
||||
assert!(json["truncated"].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_encode_heal_task_status_reports_truncated_items() {
|
||||
let encoded = encode_heal_task_status(
|
||||
"running".to_string(),
|
||||
"heal result items were truncated".to_string(),
|
||||
HealOpts::default(),
|
||||
Vec::new(),
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.expect("truncated status response should serialize");
|
||||
let json: serde_json::Value = serde_json::from_slice(&encoded).expect("json should deserialize");
|
||||
|
||||
assert_eq!(json["truncated"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_heal_task_status_preserves_progress() {
|
||||
let progress = json!({
|
||||
@@ -1800,6 +2079,7 @@ mod tests {
|
||||
String::new(),
|
||||
HealOpts::default(),
|
||||
Vec::new(),
|
||||
false,
|
||||
Some(progress.clone()),
|
||||
)
|
||||
.expect("status response should serialize");
|
||||
|
||||
+19
-14
@@ -2319,7 +2319,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn peer_rest_heal_control_uses_production_auth_and_stays_online_when_disabled() {
|
||||
async fn peer_rest_heal_control_uses_production_auth_and_keeps_validation_errors_online() {
|
||||
let _ = rustfs_credentials::set_global_rpc_secret("rpc-http-test-secret".to_string());
|
||||
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
||||
Ok(listener) => listener,
|
||||
@@ -2376,11 +2376,11 @@ mod tests {
|
||||
|
||||
for _ in 0..2 {
|
||||
let error = client
|
||||
.heal_control(1, "fingerprint".to_string(), b"query".to_vec())
|
||||
.heal_control(rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION, "fingerprint".to_string(), b"query".to_vec())
|
||||
.await
|
||||
.expect_err("dormant heal control must report disabled");
|
||||
.expect_err("a divergent topology must fail closed");
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("routing is not enabled"));
|
||||
assert!(message.contains("topology does not match"));
|
||||
assert!(!message.contains("temporarily offline"));
|
||||
}
|
||||
client
|
||||
@@ -2395,16 +2395,21 @@ mod tests {
|
||||
assert!(mismatch.to_string().contains("topology does not match"));
|
||||
assert!(!mismatch.to_string().contains("temporarily offline"));
|
||||
|
||||
let unsupported = client
|
||||
.heal_control(
|
||||
rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION + 1,
|
||||
fingerprint.clone(),
|
||||
rustfs_protos::heal_control_capability_probe(&[9; 16]),
|
||||
)
|
||||
.await
|
||||
.expect_err("an unsupported protocol version must fail closed");
|
||||
assert!(unsupported.to_string().contains("unsupported heal control protocol version"));
|
||||
assert!(!unsupported.to_string().contains("temporarily offline"));
|
||||
for unsupported_version in [
|
||||
rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION - 1,
|
||||
rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION + 1,
|
||||
] {
|
||||
let unsupported = client
|
||||
.heal_control(
|
||||
unsupported_version,
|
||||
fingerprint.clone(),
|
||||
rustfs_protos::heal_control_capability_probe(&[9; 16]),
|
||||
)
|
||||
.await
|
||||
.expect_err("an unsupported protocol version must fail closed");
|
||||
assert!(unsupported.to_string().contains("unsupported heal control protocol version"));
|
||||
assert!(!unsupported.to_string().contains("temporarily offline"));
|
||||
}
|
||||
|
||||
client
|
||||
.probe_heal_control(fingerprint)
|
||||
|
||||
@@ -39,9 +39,17 @@ use rustfs_protos::{
|
||||
proto_gen::node_service::{node_service_server::NodeService as Node, *},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::{collections::HashMap, io::Cursor, pin::Pin, sync::Arc};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io::Cursor,
|
||||
pin::Pin,
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{Duration, timeout};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tonic::{Request, Response, Status, Streaming};
|
||||
@@ -59,6 +67,95 @@ const EVENT_RPC_REQUEST_FAILED: &str = "rpc_request_failed";
|
||||
const EVENT_RPC_RESPONSE_EMITTED: &str = "rpc_response_emitted";
|
||||
const EVENT_RPC_BACKGROUND_TASK_SPAWNED: &str = "rpc_background_task_spawned";
|
||||
const EVENT_RPC_BACKGROUND_TASK_FAILED: &str = "rpc_background_task_failed";
|
||||
const HEAL_CONTROL_REPLAY_CACHE_MAX_ENTRIES: usize = 4096;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HealControlReplayEntry {
|
||||
command_digest: [u8; 32],
|
||||
expires_at_unix_ms: i64,
|
||||
result: tokio::sync::Mutex<Option<Vec<u8>>>,
|
||||
}
|
||||
|
||||
fn remove_heal_control_replay(
|
||||
replay_cache: &mut HashMap<String, Arc<HealControlReplayEntry>>,
|
||||
request_id: &str,
|
||||
replay_entry: &Arc<HealControlReplayEntry>,
|
||||
) {
|
||||
if replay_cache
|
||||
.get(request_id)
|
||||
.is_some_and(|cached| Arc::ptr_eq(cached, replay_entry))
|
||||
{
|
||||
replay_cache.remove(request_id);
|
||||
}
|
||||
}
|
||||
|
||||
static HEAL_CONTROL_REPLAY_CACHE: OnceLock<tokio::sync::Mutex<HashMap<String, Arc<HealControlReplayEntry>>>> = OnceLock::new();
|
||||
|
||||
fn admit_heal_control_replay(
|
||||
replay_cache: &mut HashMap<String, Arc<HealControlReplayEntry>>,
|
||||
request_id: &str,
|
||||
command_digest: &[u8; 32],
|
||||
expires_at_unix_ms: i64,
|
||||
now_unix_ms: i64,
|
||||
) -> Result<Arc<HealControlReplayEntry>, Status> {
|
||||
replay_cache.retain(|_, entry| entry.expires_at_unix_ms > now_unix_ms || Arc::strong_count(entry) > 1);
|
||||
if let Some(cached) = replay_cache.get(request_id) {
|
||||
if &cached.command_digest != command_digest {
|
||||
return Err(Status::already_exists("heal control request ID was reused with a different command"));
|
||||
}
|
||||
return Ok(Arc::clone(cached));
|
||||
}
|
||||
if replay_cache.len() >= HEAL_CONTROL_REPLAY_CACHE_MAX_ENTRIES {
|
||||
return Err(Status::resource_exhausted("heal control replay cache is full"));
|
||||
}
|
||||
let entry = Arc::new(HealControlReplayEntry {
|
||||
command_digest: *command_digest,
|
||||
expires_at_unix_ms,
|
||||
result: tokio::sync::Mutex::new(None),
|
||||
});
|
||||
replay_cache.insert(request_id.to_string(), Arc::clone(&entry));
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
fn heal_control_now_unix_ms() -> Result<i64, Status> {
|
||||
i64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000)
|
||||
.map_err(|_| Status::internal("heal control clock is out of range"))
|
||||
}
|
||||
|
||||
fn heal_control_remaining(expires_at_unix_ms: i64, now_unix_ms: i64) -> Result<Duration, Status> {
|
||||
let remaining_ms = expires_at_unix_ms.saturating_sub(now_unix_ms);
|
||||
let remaining_ms = u64::try_from(remaining_ms).map_err(|_| Status::deadline_exceeded("heal control request expired"))?;
|
||||
if remaining_ms == 0 {
|
||||
return Err(Status::deadline_exceeded("heal control request expired"));
|
||||
}
|
||||
Ok(Duration::from_millis(remaining_ms))
|
||||
}
|
||||
|
||||
fn validate_admin_heal_control_start(request: &rustfs_common::heal_channel::HealChannelRequest) -> Result<(), Status> {
|
||||
if request.source != rustfs_common::heal_channel::HealRequestSource::Admin {
|
||||
return Err(Status::permission_denied("heal control start source must be admin"));
|
||||
}
|
||||
if request.pool_index.is_some() != request.set_index.is_some() {
|
||||
return Err(Status::invalid_argument("heal control start requires both pool and set"));
|
||||
}
|
||||
if request.bucket.is_empty() {
|
||||
if request.object_prefix.as_deref().is_some_and(|prefix| !prefix.is_empty()) {
|
||||
return Err(Status::invalid_argument("root heal control start cannot contain an object prefix"));
|
||||
}
|
||||
if request.recursive != Some(true) {
|
||||
return Err(Status::invalid_argument("root heal control start must be recursive"));
|
||||
}
|
||||
let erasure_set_target = request.pool_index.is_some();
|
||||
if request.disk.is_some() != erasure_set_target {
|
||||
return Err(Status::invalid_argument("root erasure-set heal control target is inconsistent"));
|
||||
}
|
||||
} else if request.disk.is_some() {
|
||||
return Err(Status::invalid_argument(
|
||||
"bucket heal control start cannot contain an erasure-set disk target",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scanner_activity_response(namespace_generation: u64) -> ScannerActivityResponse {
|
||||
ScannerActivityResponse {
|
||||
@@ -265,12 +362,17 @@ impl HealControlRpcService {
|
||||
Err(Status::failed_precondition("heal control topology is not initialized"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn endpoint_pools(&self) -> Option<EndpointServerPools> {
|
||||
#[cfg(test)]
|
||||
if let Some(source) = self.endpoint_pools_source.as_ref() {
|
||||
return source.read().await.clone();
|
||||
}
|
||||
self.endpoint_pools.clone()
|
||||
#[cfg(test)]
|
||||
if self.endpoint_pools.is_some() {
|
||||
return self.endpoint_pools.clone();
|
||||
}
|
||||
let context = runtime_sources::current_app_context()?;
|
||||
context.endpoints().handle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +390,100 @@ pub(crate) async fn initialize_heal_topology_fingerprint(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn execute_heal_control_envelope(
|
||||
envelope: rustfs_protos::heal_control::Envelope,
|
||||
expected_coordinator_epoch: u64,
|
||||
) -> Result<Vec<u8>, Status> {
|
||||
execute_heal_control_envelope_with_manager(envelope, expected_coordinator_epoch, None).await
|
||||
}
|
||||
|
||||
async fn execute_heal_control_envelope_with_manager(
|
||||
envelope: rustfs_protos::heal_control::Envelope,
|
||||
expected_coordinator_epoch: u64,
|
||||
manager: Option<Arc<rustfs_heal::HealManager>>,
|
||||
) -> Result<Vec<u8>, Status> {
|
||||
let now = heal_control_now_unix_ms()?;
|
||||
envelope
|
||||
.validate_execution(now, expected_coordinator_epoch)
|
||||
.map_err(Status::failed_precondition)?;
|
||||
let expires_at_unix_ms = envelope.expires_at_unix_ms();
|
||||
let canonical_envelope = rustfs_protos::heal_control::encode_envelope(&envelope).map_err(Status::invalid_argument)?;
|
||||
let command_digest = Sha256::digest(&canonical_envelope).into();
|
||||
let (request_id, coordinator_epoch, command) = envelope.into_execution().map_err(Status::invalid_argument)?;
|
||||
|
||||
let replay_cache = HEAL_CONTROL_REPLAY_CACHE.get_or_init(|| tokio::sync::Mutex::new(HashMap::new()));
|
||||
let replay_entry = {
|
||||
let mut replay_cache = timeout(heal_control_remaining(expires_at_unix_ms, now)?, replay_cache.lock())
|
||||
.await
|
||||
.map_err(|_| Status::deadline_exceeded("heal control request expired while awaiting replay admission"))?;
|
||||
admit_heal_control_replay(&mut replay_cache, &request_id, &command_digest, expires_at_unix_ms, now)?
|
||||
};
|
||||
let mut replay_result = timeout(heal_control_remaining(expires_at_unix_ms, now)?, replay_entry.result.lock())
|
||||
.await
|
||||
.map_err(|_| Status::deadline_exceeded("heal control request expired while awaiting matching execution"))?;
|
||||
let now = heal_control_now_unix_ms()?;
|
||||
if expires_at_unix_ms <= now {
|
||||
return Err(Status::deadline_exceeded("heal control request expired before execution"));
|
||||
}
|
||||
if let Some(cached) = replay_result.as_ref() {
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
|
||||
if let rustfs_protos::heal_control::ExecutableCommand::Start { request } = &command {
|
||||
validate_admin_heal_control_start(request)?;
|
||||
}
|
||||
let retain_completed_result = !matches!(&command, rustfs_protos::heal_control::ExecutableCommand::Query { .. });
|
||||
|
||||
let manager = manager
|
||||
.or_else(|| rustfs_heal::get_heal_manager().cloned())
|
||||
.ok_or_else(|| Status::failed_precondition("heal manager is not initialized"))?;
|
||||
let processor = rustfs_heal::HealChannelProcessor::new(manager.clone());
|
||||
let remaining = heal_control_remaining(expires_at_unix_ms, now)?;
|
||||
let outcome = match command {
|
||||
rustfs_protos::heal_control::ExecutableCommand::Start { request } => {
|
||||
let receipt = timeout(remaining, processor.execute_start_request(request))
|
||||
.await
|
||||
.map_err(|_| Status::deadline_exceeded("heal control start expired before admission"))?
|
||||
.map_err(|_| Status::internal("heal control start admission failed"))?;
|
||||
rustfs_protos::heal_control::Outcome::Start {
|
||||
task_id: receipt.task_id,
|
||||
admission: receipt.result.into(),
|
||||
}
|
||||
}
|
||||
rustfs_protos::heal_control::ExecutableCommand::Query { heal_path, client_token } => {
|
||||
let response = timeout(remaining, processor.execute_query_request(heal_path, client_token))
|
||||
.await
|
||||
.map_err(|_| Status::deadline_exceeded("heal control query expired before execution"))?
|
||||
.map_err(|_| Status::internal("heal control query failed"))?;
|
||||
rustfs_protos::heal_control::Outcome::Channel {
|
||||
success: response.success,
|
||||
data: response.data,
|
||||
error: response.error,
|
||||
}
|
||||
}
|
||||
rustfs_protos::heal_control::ExecutableCommand::Cancel { heal_path, client_token } => {
|
||||
let response = timeout(remaining, processor.execute_cancel_request(heal_path, client_token))
|
||||
.await
|
||||
.map_err(|_| Status::deadline_exceeded("heal control cancel expired before execution"))?
|
||||
.map_err(|_| Status::internal("heal control cancel failed"))?;
|
||||
rustfs_protos::heal_control::Outcome::Channel {
|
||||
success: response.success,
|
||||
data: response.data,
|
||||
error: response.error,
|
||||
}
|
||||
}
|
||||
};
|
||||
let result = rustfs_protos::heal_control::ResultEnvelope::new(request_id.clone(), coordinator_epoch, outcome)
|
||||
.and_then(|result| rustfs_protos::heal_control::encode_result(&result))
|
||||
.map_err(Status::internal)?;
|
||||
*replay_result = Some(result.clone());
|
||||
if !retain_completed_result {
|
||||
let mut replay_cache = replay_cache.lock().await;
|
||||
remove_heal_control_replay(&mut replay_cache, &request_id, &replay_entry);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl heal_control_service_server::HealControlService for HealControlRpcService {
|
||||
async fn heal_control(&self, request: Request<HealControlRequest>) -> Result<Response<HealControlResponse>, Status> {
|
||||
@@ -307,11 +503,11 @@ impl heal_control_service_server::HealControlService for HealControlRpcService {
|
||||
if request.get_ref().version != rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION {
|
||||
return Err(Status::failed_precondition("unsupported heal control protocol version"));
|
||||
}
|
||||
let fingerprint = self.capability_fingerprint().await?;
|
||||
if request.get_ref().topology_fingerprint != *fingerprint {
|
||||
return Err(Status::failed_precondition("heal control topology does not match"));
|
||||
}
|
||||
if rustfs_protos::is_heal_control_capability_probe(&request.get_ref().command) {
|
||||
let fingerprint = self.capability_fingerprint().await?;
|
||||
if request.get_ref().topology_fingerprint != *fingerprint {
|
||||
return Err(Status::failed_precondition("heal control topology does not match"));
|
||||
}
|
||||
let canonical_ack = rustfs_protos::canonical_heal_control_capability_ack(
|
||||
request.get_ref().version,
|
||||
fingerprint,
|
||||
@@ -324,9 +520,39 @@ impl heal_control_service_server::HealControlService for HealControlRpcService {
|
||||
success: true,
|
||||
result: result.into(),
|
||||
error_info: None,
|
||||
response_proof: Bytes::new(),
|
||||
}));
|
||||
}
|
||||
Err(Status::unimplemented("heal control routing is not enabled"))
|
||||
let endpoints = self
|
||||
.endpoint_pools()
|
||||
.await
|
||||
.ok_or_else(|| Status::failed_precondition("heal control topology is not initialized"))?;
|
||||
if !heal::heal_control_coordinator(&endpoints)
|
||||
.map_err(Status::failed_precondition)?
|
||||
.is_local
|
||||
{
|
||||
return Err(Status::failed_precondition("heal control request reached a non-coordinator node"));
|
||||
}
|
||||
let envelope =
|
||||
rustfs_protos::heal_control::decode_envelope(&request.get_ref().command).map_err(Status::invalid_argument)?;
|
||||
let coordinator_epoch =
|
||||
rustfs_protos::heal_control_coordinator_epoch(fingerprint).map_err(Status::failed_precondition)?;
|
||||
let result = execute_heal_control_envelope(envelope, coordinator_epoch).await?;
|
||||
let canonical_response = rustfs_protos::canonical_heal_control_response_body(
|
||||
request.get_ref().version,
|
||||
&request.get_ref().topology_fingerprint,
|
||||
&request.get_ref().command,
|
||||
&result,
|
||||
)
|
||||
.map_err(|_| Status::internal("heal control response length cannot be represented"))?;
|
||||
let response_proof = sign_tonic_rpc_response_proof(&canonical_response)
|
||||
.map_err(|_| Status::internal("heal control response proof is unavailable"))?;
|
||||
Ok(Response::new(HealControlResponse {
|
||||
success: true,
|
||||
result: result.into(),
|
||||
error_info: None,
|
||||
response_proof: response_proof.into(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1388,18 +1614,21 @@ impl Node for NodeService {
|
||||
#[allow(unused_imports)]
|
||||
mod tests {
|
||||
use super::{
|
||||
CollectMetricsOpts, Error, HEAL_CONTROL_PAYLOAD_MAX_SIZE, MetricType, Node as _, NodeService, PEER_RESTSIGNAL,
|
||||
CollectMetricsOpts, DiskStore, Error, HEAL_CONTROL_PAYLOAD_MAX_SIZE, MetricType, Node as _, NodeService, PEER_RESTSIGNAL,
|
||||
PEER_RESTSUB_SYS, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC, STORAGE_CLASS_SUB_SYS,
|
||||
background_rebalance_start_error_message, initialize_heal_topology_fingerprint, make_heal_control_server,
|
||||
make_heal_control_server_with_cache, make_server, scanner_activity_response, stop_rebalance_response,
|
||||
admit_heal_control_replay, background_rebalance_start_error_message, execute_heal_control_envelope_with_manager,
|
||||
initialize_heal_topology_fingerprint, make_heal_control_server, make_heal_control_server_with_cache, make_server,
|
||||
remove_heal_control_replay, scanner_activity_response, stop_rebalance_response,
|
||||
};
|
||||
use crate::storage::rpc::node_service::heal::heal_topology_fingerprint;
|
||||
use crate::storage::storage_api::rpc_consumer::node_service::{HealBucketInfo, HealEndpoint};
|
||||
use crate::storage::storage_api::set_tonic_canonical_body_digest;
|
||||
use crate::storage::storage_api::{
|
||||
Endpoint,
|
||||
ecstore_layout::{EndpointServerPools, Endpoints, PoolEndpoints},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use rustfs_heal::heal::{manager::HealManager, storage::HealStorageAPI};
|
||||
use rustfs_protos::models::PingBodyBuilder;
|
||||
use rustfs_protos::proto_gen::node_service::{
|
||||
BackgroundHealStatusRequest, CheckPartsRequest, DeleteBucketMetadataRequest, DeleteBucketRequest, DeletePathsRequest,
|
||||
@@ -1422,14 +1651,273 @@ mod tests {
|
||||
node_service_server::NodeServiceServer,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::Duration;
|
||||
use tokio_stream::wrappers::TcpListenerStream;
|
||||
use tonic::{Request, Response, Status};
|
||||
|
||||
struct HealControlMockStorage;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl HealStorageAPI for HealControlMockStorage {
|
||||
async fn get_object_meta(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_object: &str,
|
||||
) -> rustfs_heal::Result<Option<rustfs_heal::heal::storage::HealObjectInfo>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_object_data(&self, _bucket: &str, _object: &str) -> rustfs_heal::Result<Option<Vec<u8>>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn put_object_data(&self, _bucket: &str, _object: &str, _data: &[u8]) -> rustfs_heal::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_object(&self, _bucket: &str, _object: &str) -> rustfs_heal::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn verify_object_integrity(&self, _bucket: &str, _object: &str) -> rustfs_heal::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn ec_decode_rebuild(&self, _bucket: &str, _object: &str) -> rustfs_heal::Result<Vec<u8>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_disk_status(&self, _endpoint: &HealEndpoint) -> rustfs_heal::Result<rustfs_heal::heal::storage::DiskStatus> {
|
||||
Ok(rustfs_heal::heal::storage::DiskStatus::Ok)
|
||||
}
|
||||
|
||||
async fn format_disk(&self, _endpoint: &HealEndpoint) -> rustfs_heal::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_bucket_info(&self, _bucket: &str) -> rustfs_heal::Result<Option<HealBucketInfo>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn heal_bucket_metadata(&self, _bucket: &str) -> rustfs_heal::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_buckets(&self) -> rustfs_heal::Result<Vec<HealBucketInfo>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn object_exists(&self, _bucket: &str, _object: &str) -> rustfs_heal::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn get_object_size(&self, _bucket: &str, _object: &str) -> rustfs_heal::Result<Option<u64>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_object_checksum(&self, _bucket: &str, _object: &str) -> rustfs_heal::Result<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn heal_object(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_object: &str,
|
||||
_version_id: Option<&str>,
|
||||
_opts: &rustfs_common::heal_channel::HealOpts,
|
||||
) -> rustfs_heal::Result<(rustfs_madmin::heal_commands::HealResultItem, Option<rustfs_heal::Error>)> {
|
||||
Ok((rustfs_madmin::heal_commands::HealResultItem::default(), None))
|
||||
}
|
||||
|
||||
async fn heal_bucket(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_opts: &rustfs_common::heal_channel::HealOpts,
|
||||
) -> rustfs_heal::Result<rustfs_madmin::heal_commands::HealResultItem> {
|
||||
Ok(rustfs_madmin::heal_commands::HealResultItem::default())
|
||||
}
|
||||
|
||||
async fn heal_format(
|
||||
&self,
|
||||
_dry_run: bool,
|
||||
) -> rustfs_heal::Result<(rustfs_madmin::heal_commands::HealResultItem, Option<rustfs_heal::Error>)> {
|
||||
Ok((rustfs_madmin::heal_commands::HealResultItem::default(), None))
|
||||
}
|
||||
|
||||
async fn list_objects_for_heal(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_prefix: &str,
|
||||
) -> rustfs_heal::Result<Vec<rustfs_heal::heal::storage::HealListItem>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn list_objects_for_heal_page(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_prefix: &str,
|
||||
_continuation_token: Option<&str>,
|
||||
) -> rustfs_heal::Result<(Vec<rustfs_heal::heal::storage::HealListItem>, Option<String>, bool)> {
|
||||
Ok((Vec::new(), None, false))
|
||||
}
|
||||
|
||||
async fn get_disk_for_resume(&self, _set_disk_id: &str) -> rustfs_heal::Result<DiskStore> {
|
||||
Err(rustfs_heal::Error::other("not implemented in heal control test"))
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_node_service() -> NodeService {
|
||||
make_server()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heal_control_replay_cache_singleflights_only_matching_request_ids() {
|
||||
let mut cache = HashMap::new();
|
||||
let first = admit_heal_control_replay(&mut cache, "request-1", &[1; 32], 200, 100).unwrap();
|
||||
let exact = admit_heal_control_replay(&mut cache, "request-1", &[1; 32], 200, 100).unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &exact));
|
||||
|
||||
let collision = admit_heal_control_replay(&mut cache, "request-1", &[2; 32], 200, 100)
|
||||
.expect_err("one request ID must not identify two commands");
|
||||
assert_eq!(collision.code(), tonic::Code::AlreadyExists);
|
||||
|
||||
remove_heal_control_replay(&mut cache, "request-1", &first);
|
||||
assert!(!cache.contains_key("request-1"), "completed query results must not remain cached");
|
||||
|
||||
let second = admit_heal_control_replay(&mut cache, "request-2", &[2; 32], 300, 100).unwrap();
|
||||
let first_execution = first.result.lock().await;
|
||||
let _second_execution = tokio::time::timeout(Duration::from_millis(50), second.result.lock())
|
||||
.await
|
||||
.expect("a different request ID must not wait behind the first request");
|
||||
|
||||
drop(first_execution);
|
||||
drop(_second_execution);
|
||||
drop(exact);
|
||||
drop(first);
|
||||
drop(second);
|
||||
let _third = admit_heal_control_replay(&mut cache, "request-3", &[3; 32], 400, 300).unwrap();
|
||||
assert!(!cache.contains_key("request-1"), "expired idle entries must be purged before admission");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heal_control_executor_preserves_canonical_token_and_drops_query_results() {
|
||||
let manager = Arc::new(HealManager::new(Arc::new(HealControlMockStorage), None));
|
||||
let coordinator_epoch = 7;
|
||||
let now = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000;
|
||||
let now = i64::try_from(now).expect("test clock should fit in i64");
|
||||
let metadata = || rustfs_protos::heal_control::RequestMetadata::new(rand::random(), now, now + 30_000, coordinator_epoch);
|
||||
let start = |request_id: String| {
|
||||
let mut request =
|
||||
rustfs_common::heal_channel::create_heal_request("bucket".to_string(), Some("prefix".to_string()), false, None);
|
||||
request.id = request_id;
|
||||
request.source = rustfs_common::heal_channel::HealRequestSource::Admin;
|
||||
request
|
||||
};
|
||||
|
||||
let canonical_token = uuid::Uuid::new_v4().to_string();
|
||||
let first = rustfs_protos::heal_control::Envelope::start(start(canonical_token.clone()), metadata()).unwrap();
|
||||
let first_result = execute_heal_control_envelope_with_manager(first, coordinator_epoch, Some(Arc::clone(&manager)))
|
||||
.await
|
||||
.unwrap();
|
||||
let first_outcome = rustfs_protos::heal_control::decode_result(&first_result)
|
||||
.and_then(|result| result.into_outcome(&canonical_token, coordinator_epoch))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
first_outcome,
|
||||
rustfs_protos::heal_control::Outcome::Start {
|
||||
task_id,
|
||||
admission: rustfs_protos::heal_control::Admission::Accepted,
|
||||
} if task_id == canonical_token
|
||||
));
|
||||
|
||||
let duplicate_id = uuid::Uuid::new_v4().to_string();
|
||||
let duplicate = rustfs_protos::heal_control::Envelope::start(start(duplicate_id.clone()), metadata()).unwrap();
|
||||
let duplicate_result =
|
||||
execute_heal_control_envelope_with_manager(duplicate, coordinator_epoch, Some(Arc::clone(&manager)))
|
||||
.await
|
||||
.unwrap();
|
||||
let duplicate_outcome = rustfs_protos::heal_control::decode_result(&duplicate_result)
|
||||
.and_then(|result| result.into_outcome(&duplicate_id, coordinator_epoch))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
duplicate_outcome,
|
||||
rustfs_protos::heal_control::Outcome::Start {
|
||||
task_id,
|
||||
admission: rustfs_protos::heal_control::Admission::Merged,
|
||||
} if task_id == canonical_token
|
||||
));
|
||||
|
||||
let query_id = uuid::Uuid::new_v4().to_string();
|
||||
let query = rustfs_protos::heal_control::Envelope::query(
|
||||
query_id.clone(),
|
||||
metadata(),
|
||||
"bucket/prefix".to_string(),
|
||||
canonical_token.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let query_result = execute_heal_control_envelope_with_manager(query, coordinator_epoch, Some(Arc::clone(&manager)))
|
||||
.await
|
||||
.unwrap();
|
||||
let query_outcome = rustfs_protos::heal_control::decode_result(&query_result)
|
||||
.and_then(|result| result.into_outcome(&query_id, coordinator_epoch))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
query_outcome,
|
||||
rustfs_protos::heal_control::Outcome::Channel { success: true, .. }
|
||||
));
|
||||
|
||||
let cancel_id = uuid::Uuid::new_v4().to_string();
|
||||
let cancel = rustfs_protos::heal_control::Envelope::cancel(
|
||||
cancel_id.clone(),
|
||||
metadata(),
|
||||
"bucket/prefix".to_string(),
|
||||
canonical_token.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let cancel_result = execute_heal_control_envelope_with_manager(cancel, coordinator_epoch, Some(Arc::clone(&manager)))
|
||||
.await
|
||||
.unwrap();
|
||||
let cancel_outcome = rustfs_protos::heal_control::decode_result(&cancel_result)
|
||||
.and_then(|result| result.into_outcome(&cancel_id, coordinator_epoch))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
cancel_outcome,
|
||||
rustfs_protos::heal_control::Outcome::Channel { success: true, .. }
|
||||
));
|
||||
|
||||
let stopped_query_id = uuid::Uuid::new_v4().to_string();
|
||||
let stopped_query = rustfs_protos::heal_control::Envelope::query(
|
||||
stopped_query_id.clone(),
|
||||
metadata(),
|
||||
"bucket/prefix".to_string(),
|
||||
canonical_token,
|
||||
)
|
||||
.unwrap();
|
||||
let stopped_result = execute_heal_control_envelope_with_manager(stopped_query, coordinator_epoch, Some(manager))
|
||||
.await
|
||||
.unwrap();
|
||||
let stopped_outcome = rustfs_protos::heal_control::decode_result(&stopped_result)
|
||||
.and_then(|result| result.into_outcome(&stopped_query_id, coordinator_epoch))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
stopped_outcome,
|
||||
rustfs_protos::heal_control::Outcome::Channel {
|
||||
success: true,
|
||||
error: Some(detail),
|
||||
..
|
||||
} if detail == "heal task not found or expired"
|
||||
));
|
||||
|
||||
let replay_cache = super::HEAL_CONTROL_REPLAY_CACHE.get().unwrap().lock().await;
|
||||
assert!(!replay_cache.contains_key(&query_id), "completed query results must not remain cached");
|
||||
assert!(
|
||||
!replay_cache.contains_key(&stopped_query_id),
|
||||
"completed stopped queries must not remain cached"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_make_server() {
|
||||
let service = make_server();
|
||||
@@ -1439,19 +1927,24 @@ mod tests {
|
||||
|
||||
fn heal_control_request(command: &[u8]) -> Request<HealControlRequest> {
|
||||
Request::new(HealControlRequest {
|
||||
version: 1,
|
||||
version: rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION,
|
||||
topology_fingerprint: "fingerprint".to_string(),
|
||||
command: Bytes::copy_from_slice(command),
|
||||
})
|
||||
}
|
||||
|
||||
fn heal_control_test_endpoints(last_host: &str) -> EndpointServerPools {
|
||||
heal_control_test_endpoints_with_coordinator(last_host, false)
|
||||
}
|
||||
|
||||
fn heal_control_test_endpoints_with_coordinator(last_host: &str, coordinator_local: bool) -> EndpointServerPools {
|
||||
let endpoints = ["node-a", "node-b", "node-c", last_host]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, host)| {
|
||||
let mut endpoint = Endpoint::try_from(format!("http://{host}:9000/disk{}", index + 1).as_str())
|
||||
.expect("test endpoint should parse");
|
||||
endpoint.is_local = coordinator_local && index == 0;
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(index / 2);
|
||||
endpoint.set_disk_index(index % 2);
|
||||
@@ -1475,7 +1968,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heal_control_requires_body_bound_auth_before_reporting_disabled() {
|
||||
async fn heal_control_requires_body_bound_auth_before_topology_validation() {
|
||||
let service = make_heal_control_server();
|
||||
let unsigned = service
|
||||
.heal_control(heal_control_request(b"query"))
|
||||
@@ -1484,20 +1977,31 @@ mod tests {
|
||||
assert_eq!(unsigned.code(), tonic::Code::PermissionDenied);
|
||||
|
||||
let mut tampered = heal_control_request(b"query");
|
||||
let other_body =
|
||||
rustfs_protos::canonical_heal_control_request_body(1, "fingerprint", b"cancel").expect("small request should encode");
|
||||
let other_body = rustfs_protos::canonical_heal_control_request_body(
|
||||
rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION,
|
||||
"fingerprint",
|
||||
b"cancel",
|
||||
)
|
||||
.expect("small request should encode");
|
||||
set_tonic_canonical_body_digest(&mut tampered, &other_body).expect("digest metadata should encode");
|
||||
mark_v2_authenticated(&mut tampered);
|
||||
let tampered = service.heal_control(tampered).await.expect_err("tampered request must fail");
|
||||
assert_eq!(tampered.code(), tonic::Code::PermissionDenied);
|
||||
|
||||
let mut signed = heal_control_request(b"query");
|
||||
let body =
|
||||
rustfs_protos::canonical_heal_control_request_body(1, "fingerprint", b"query").expect("small request should encode");
|
||||
let body = rustfs_protos::canonical_heal_control_request_body(
|
||||
rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION,
|
||||
"fingerprint",
|
||||
b"query",
|
||||
)
|
||||
.expect("small request should encode");
|
||||
set_tonic_canonical_body_digest(&mut signed, &body).expect("digest metadata should encode");
|
||||
mark_v2_authenticated(&mut signed);
|
||||
let disabled = service.heal_control(signed).await.expect_err("routing must remain disabled");
|
||||
assert_eq!(disabled.code(), tonic::Code::Unimplemented);
|
||||
let unavailable = service
|
||||
.heal_control(signed)
|
||||
.await
|
||||
.expect_err("authenticated commands still require initialized topology");
|
||||
assert_eq!(unavailable.code(), tonic::Code::FailedPrecondition);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1511,15 +2015,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heal_control_probe_requires_exact_topology_and_keeps_commands_disabled() {
|
||||
async fn heal_control_probe_requires_exact_topology_and_coordinator() {
|
||||
let _ = rustfs_credentials::set_global_rpc_secret("heal-control-node-service-test-secret".to_string());
|
||||
let endpoints = heal_control_test_endpoints("node-d");
|
||||
let fingerprint = heal_topology_fingerprint(&endpoints).expect("test topology should hash");
|
||||
let cache = Arc::new(tokio::sync::OnceCell::new());
|
||||
initialize_heal_topology_fingerprint(Arc::clone(&cache), endpoints)
|
||||
.await
|
||||
.expect("valid topology should initialize");
|
||||
let service = make_heal_control_server_with_cache(cache);
|
||||
let (service, source) = super::make_heal_control_server_for_source();
|
||||
*source.write().await = Some(endpoints);
|
||||
|
||||
let probe_command = rustfs_protos::heal_control_capability_probe(&[7; 16]);
|
||||
let mut probe = Request::new(HealControlRequest {
|
||||
@@ -1548,6 +2049,25 @@ mod tests {
|
||||
crate::storage::storage_api::verify_tonic_rpc_response_proof(&canonical_ack, &response.into_inner().result)
|
||||
.expect("response proof should authenticate the exact acknowledgement");
|
||||
|
||||
let mut old_probe = Request::new(HealControlRequest {
|
||||
version: rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION - 1,
|
||||
topology_fingerprint: fingerprint.clone(),
|
||||
command: Bytes::from(probe_command.clone()),
|
||||
});
|
||||
let old_body = rustfs_protos::canonical_heal_control_request_body(
|
||||
old_probe.get_ref().version,
|
||||
&old_probe.get_ref().topology_fingerprint,
|
||||
&old_probe.get_ref().command,
|
||||
)
|
||||
.expect("old probe should encode for rejection test");
|
||||
set_tonic_canonical_body_digest(&mut old_probe, &old_body).expect("digest metadata should encode");
|
||||
mark_v2_authenticated(&mut old_probe);
|
||||
let old_version = service
|
||||
.heal_control(old_probe)
|
||||
.await
|
||||
.expect_err("old coordination capability must fail closed");
|
||||
assert_eq!(old_version.code(), tonic::Code::FailedPrecondition);
|
||||
|
||||
let divergent_probe = rustfs_protos::heal_control_capability_probe(&[8; 16]);
|
||||
let mut divergent = Request::new(HealControlRequest {
|
||||
version: rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION,
|
||||
@@ -1569,15 +2089,81 @@ mod tests {
|
||||
.expect_err("divergent topology must fail closed");
|
||||
assert_eq!(mismatch.code(), tonic::Code::FailedPrecondition);
|
||||
|
||||
let mut command = heal_control_request(b"start");
|
||||
let body = rustfs_protos::canonical_heal_control_request_body(1, "fingerprint", b"start").expect("command should encode");
|
||||
let mut command = Request::new(HealControlRequest {
|
||||
version: rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION,
|
||||
topology_fingerprint: fingerprint.clone(),
|
||||
command: Bytes::from_static(b"start"),
|
||||
});
|
||||
let body = rustfs_protos::canonical_heal_control_request_body(
|
||||
rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION,
|
||||
&fingerprint,
|
||||
b"start",
|
||||
)
|
||||
.expect("command should encode");
|
||||
set_tonic_canonical_body_digest(&mut command, &body).expect("digest metadata should encode");
|
||||
mark_v2_authenticated(&mut command);
|
||||
let disabled = service
|
||||
let non_coordinator = service
|
||||
.heal_control(command)
|
||||
.await
|
||||
.expect_err("commands must remain disabled");
|
||||
assert_eq!(disabled.code(), tonic::Code::Unimplemented);
|
||||
.expect_err("commands must be rejected by a non-coordinator node");
|
||||
assert_eq!(non_coordinator.code(), tonic::Code::FailedPrecondition);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heal_control_coordinator_rejects_expired_and_non_admin_starts() {
|
||||
let _ = rustfs_credentials::set_global_rpc_secret("heal-control-node-service-test-secret".to_string());
|
||||
let endpoints = heal_control_test_endpoints_with_coordinator("node-d", true);
|
||||
let fingerprint = heal_topology_fingerprint(&endpoints).expect("test topology should hash");
|
||||
let coordinator_epoch =
|
||||
rustfs_protos::heal_control_coordinator_epoch(&fingerprint).expect("test topology should have an epoch");
|
||||
let (service, source) = super::make_heal_control_server_for_source();
|
||||
*source.write().await = Some(endpoints);
|
||||
|
||||
fn signed_command(fingerprint: &str, command: Vec<u8>) -> Request<HealControlRequest> {
|
||||
let mut request = Request::new(HealControlRequest {
|
||||
version: rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION,
|
||||
topology_fingerprint: fingerprint.to_string(),
|
||||
command: command.into(),
|
||||
});
|
||||
let body = rustfs_protos::canonical_heal_control_request_body(
|
||||
request.get_ref().version,
|
||||
&request.get_ref().topology_fingerprint,
|
||||
&request.get_ref().command,
|
||||
)
|
||||
.expect("command should encode");
|
||||
set_tonic_canonical_body_digest(&mut request, &body).expect("digest metadata should encode");
|
||||
mark_v2_authenticated(&mut request);
|
||||
request
|
||||
}
|
||||
|
||||
let expired_request = rustfs_common::heal_channel::create_heal_request("bucket".to_string(), None, false, None);
|
||||
let expired = rustfs_protos::heal_control::Envelope::start(
|
||||
expired_request,
|
||||
rustfs_protos::heal_control::RequestMetadata::new([1; 16], 1, 2, coordinator_epoch),
|
||||
)
|
||||
.and_then(|envelope| rustfs_protos::heal_control::encode_envelope(&envelope))
|
||||
.expect("expired command should encode structurally");
|
||||
let expired = service
|
||||
.heal_control(signed_command(&fingerprint, expired))
|
||||
.await
|
||||
.expect_err("expired commands must fail before admission");
|
||||
assert_eq!(expired.code(), tonic::Code::FailedPrecondition);
|
||||
|
||||
let mut non_admin_request = rustfs_common::heal_channel::create_heal_request("bucket".to_string(), None, false, None);
|
||||
non_admin_request.source = rustfs_common::heal_channel::HealRequestSource::Scanner;
|
||||
let now = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000;
|
||||
let now = i64::try_from(now).expect("test clock should fit in i64");
|
||||
let non_admin = rustfs_protos::heal_control::Envelope::start(
|
||||
non_admin_request,
|
||||
rustfs_protos::heal_control::RequestMetadata::new([2; 16], now, now + 1_000, coordinator_epoch),
|
||||
)
|
||||
.and_then(|envelope| rustfs_protos::heal_control::encode_envelope(&envelope))
|
||||
.expect("non-admin command should encode structurally");
|
||||
let non_admin = service
|
||||
.heal_control(signed_command(&fingerprint, non_admin))
|
||||
.await
|
||||
.expect_err("non-admin commands must fail before admission");
|
||||
assert_eq!(non_admin.code(), tonic::Code::PermissionDenied);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3389,32 +3975,43 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heal_control_transport_enforces_codec_limit_and_reaches_disabled_handler() {
|
||||
async fn heal_control_transport_enforces_codec_limit_and_fails_closed() {
|
||||
let Some(mut client) = connect_test_heal_control_client().await else {
|
||||
return;
|
||||
};
|
||||
let mut request = heal_control_request(b"query");
|
||||
let body =
|
||||
rustfs_protos::canonical_heal_control_request_body(1, "fingerprint", b"query").expect("small request should encode");
|
||||
let body = rustfs_protos::canonical_heal_control_request_body(
|
||||
rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION,
|
||||
"fingerprint",
|
||||
b"query",
|
||||
)
|
||||
.expect("small request should encode");
|
||||
set_tonic_canonical_body_digest(&mut request, &body).expect("digest metadata should encode");
|
||||
mark_v2_authenticated(&mut request);
|
||||
let disabled = client.heal_control(request).await.expect_err("routing must remain disabled");
|
||||
assert_eq!(disabled.code(), tonic::Code::Unimplemented);
|
||||
let rejected = client
|
||||
.heal_control(request)
|
||||
.await
|
||||
.expect_err("invalid command must fail closed");
|
||||
assert_eq!(rejected.code(), tonic::Code::FailedPrecondition);
|
||||
|
||||
let max_command = vec![0; HEAL_CONTROL_PAYLOAD_MAX_SIZE];
|
||||
let mut max_request = heal_control_request(&max_command);
|
||||
let max_body = rustfs_protos::canonical_heal_control_request_body(1, "fingerprint", &max_command)
|
||||
.expect("maximum request should encode");
|
||||
let max_body = rustfs_protos::canonical_heal_control_request_body(
|
||||
rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION,
|
||||
"fingerprint",
|
||||
&max_command,
|
||||
)
|
||||
.expect("maximum request should encode");
|
||||
set_tonic_canonical_body_digest(&mut max_request, &max_body).expect("digest metadata should encode");
|
||||
mark_v2_authenticated(&mut max_request);
|
||||
let disabled = client
|
||||
let rejected = client
|
||||
.heal_control(max_request)
|
||||
.await
|
||||
.expect_err("maximum valid request must reach disabled handler");
|
||||
assert_eq!(disabled.code(), tonic::Code::Unimplemented);
|
||||
.expect_err("maximum valid transport payload must reach validation");
|
||||
assert_eq!(rejected.code(), tonic::Code::FailedPrecondition);
|
||||
|
||||
let oversized = Request::new(HealControlRequest {
|
||||
version: 1,
|
||||
version: rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION,
|
||||
topology_fingerprint: "fingerprint".to_string(),
|
||||
command: Bytes::from(vec![0; rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE]),
|
||||
});
|
||||
|
||||
@@ -28,6 +28,24 @@ const NODE_HEAL_STATUS_VERSION: u8 = 1;
|
||||
const NODE_HEAL_STATUS_MAX_SIZE: usize = 64 * 1024;
|
||||
const HEAL_TOPOLOGY_FINGERPRINT_DOMAIN: &[u8] = b"rustfs-heal-topology-v1\0";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct HealControlCoordinator {
|
||||
pub grid_host: String,
|
||||
pub is_local: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn heal_control_coordinator(endpoint_pools: &EndpointServerPools) -> Result<HealControlCoordinator, String> {
|
||||
endpoint_pools
|
||||
.get_nodes()
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|node| HealControlCoordinator {
|
||||
grid_host: node.grid_host,
|
||||
is_local: node.is_local,
|
||||
})
|
||||
.ok_or_else(|| "heal control topology has no coordinator".to_string())
|
||||
}
|
||||
|
||||
fn hash_sized(hasher: &mut Sha256, value: &[u8]) -> Result<(), String> {
|
||||
let len = u64::try_from(value.len()).map_err(|_| "heal topology field length cannot be represented".to_string())?;
|
||||
hasher.update(len.to_be_bytes());
|
||||
@@ -246,7 +264,7 @@ pub(crate) fn decode_node_heal_status(data: &[u8]) -> Result<NodeHealStatusSnaps
|
||||
mod tests {
|
||||
use super::{
|
||||
NODE_HEAL_STATUS_MAX_SIZE, NODE_HEAL_STATUS_VERSION, NodeHealProgress, NodeHealStatusSnapshot, decode_node_heal_status,
|
||||
encode_node_heal_status, heal_topology_fingerprint,
|
||||
encode_node_heal_status, heal_control_coordinator, heal_topology_fingerprint,
|
||||
};
|
||||
use crate::storage::storage_api::{
|
||||
Endpoint,
|
||||
@@ -308,6 +326,23 @@ mod tests {
|
||||
assert_ne!(heal_topology_fingerprint(&changed).expect("changed topology should still hash"), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_control_coordinator_is_stable_across_node_views() {
|
||||
let topology = topology_endpoints("node-d");
|
||||
let coordinator = heal_control_coordinator(&topology).expect("valid topology should select a coordinator");
|
||||
assert_eq!(coordinator.grid_host, "http://node-a:9000");
|
||||
assert!(coordinator.is_local);
|
||||
|
||||
let mut other_node_view = topology;
|
||||
for endpoint in other_node_view.as_mut()[0].endpoints.as_mut() {
|
||||
endpoint.is_local = endpoint.host_port() == "node-c:9000";
|
||||
}
|
||||
let coordinator = heal_control_coordinator(&other_node_view).expect("all nodes should select the same coordinator");
|
||||
assert_eq!(coordinator.grid_host, "http://node-a:9000");
|
||||
assert!(!coordinator.is_local);
|
||||
assert!(heal_control_coordinator(&EndpointServerPools::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_topology_fingerprint_rejects_duplicate_positions() {
|
||||
let mut topology = topology_endpoints("node-d");
|
||||
|
||||
@@ -228,6 +228,11 @@ pub(crate) mod rpc_consumer {
|
||||
};
|
||||
pub(crate) type StorageResult<T> = super::super::Result<T>;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) type HealEndpoint = super::super::ecstore_disk::endpoint::Endpoint;
|
||||
#[cfg(test)]
|
||||
pub(crate) type HealBucketInfo = super::super::contract::bucket::BucketInfo;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) const STORAGE_CLASS_SUB_SYS: &str = super::super::STORAGE_CLASS_SUB_SYS;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user