Files
rustfs/crates/protocols/src/swift/sync.rs
T
houseme efa89a98ed refactor(logging): standardize protocol and observability events (#3419)
* refactor(logging): standardize object capacity events

* refactor(logging): standardize protocol server events

* refactor(logging): standardize swift protocol events

* refactor(logging): standardize observability events

* refactor(logging): move masking helper and extend guardrails
2026-06-14 07:14:45 +08:00

499 lines
16 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Container Synchronization Support for Swift API
//!
//! This module implements bidirectional container synchronization between
//! Swift clusters, enabling disaster recovery, geo-replication, and
//! multi-region deployments.
//!
//! # Configuration
//!
//! Container sync is configured via container metadata:
//!
//! ```bash
//! # Set sync target and key
//! swift post my-container \
//! -H "X-Container-Sync-To: https://remote-swift.example.com/v1/AUTH_remote/backup-container" \
//! -H "X-Container-Sync-Key: mysecretkey123"
//! ```
//!
//! # Sync Process
//!
//! 1. Background worker periodically scans containers with sync configuration
//! 2. Compares local objects with remote container
//! 3. Syncs new/updated objects to remote
//! 4. Uses timestamp-based conflict resolution
//! 5. Retries failed syncs with exponential backoff
//!
//! # Conflict Resolution
//!
//! - **Last-Write-Wins**: Most recent timestamp wins
//! - **Bidirectional**: Both clusters can accept writes
//! - **Eventual Consistency**: Objects converge to same state
//!
//! # Security
//!
//! - Shared secret key (X-Container-Sync-Key) for authentication
//! - HTTPS recommended for remote connections
//! - Per-container isolation
use super::{SwiftError, SwiftResult};
use std::collections::HashMap;
use tracing::{debug, warn};
const LOG_COMPONENT_PROTOCOLS: &str = "protocols";
const LOG_SUBSYSTEM_SWIFT_SYNC: &str = "swift_sync";
const EVENT_SWIFT_SYNC_CONFIG_STATE: &str = "swift_sync_config_state";
const EVENT_SWIFT_SYNC_RETRY_STATE: &str = "swift_sync_retry_state";
/// Container sync configuration
#[derive(Debug, Clone, PartialEq)]
pub struct SyncConfig {
/// Target Swift URL (e.g., "https://remote/v1/AUTH_account/container")
pub sync_to: String,
/// Shared secret key for authentication
pub sync_key: String,
/// Enable/disable sync
pub enabled: bool,
}
impl SyncConfig {
/// Parse sync configuration from container metadata
pub fn from_metadata(metadata: &HashMap<String, String>) -> SwiftResult<Option<Self>> {
// Check for sync target
let sync_to = match metadata.get("x-container-sync-to") {
Some(url) if !url.is_empty() => url.clone(),
_ => return Ok(None),
};
// Get sync key
let sync_key = metadata
.get("x-container-sync-key")
.ok_or_else(|| SwiftError::BadRequest("X-Container-Sync-Key required when X-Container-Sync-To is set".to_string()))?
.clone();
if sync_key.is_empty() {
return Err(SwiftError::BadRequest("X-Container-Sync-Key cannot be empty".to_string()));
}
Ok(Some(SyncConfig {
sync_to,
sync_key,
enabled: true,
}))
}
/// Convert to container metadata headers
pub fn to_metadata(&self) -> HashMap<String, String> {
let mut metadata = HashMap::new();
metadata.insert("x-container-sync-to".to_string(), self.sync_to.clone());
metadata.insert("x-container-sync-key".to_string(), self.sync_key.clone());
metadata
}
/// Validate sync target URL
pub fn validate(&self) -> SwiftResult<()> {
// Parse URL to ensure it's valid
if !self.sync_to.starts_with("http://") && !self.sync_to.starts_with("https://") {
return Err(SwiftError::BadRequest("X-Container-Sync-To must be a valid HTTP(S) URL".to_string()));
}
// Warn if using HTTP instead of HTTPS
if self.sync_to.starts_with("http://") {
warn!(event = EVENT_SWIFT_SYNC_CONFIG_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SWIFT_SYNC, result = "unencrypted_http", sync_to = %self.sync_to, "swift sync config state changed");
}
// Validate key length (recommend at least 16 characters)
if self.sync_key.len() < 16 {
warn!(
event = EVENT_SWIFT_SYNC_CONFIG_STATE,
component = LOG_COMPONENT_PROTOCOLS,
subsystem = LOG_SUBSYSTEM_SWIFT_SYNC,
result = "short_key",
key_length = self.sync_key.len(),
"swift sync config state changed"
);
}
Ok(())
}
}
/// Sync status for a container
#[derive(Debug, Clone)]
pub struct SyncStatus {
/// Last successful sync timestamp (Unix seconds)
pub last_sync: Option<u64>,
/// Number of objects successfully synced
pub objects_synced: u64,
/// Number of sync failures
pub sync_failures: u64,
/// Last sync error message
pub last_error: Option<String>,
/// Objects currently in sync queue
pub queue_size: u64,
}
impl SyncStatus {
/// Create new empty sync status
pub fn new() -> Self {
SyncStatus {
last_sync: None,
objects_synced: 0,
sync_failures: 0,
last_error: None,
queue_size: 0,
}
}
/// Record successful sync
pub fn record_success(&mut self, timestamp: u64, objects_count: u64) {
self.last_sync = Some(timestamp);
self.objects_synced += objects_count;
self.last_error = None;
}
/// Record sync failure
pub fn record_failure(&mut self, error_msg: String) {
self.sync_failures += 1;
self.last_error = Some(error_msg);
}
}
impl Default for SyncStatus {
fn default() -> Self {
Self::new()
}
}
/// Sync queue entry for an object that needs syncing
#[derive(Debug, Clone)]
pub struct SyncQueueEntry {
/// Object name
pub object: String,
/// Object ETag for change detection
pub etag: String,
/// Last modified timestamp
pub last_modified: u64,
/// Retry count
pub retry_count: u32,
/// Next retry time (Unix seconds)
pub next_retry: u64,
}
impl SyncQueueEntry {
/// Create new sync queue entry
pub fn new(object: String, etag: String, last_modified: u64) -> Self {
SyncQueueEntry {
object,
etag,
last_modified,
retry_count: 0,
next_retry: 0,
}
}
/// Calculate next retry time with exponential backoff
pub fn schedule_retry(&mut self, current_time: u64) {
self.retry_count += 1;
// Exponential backoff: 1m, 2m, 4m, 8m, 16m, max 1 hour
let backoff_seconds = std::cmp::min(60 * (1 << (self.retry_count - 1)), 3600);
self.next_retry = current_time + backoff_seconds;
debug!(event = EVENT_SWIFT_SYNC_RETRY_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SWIFT_SYNC, state = "scheduled", retry_count = self.retry_count, object = %self.object, backoff_seconds, "swift sync retry state changed");
}
/// Check if ready for retry
pub fn ready_for_retry(&self, current_time: u64) -> bool {
current_time >= self.next_retry
}
/// Check if max retries exceeded
pub fn max_retries_exceeded(&self) -> bool {
self.retry_count >= 10 // Max 10 retries
}
}
/// Conflict resolution strategy
#[derive(Debug, Clone, PartialEq)]
pub enum ConflictResolution {
/// Use object with most recent timestamp
LastWriteWins,
/// Always prefer local object
LocalWins,
/// Always prefer remote object
RemoteWins,
}
/// Compare timestamps for conflict resolution
pub fn resolve_conflict(local_timestamp: u64, remote_timestamp: u64, strategy: ConflictResolution) -> bool {
match strategy {
ConflictResolution::LastWriteWins => local_timestamp >= remote_timestamp,
ConflictResolution::LocalWins => true,
ConflictResolution::RemoteWins => false,
}
}
/// Extract container name from sync target URL
///
/// Example: "https://remote/v1/AUTH_account/container" -> "container"
pub fn extract_target_container(sync_to: &str) -> SwiftResult<String> {
let url_path = sync_to
.strip_prefix("http://")
.or_else(|| sync_to.strip_prefix("https://"))
.ok_or_else(|| SwiftError::BadRequest("Invalid sync URL".to_string()))?;
// Find the path after the host
let path_start = url_path
.find('/')
.ok_or_else(|| SwiftError::BadRequest("Invalid sync URL: missing path".to_string()))?;
let path = &url_path[path_start..];
// Expected format: /v1/{account}/{container}
let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if parts.len() < 3 {
return Err(SwiftError::BadRequest(
"Invalid sync URL: expected format /v1/{account}/{container}".to_string(),
));
}
Ok(parts[2].to_string())
}
/// Generate sync signature for authentication
///
/// Uses HMAC-SHA1 of the request path with shared secret
pub fn generate_sync_signature(path: &str, key: &str) -> SwiftResult<String> {
use hmac::{Hmac, KeyInit, Mac};
use sha1::Sha1;
type HmacSha1 = Hmac<Sha1>;
let mut mac = HmacSha1::new_from_slice(key.as_bytes())
.map_err(|_| SwiftError::BadRequest("Invalid X-Container-Sync-Key".to_string()))?;
mac.update(path.as_bytes());
let result = mac.finalize();
Ok(hex::encode(result.into_bytes()))
}
/// Verify sync signature
pub fn verify_sync_signature(path: &str, key: &str, signature: &str) -> bool {
match generate_sync_signature(path, key) {
Ok(expected) => expected == signature,
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sync_config_from_metadata() {
let mut metadata = HashMap::new();
metadata.insert(
"x-container-sync-to".to_string(),
"https://remote.example.com/v1/AUTH_remote/backup".to_string(),
);
metadata.insert("x-container-sync-key".to_string(), "mysecretkey123".to_string());
let config = SyncConfig::from_metadata(&metadata).unwrap();
assert!(config.is_some());
let config = config.unwrap();
assert_eq!(config.sync_to, "https://remote.example.com/v1/AUTH_remote/backup");
assert_eq!(config.sync_key, "mysecretkey123");
assert!(config.enabled);
}
#[test]
fn test_sync_config_from_metadata_no_sync() {
let metadata = HashMap::new();
let config = SyncConfig::from_metadata(&metadata).unwrap();
assert!(config.is_none());
}
#[test]
fn test_sync_config_from_metadata_missing_key() {
let mut metadata = HashMap::new();
metadata.insert("x-container-sync-to".to_string(), "https://example.com/v1/AUTH_test/backup".to_string());
let result = SyncConfig::from_metadata(&metadata);
assert!(result.is_err());
}
#[test]
fn test_sync_config_validation() {
let config = SyncConfig {
sync_to: "https://example.com/v1/AUTH_test/backup".to_string(),
sync_key: "verylongsecretkey123".to_string(),
enabled: true,
};
assert!(config.validate().is_ok());
}
#[test]
fn test_sync_config_validation_invalid_url() {
let config = SyncConfig {
sync_to: "invalid-url".to_string(),
sync_key: "secretkey".to_string(),
enabled: true,
};
assert!(config.validate().is_err());
}
#[test]
fn test_sync_status_record_success() {
let mut status = SyncStatus::new();
assert_eq!(status.objects_synced, 0);
status.record_success(1000, 5);
assert_eq!(status.last_sync, Some(1000));
assert_eq!(status.objects_synced, 5);
assert_eq!(status.last_error, None);
}
#[test]
fn test_sync_status_record_failure() {
let mut status = SyncStatus::new();
status.record_failure("Connection timeout".to_string());
assert_eq!(status.sync_failures, 1);
assert_eq!(status.last_error, Some("Connection timeout".to_string()));
}
#[test]
fn test_sync_queue_entry_retry_backoff() {
let mut entry = SyncQueueEntry::new("test.txt".to_string(), "abc123".to_string(), 1000);
assert_eq!(entry.retry_count, 0);
entry.schedule_retry(2000);
assert_eq!(entry.retry_count, 1);
assert_eq!(entry.next_retry, 2060); // 2000 + 60 (1 minute)
entry.schedule_retry(2060);
assert_eq!(entry.retry_count, 2);
assert_eq!(entry.next_retry, 2180); // 2060 + 120 (2 minutes)
}
#[test]
fn test_sync_queue_entry_ready_for_retry() {
let mut entry = SyncQueueEntry::new("test.txt".to_string(), "abc123".to_string(), 1000);
entry.schedule_retry(2000);
assert!(!entry.ready_for_retry(2000));
assert!(!entry.ready_for_retry(2059));
assert!(entry.ready_for_retry(2060));
assert!(entry.ready_for_retry(3000));
}
#[test]
fn test_sync_queue_entry_max_retries() {
let mut entry = SyncQueueEntry::new("test.txt".to_string(), "abc123".to_string(), 1000);
for i in 0..10 {
assert!(!entry.max_retries_exceeded());
entry.schedule_retry(1000 + i * 60);
}
assert!(entry.max_retries_exceeded());
}
#[test]
fn test_resolve_conflict_last_write_wins() {
// Local newer
assert!(resolve_conflict(2000, 1000, ConflictResolution::LastWriteWins));
// Remote newer
assert!(!resolve_conflict(1000, 2000, ConflictResolution::LastWriteWins));
// Same timestamp (local wins by default in our implementation)
assert!(resolve_conflict(1000, 1000, ConflictResolution::LastWriteWins));
}
#[test]
fn test_resolve_conflict_strategies() {
assert!(resolve_conflict(1000, 2000, ConflictResolution::LocalWins));
assert!(!resolve_conflict(2000, 1000, ConflictResolution::RemoteWins));
}
#[test]
fn test_extract_target_container() {
let url = "https://remote.example.com/v1/AUTH_test/backup-container";
let container = extract_target_container(url).unwrap();
assert_eq!(container, "backup-container");
let url2 = "http://localhost:8080/v1/AUTH_local/my-container";
let container2 = extract_target_container(url2).unwrap();
assert_eq!(container2, "my-container");
}
#[test]
fn test_extract_target_container_invalid() {
assert!(extract_target_container("invalid-url").is_err());
assert!(extract_target_container("https://example.com/invalid").is_err());
}
#[test]
fn test_generate_sync_signature() {
let path = "/v1/AUTH_test/container/object.txt";
let key = "mysecretkey";
let sig1 = generate_sync_signature(path, key).expect("failed to generate sync signature");
let sig2 = generate_sync_signature(path, key).expect("failed to generate sync signature");
// Signature should be deterministic
assert_eq!(sig1, sig2);
assert_eq!(sig1.len(), 40); // SHA1 = 20 bytes = 40 hex chars
// Different key produces different signature
let sig3 = generate_sync_signature(path, "differentkey").expect("failed to generate sync signature");
assert_ne!(sig1, sig3);
}
#[test]
fn test_verify_sync_signature() {
let path = "/v1/AUTH_test/container/object.txt";
let key = "mysecretkey";
let signature = generate_sync_signature(path, key).expect("failed to generate sync signature");
assert!(verify_sync_signature(path, key, &signature));
// Wrong signature
assert!(!verify_sync_signature(path, key, "wrongsignature"));
// Wrong key
assert!(!verify_sync_signature(path, "wrongkey", &signature));
}
}