mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-27 23:47:28 +00:00
refactor(admin): move site-replication service core out of handlers (#6699)
Mechanical move-only extraction for backlog#1840 PR1+PR4: the site-replication state (load/parse/persist/RMW transaction), repair state machine, peer transport (client cache, DNS resolver, send_peer_* family), retry queue, and the four storage-side hooks move from rustfs/src/admin/handlers/site_replication.rs into the new infra-layer module rustfs/src/site_replication/ ({mod,state,state_lock,identity,transport,retry,repair,hooks}.rs). The admin handler file keeps route registration, all Operation impls, request/response glue, and the in-file test module, and re-exports the moved items so existing paths keep resolving. admin/site_replication_identity.rs and admin/site_replication_state.rs relocate wholesale as identity.rs/state_lock.rs.
Storage access from the moved code goes through a new site_replication consumer module in the root facade (rustfs/src/storage_api.rs), including an s3 shim so the module stays off the direct s3s surface (file count stays at the 215 baseline). The three admin runtime-source wrappers the moved code needs (outbound TLS generation incl. the test atomic, outbound TLS state, runtime port) are reproduced locally; the TLS-generation trio moves out of admin/runtime_sources.rs since site replication was its only consumer. The one non-verbatim rewrite: site_replication_peer_payload inlines encrypt_stream_io in its encrypted branch, which is provably the branch encode_compatible_admin_payload always took for the /minio/admin peer-join wire path.
app/bucket_usecase.rs now imports the three bucket hooks from crate::site_replication, deleting the three app->interface entries from the layer baseline (shrink-only). The peer-client cache test moves with the owner-local SITE_REPLICATION_PEER_CLIENT static into transport.rs (228+1 = 229 tests conserved). New module files are added to the logging-guardrail checked list; the s3_error! line baseline tightens 1620 -> 1619; global-state/config-consumer inventories and ARCHITECTURE.md pointers updated.
Verified: cargo check -p rustfs --all-targets clean; cargo clippy --workspace --all-targets clean; cargo nextest run -p rustfs --lib 3852/3852 passed; make pre-commit green; scripts/check_layer_dependencies.sh green with baseline-only deletions; line-multiset conservation audit over the moved code accounts for every non-verbatim line (visibility bumps, import rewrites, fmt reflow).
Refs rustfs/backlog#1840
This commit is contained in:
@@ -332,6 +332,8 @@ The binary (`main.rs`) boots in this order:
|
|||||||
|
|
||||||
- **"Where is replication configured?"**
|
- **"Where is replication configured?"**
|
||||||
`admin/handlers/replication.rs` and `admin/handlers/site_replication.rs` for API,
|
`admin/handlers/replication.rs` and `admin/handlers/site_replication.rs` for API,
|
||||||
|
`rustfs/src/site_replication/` for the site-replication service subsystem
|
||||||
|
(state, peer transport, retry queue, repair, hooks),
|
||||||
`ecstore/src/bucket/replication/` for engine
|
`ecstore/src/bucket/replication/` for engine
|
||||||
|
|
||||||
- **"Where do I add a new admin endpoint?"**
|
- **"Where do I add a new admin endpoint?"**
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ behind narrower contracts.
|
|||||||
| Files | Current usage |
|
| Files | Current usage |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `rustfs/src/admin/handlers/kms_dynamic.rs` | Uses generic `read_config` and `save_config` for dynamic KMS config objects. |
|
| `rustfs/src/admin/handlers/kms_dynamic.rs` | Uses generic `read_config` and `save_config` for dynamic KMS config objects. |
|
||||||
| `rustfs/src/admin/handlers/site_replication.rs` | Uses generic `read_config`, `save_config`, and `delete_config` for site-replication state objects. |
|
| `rustfs/src/site_replication/state.rs` | Uses generic `read_config`, `save_config`, and `delete_config` (via the root storage facade) for site-replication state objects. |
|
||||||
| `rustfs/src/admin/service/site_replication.rs` | Uses generic `read_config` and `save_config` for site-replication state normalization. |
|
| `rustfs/src/admin/service/site_replication.rs` | Uses generic `read_config` and `save_config` for site-replication state normalization. |
|
||||||
| `rustfs/src/server/module_switch.rs` | Uses generic `read_config` and `save_config` for module-switch config objects. |
|
| `rustfs/src/server/module_switch.rs` | Uses generic `read_config` and `save_config` for module-switch config objects. |
|
||||||
| `crates/iam/src/store/object.rs` | Uses generic `read_config_no_lock`, `read_config_with_metadata`, `save_config`, `save_config_with_opts`, and `delete_config` helper variants for IAM object-store persistence paths. |
|
| `crates/iam/src/store/object.rs` | Uses generic `read_config_no_lock`, `read_config_with_metadata`, `save_config`, `save_config_with_opts`, and `delete_config` helper variants for IAM object-store persistence paths. |
|
||||||
|
|||||||
@@ -110,11 +110,11 @@ inventory. Generic function-local names such as `CACHE`, `LOCK`, `INIT`, and
|
|||||||
| `GET_OBJECT_BUFFER_THRESHOLD_WARNED`, `GET_READER_STREAM_BUFFER_SIZE_OVERRIDE`, function-local `ENABLED`, `OBJECT_SEEK_SUPPORT_THRESHOLD`, `OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS` | `rustfs/src/app/object/get.rs` | Cache or constant / owner-local cache | Object GET/seek tuning caches and warning guards stay private to object usecase helpers. |
|
| `GET_OBJECT_BUFFER_THRESHOLD_WARNED`, `GET_READER_STREAM_BUFFER_SIZE_OVERRIDE`, function-local `ENABLED`, `OBJECT_SEEK_SUPPORT_THRESHOLD`, `OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS` | `rustfs/src/app/object/get.rs` | Cache or constant / owner-local cache | Object GET/seek tuning caches and warning guards stay private to object usecase helpers. |
|
||||||
| `SUPPORTED_HEADERS` | `rustfs/src/storage/options.rs` | Cache or constant / owner-local constant | Supported-header lookup state stays private to storage option parsing. |
|
| `SUPPORTED_HEADERS` | `rustfs/src/storage/options.rs` | Cache or constant / owner-local constant | Supported-header lookup state stays private to storage option parsing. |
|
||||||
| `AUDIT_TARGET_SPECS`, `NOTIFICATION_TARGET_SPECS` | `rustfs/src/admin/handlers/audit.rs`, `rustfs/src/admin/handlers/event.rs`, `rustfs/src/admin/handlers/plugins_instances.rs` | Cache or constant / owner-local constant | Admin target descriptor tables stay private to their handler owners. |
|
| `AUDIT_TARGET_SPECS`, `NOTIFICATION_TARGET_SPECS` | `rustfs/src/admin/handlers/audit.rs`, `rustfs/src/admin/handlers/event.rs`, `rustfs/src/admin/handlers/plugins_instances.rs` | Cache or constant / owner-local constant | Admin target descriptor tables stay private to their handler owners. |
|
||||||
| `SITE_REPLICATION_PEER_CLIENT` | `rustfs/src/admin/handlers/site_replication.rs` | Process-global owner-local cache | Site-replication peer client cache stays private to site-replication handlers. The state RMW transaction holds no process-local mutex — see `rustfs/src/admin/site_replication_state.rs`. |
|
| `SITE_REPLICATION_PEER_CLIENT` | `rustfs/src/site_replication/transport.rs` | Process-global owner-local cache | Site-replication peer client cache stays private to the site-replication transport module. The state RMW transaction holds no process-local mutex — see `rustfs/src/site_replication/state_lock.rs`. |
|
||||||
| `AUDIT_MODULE_ENABLED`, `NOTIFY_MODULE_ENABLED`, `PERSISTED_NOTIFY_MODULE_ENABLED`, `PERSISTED_AUDIT_MODULE_ENABLED`, `PERSISTED_MODULE_SWITCH_CONFIGURED` | `rustfs/src/server/audit.rs`, `rustfs/src/server/event.rs`, `rustfs/src/server/module_switch.rs` | Process-global owner-local toggles | Audit/notify module snapshots stay private to the server module switch owners. |
|
| `AUDIT_MODULE_ENABLED`, `NOTIFY_MODULE_ENABLED`, `PERSISTED_NOTIFY_MODULE_ENABLED`, `PERSISTED_AUDIT_MODULE_ENABLED`, `PERSISTED_MODULE_SWITCH_CONFIGURED` | `rustfs/src/server/audit.rs`, `rustfs/src/server/event.rs`, `rustfs/src/server/module_switch.rs` | Process-global owner-local toggles | Audit/notify module snapshots stay private to the server module switch owners. |
|
||||||
| `DELETE_TAIL_TOTAL`, `DELETE_CLEANUP_TOTAL`, `DELETE_REPLICATION_TOTAL`, `DELETE_NOTIFY_TOTAL` | `rustfs/src/delete_tail_activity.rs` | Process-global owner-local counters | Delete-tail activity counters stay private behind delete-tail activity helpers. |
|
| `DELETE_TAIL_TOTAL`, `DELETE_CLEANUP_TOTAL`, `DELETE_REPLICATION_TOTAL`, `DELETE_NOTIFY_TOTAL` | `rustfs/src/delete_tail_activity.rs` | Process-global owner-local counters | Delete-tail activity counters stay private behind delete-tail activity helpers. |
|
||||||
| `EMBEDDED_SERVER_STARTED` | `rustfs/src/startup_lifecycle.rs` | Process-global owner-local guard | Embedded startup single-start protection stays private to startup lifecycle. |
|
| `EMBEDDED_SERVER_STARTED` | `rustfs/src/startup_lifecycle.rs` | Process-global owner-local guard | Embedded startup single-start protection stays private to startup lifecycle. |
|
||||||
| `TEST_OUTBOUND_TLS_GENERATION` | `rustfs/src/admin/runtime_sources.rs` | Test or fixture state | Outbound TLS generation test hook state stays private to admin runtime-source tests. |
|
| `TEST_OUTBOUND_TLS_GENERATION` | `rustfs/src/site_replication/mod.rs` | Test or fixture state | Outbound TLS generation test hook state stays private to site-replication transport tests. |
|
||||||
| `TEST_REMAINING_FAILURES` | `rustfs/src/startup_iam.rs` | Test or fixture state | IAM startup retry injection state stays private to debug/test startup code. |
|
| `TEST_REMAINING_FAILURES` | `rustfs/src/startup_iam.rs` | Test or fixture state | IAM startup retry injection state stays private to debug/test startup code. |
|
||||||
| `CAPACITY_DIRTY_SCOPE_ENV`, `CAPACITY_DIRTY_SCOPE_INIT`, `GLOBAL_ENV`, function-local `INIT` | `rustfs/src/app/*_test.rs` | Test or fixture state | App integration test fixture state stays private to the owning test modules. |
|
| `CAPACITY_DIRTY_SCOPE_ENV`, `CAPACITY_DIRTY_SCOPE_INIT`, `GLOBAL_ENV`, function-local `INIT` | `rustfs/src/app/*_test.rs` | Test or fixture state | App integration test fixture state stays private to the owning test modules. |
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -23,8 +23,6 @@ pub(crate) mod route_policy;
|
|||||||
pub mod router;
|
pub mod router;
|
||||||
pub(crate) mod runtime_sources;
|
pub(crate) mod runtime_sources;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod site_replication_identity;
|
|
||||||
pub(crate) mod site_replication_state;
|
|
||||||
pub(crate) mod storage_api;
|
pub(crate) mod storage_api;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
|
|||||||
@@ -36,10 +36,8 @@ pub(crate) use crate::runtime_sources::{
|
|||||||
};
|
};
|
||||||
use rustfs_config::server_config::Config;
|
use rustfs_config::server_config::Config;
|
||||||
use rustfs_kms::KmsServiceManager;
|
use rustfs_kms::KmsServiceManager;
|
||||||
use rustfs_tls_runtime::{GlobalPublishedOutboundTlsState, TlsGeneration};
|
use rustfs_tls_runtime::GlobalPublishedOutboundTlsState;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
#[cfg(test)]
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
pub(crate) fn default_admin_usecase() -> DefaultAdminUsecase {
|
pub(crate) fn default_admin_usecase() -> DefaultAdminUsecase {
|
||||||
@@ -116,29 +114,6 @@ pub(crate) fn current_or_init_kms_runtime_service_manager() -> Arc<KmsServiceMan
|
|||||||
.unwrap_or_else(rustfs_kms::init_global_kms_service_manager)
|
.unwrap_or_else(rustfs_kms::init_global_kms_service_manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
static TEST_OUTBOUND_TLS_GENERATION: AtomicU64 = AtomicU64::new(0);
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn set_test_outbound_tls_generation(generation: u64) {
|
|
||||||
root_runtime_sources::set_test_outbound_tls_generation(generation);
|
|
||||||
TEST_OUTBOUND_TLS_GENERATION.store(generation, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn current_outbound_tls_generation() -> TlsGeneration {
|
|
||||||
root_runtime_sources::current_outbound_tls_generation().unwrap_or_else(empty_outbound_tls_generation)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn empty_outbound_tls_generation() -> TlsGeneration {
|
|
||||||
TlsGeneration(TEST_OUTBOUND_TLS_GENERATION.load(Ordering::Relaxed))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(test))]
|
|
||||||
fn empty_outbound_tls_generation() -> TlsGeneration {
|
|
||||||
TlsGeneration(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn current_outbound_tls_state() -> GlobalPublishedOutboundTlsState {
|
pub(crate) async fn current_outbound_tls_state() -> GlobalPublishedOutboundTlsState {
|
||||||
if let Some(state) = root_runtime_sources::current_outbound_tls_state().await {
|
if let Some(state) = root_runtime_sources::current_outbound_tls_state().await {
|
||||||
return state;
|
return state;
|
||||||
|
|||||||
@@ -13,11 +13,11 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::admin::runtime_sources::{AppContext, current_app_context, current_object_store_handle_for_context};
|
use crate::admin::runtime_sources::{AppContext, current_app_context, current_object_store_handle_for_context};
|
||||||
use crate::admin::site_replication_identity::{
|
use crate::admin::storage_api::error::Error as StorageError;
|
||||||
|
use crate::site_replication::identity::{
|
||||||
deployment_id_for_endpoint, mark_unknown_peer_sync_enabled, normalize_peer_map_by_identity_with,
|
deployment_id_for_endpoint, mark_unknown_peer_sync_enabled, normalize_peer_map_by_identity_with,
|
||||||
};
|
};
|
||||||
use crate::admin::site_replication_state::{SITE_REPLICATION_STATE_PATH, with_site_replication_state_lock_on};
|
use crate::site_replication::state_lock::{SITE_REPLICATION_STATE_PATH, with_site_replication_state_lock_on};
|
||||||
use crate::admin::storage_api::error::Error as StorageError;
|
|
||||||
use crate::storage::storage_api::{read_config_no_lock, save_config_no_lock};
|
use crate::storage::storage_api::{read_config_no_lock, save_config_no_lock};
|
||||||
use rustfs_madmin::PeerInfo;
|
use rustfs_madmin::PeerInfo;
|
||||||
use s3s::{S3Error, S3ErrorCode, S3Result};
|
use s3s::{S3Error, S3ErrorCode, S3Result};
|
||||||
|
|||||||
@@ -445,8 +445,8 @@ pub(crate) mod replication {
|
|||||||
pub(crate) use super::ecstore_bucket::replication::{
|
pub(crate) use super::ecstore_bucket::replication::{
|
||||||
OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS,
|
OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS,
|
||||||
REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS,
|
REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS,
|
||||||
REPLICATION_WRITABLE_FIELDS, assign_site_replication_rule_priorities, is_site_replication_role,
|
REPLICATION_WRITABLE_FIELDS, assign_site_replication_rule_priorities, merge_incoming_replication_config,
|
||||||
merge_incoming_replication_config, replication_target_arn_deployment_id, site_replication_rule_deployment_id,
|
replication_target_arn_deployment_id,
|
||||||
};
|
};
|
||||||
pub(crate) type BucketReplicationResyncStatus = super::ecstore_bucket::replication::BucketReplicationResyncStatus;
|
pub(crate) type BucketReplicationResyncStatus = super::ecstore_bucket::replication::BucketReplicationResyncStatus;
|
||||||
pub(crate) type BucketStats = super::ecstore_bucket::replication::BucketStats;
|
pub(crate) type BucketStats = super::ecstore_bucket::replication::BucketStats;
|
||||||
@@ -653,8 +653,6 @@ pub(crate) mod replication {
|
|||||||
|
|
||||||
pub(crate) mod target {
|
pub(crate) mod target {
|
||||||
pub(crate) use super::ecstore_bucket::target::duration_from_secs_or_nanos;
|
pub(crate) use super::ecstore_bucket::target::duration_from_secs_or_nanos;
|
||||||
#[allow(clippy::upper_case_acronyms)]
|
|
||||||
pub(crate) type ARN = super::ecstore_bucket::target::ARN;
|
|
||||||
pub(crate) type BucketTarget = super::ecstore_bucket::target::BucketTarget;
|
pub(crate) type BucketTarget = super::ecstore_bucket::target::BucketTarget;
|
||||||
pub(crate) type BucketTargetType = super::ecstore_bucket::target::BucketTargetType;
|
pub(crate) type BucketTargetType = super::ecstore_bucket::target::BucketTargetType;
|
||||||
pub(crate) type BucketTargets = super::ecstore_bucket::target::BucketTargets;
|
pub(crate) type BucketTargets = super::ecstore_bucket::target::BucketTargets;
|
||||||
|
|||||||
@@ -64,9 +64,6 @@ use super::storage_api::bucket_usecase::{
|
|||||||
get_validated_store, process_lambda_configurations, process_queue_configurations, process_topic_configurations,
|
get_validated_store, process_lambda_configurations, process_queue_configurations, process_topic_configurations,
|
||||||
request_context, validate_list_object_unordered_with_delimiter,
|
request_context, validate_list_object_unordered_with_delimiter,
|
||||||
};
|
};
|
||||||
use crate::admin::handlers::site_replication::{
|
|
||||||
site_replication_bucket_meta_hook, site_replication_delete_bucket_hook, site_replication_make_bucket_hook,
|
|
||||||
};
|
|
||||||
use crate::app::object_data_cache::invalidate_object_data_cache_bucket_after_delete;
|
use crate::app::object_data_cache::invalidate_object_data_cache_bucket_after_delete;
|
||||||
use crate::app::runtime_sources::{
|
use crate::app::runtime_sources::{
|
||||||
AppContext, current_app_context, current_encryption_service, current_notification_system,
|
AppContext, current_app_context, current_encryption_service, current_notification_system,
|
||||||
@@ -75,6 +72,9 @@ use crate::app::runtime_sources::{
|
|||||||
use crate::auth::get_condition_values_with_client_info;
|
use crate::auth::get_condition_values_with_client_info;
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use crate::shared_types::RemoteAddr;
|
use crate::shared_types::RemoteAddr;
|
||||||
|
use crate::site_replication::{
|
||||||
|
site_replication_bucket_meta_hook, site_replication_delete_bucket_hook, site_replication_make_bucket_hook,
|
||||||
|
};
|
||||||
use crate::storage::storage_api::lock_bucket_targets_metadata;
|
use crate::storage::storage_api::lock_bucket_targets_metadata;
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
use metrics::counter;
|
use metrics::counter;
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ pub mod runtime_capabilities;
|
|||||||
pub(crate) mod runtime_sources;
|
pub(crate) mod runtime_sources;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod shared_types;
|
pub mod shared_types;
|
||||||
|
pub(crate) mod site_replication;
|
||||||
pub(crate) mod site_replication_reconcile;
|
pub(crate) mod site_replication_reconcile;
|
||||||
pub(crate) mod startup_audit;
|
pub(crate) mod startup_audit;
|
||||||
pub(crate) mod startup_auth;
|
pub(crate) mod startup_auth;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -86,7 +86,7 @@ pub(crate) fn mark_unknown_peer_sync_enabled(peers: &mut BTreeMap<String, PeerIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn is_https_endpoint(endpoint: &str) -> bool {
|
pub(crate) fn is_https_endpoint(endpoint: &str) -> bool {
|
||||||
canonical_endpoint(endpoint).starts_with("https://")
|
canonical_endpoint(endpoint).starts_with("https://")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//! Site-replication service subsystem (backlog#1840).
|
||||||
|
//!
|
||||||
|
//! The parts of site replication that storage-side flows call into — the
|
||||||
|
//! persisted cluster state and its RMW transaction, the peer HTTP transport,
|
||||||
|
//! the retry queue, the repair state machine, and the bucket/IAM broadcast
|
||||||
|
//! hooks — live here in the infra layer. The admin HTTP handlers stay in
|
||||||
|
//! `crate::admin::handlers::site_replication` and call down into this module;
|
||||||
|
//! that file re-exports these items so existing paths keep resolving.
|
||||||
|
//!
|
||||||
|
//! Storage access goes through the root facade (`crate::storage_api`) and
|
||||||
|
//! never through the admin or storage interface layers — this module sits
|
||||||
|
//! below the interface layer and must not import upward.
|
||||||
|
|
||||||
|
pub(crate) mod identity;
|
||||||
|
pub(crate) mod state_lock;
|
||||||
|
|
||||||
|
pub(crate) mod hooks;
|
||||||
|
pub(crate) mod repair;
|
||||||
|
pub(crate) mod retry;
|
||||||
|
pub(crate) mod state;
|
||||||
|
pub(crate) mod transport;
|
||||||
|
|
||||||
|
pub(crate) use self::hooks::*;
|
||||||
|
pub(crate) use self::repair::*;
|
||||||
|
pub(crate) use self::retry::*;
|
||||||
|
pub(crate) use self::state::*;
|
||||||
|
pub(crate) use self::transport::*;
|
||||||
|
|
||||||
|
use self::identity::{
|
||||||
|
canonical_endpoint, deployment_id_for_endpoint, mark_unknown_peer_sync_enabled, normalize_peer_map_by_identity_with,
|
||||||
|
same_identity_endpoint,
|
||||||
|
};
|
||||||
|
use self::state_lock::{SITE_REPLICATION_STATE_PATH, with_site_replication_state_lock};
|
||||||
|
use crate::auth::constant_time_eq;
|
||||||
|
use crate::config::get_config_snapshot;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::runtime_sources::{
|
||||||
|
current_deployment_id, current_endpoints_handle, current_iam_handle, current_object_store_handle, current_region,
|
||||||
|
};
|
||||||
|
use crate::storage_api::site_replication::s3::{
|
||||||
|
Body, BucketLifecycleConfiguration, BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus,
|
||||||
|
DeleteReplication, DeleteReplicationStatus, Destination, ExistingObjectReplication, ExistingObjectReplicationStatus,
|
||||||
|
LifecycleRule, ReplicaModifications, ReplicaModificationsStatus, ReplicationConfiguration, ReplicationRule,
|
||||||
|
ReplicationRuleStatus, S3Error, S3ErrorCode, S3Response, S3Result, SourceSelectionCriteria, VersioningConfiguration,
|
||||||
|
s3_error,
|
||||||
|
};
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::storage_api::site_replication::save_config as save_admin_config;
|
||||||
|
use crate::storage_api::site_replication::{
|
||||||
|
ARN, BUCKET_REPLICATION_CONFIG, BUCKET_TARGETS_FILE, BUCKET_VERSIONING_CONFIG, BucketOperations, BucketOptions, BucketTarget,
|
||||||
|
BucketTargetSys, BucketTargetType, BucketTargets, Credentials, ECStore, OperatorRuleContract, StorageError,
|
||||||
|
VersioningApi as _, assign_site_replication_rule_priorities, delete_config_no_lock, deserialize, is_site_replication_role,
|
||||||
|
lock_bucket_targets_metadata, metadata_sys, read_config as read_admin_config, read_config_no_lock,
|
||||||
|
replication_target_arn_deployment_id, save_config_no_lock, serialize, site_replication_rule_deployment_id,
|
||||||
|
with_config_object_read_lock, with_config_object_write_lock,
|
||||||
|
};
|
||||||
|
use base64_simd::STANDARD as BASE64_STANDARD;
|
||||||
|
use base64_simd::URL_SAFE_NO_PAD;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use http::header::{CONTENT_TYPE, HOST};
|
||||||
|
use http::{HeaderMap, HeaderValue, Uri};
|
||||||
|
use hyper::{Method, StatusCode};
|
||||||
|
use rustfs_config::{DEFAULT_CONSOLE_ADDRESS, DEFAULT_RUSTFS_TLS_PATH, ENV_RUSTFS_CONSOLE_ADDRESS, ENV_RUSTFS_TLS_PATH};
|
||||||
|
use rustfs_iam::store::{MappedPolicy, UserType, sr_wire_user_type};
|
||||||
|
use rustfs_iam::sys::SITE_REPLICATOR_SERVICE_ACCOUNT;
|
||||||
|
use rustfs_madmin::{
|
||||||
|
AddOrUpdateUserReq, GroupAddRemove, GroupStatus, PeerInfo, PeerSite, ReplicateEditStatus, SITE_REPL_API_VERSION,
|
||||||
|
SRBucketInfo, SRBucketMeta, SRGroupInfo, SRIAMItem, SRIAMPolicy, SRInfo, SRPolicyMapping, SRRemoveReq, SRResyncOpStatus,
|
||||||
|
SRRetryStats, SRStateInfo, SyncStatus,
|
||||||
|
};
|
||||||
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
|
use rustfs_signer::sign_v4;
|
||||||
|
use rustfs_tls_runtime::{GlobalPublishedOutboundTlsState, TlsGeneration};
|
||||||
|
use rustfs_utils::egress::{OutboundUrlError, validate_outbound_url};
|
||||||
|
use rustfs_utils::http::get_source_scheme;
|
||||||
|
use rustls_pki_types::pem::PemObject;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde::de::IgnoredAny;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use url::{Url, form_urlencoded};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub(crate) const LOG_COMPONENT_ADMIN: &str = "admin";
|
||||||
|
|
||||||
|
pub(crate) const LOG_SUBSYSTEM_SITE_REPLICATION: &str = "site_replication";
|
||||||
|
|
||||||
|
pub(crate) const EVENT_ADMIN_SITE_REPLICATION_STATE: &str = "admin_site_replication_state";
|
||||||
|
|
||||||
|
/// Layer-local mirror of `crate::admin::utils::json_response` (the repair
|
||||||
|
/// executor answers the admin HTTP surface but must not import upward from
|
||||||
|
/// the infra layer).
|
||||||
|
fn json_response<T: Serialize>(status: StatusCode, value: &T) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||||
|
let data = serde_json::to_vec(value)
|
||||||
|
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to serialize response: {e}")))?;
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||||
|
Ok(S3Response::with_headers((status, Body::from(data)), headers))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin layer's runtime-source wrappers apply fallbacks on top of
|
||||||
|
// `crate::runtime_sources`; this module reproduces the same fallbacks locally
|
||||||
|
// (verbatim from `crate::admin::runtime_sources`) so it never imports upward
|
||||||
|
// into the interface layer.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
static TEST_OUTBOUND_TLS_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn set_test_outbound_tls_generation(generation: u64) {
|
||||||
|
crate::runtime_sources::set_test_outbound_tls_generation(generation);
|
||||||
|
TEST_OUTBOUND_TLS_GENERATION.store(generation, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_outbound_tls_generation() -> TlsGeneration {
|
||||||
|
crate::runtime_sources::current_outbound_tls_generation().unwrap_or_else(empty_outbound_tls_generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn empty_outbound_tls_generation() -> TlsGeneration {
|
||||||
|
TlsGeneration(TEST_OUTBOUND_TLS_GENERATION.load(std::sync::atomic::Ordering::Relaxed))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn empty_outbound_tls_generation() -> TlsGeneration {
|
||||||
|
TlsGeneration(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn current_outbound_tls_state() -> GlobalPublishedOutboundTlsState {
|
||||||
|
if let Some(state) = crate::runtime_sources::current_outbound_tls_state().await {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::runtime_sources::fallback_outbound_tls_runtime_interface()
|
||||||
|
.state()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_runtime_port() -> u16 {
|
||||||
|
crate::runtime_sources::current_runtime_port().unwrap_or(rustfs_config::DEFAULT_PORT)
|
||||||
|
}
|
||||||
@@ -0,0 +1,845 @@
|
|||||||
|
// Copyright 2024 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(crate) const SITE_REPLICATION_REPAIR_STATE_PATH: &str = "config/site-replication/repair-state.json";
|
||||||
|
|
||||||
|
pub(crate) const SITE_REPLICATION_REPAIR_EXECUTION_LOCK_PATH: &str = "config/site-replication/repair-execution.lock";
|
||||||
|
|
||||||
|
pub(crate) const SITE_REPLICATION_REPAIR_OPERATION_LIMIT: usize = 32;
|
||||||
|
|
||||||
|
pub(crate) const SITE_REPLICATION_REPAIR_IAM_FAMILY: &str = "iam";
|
||||||
|
|
||||||
|
pub(crate) const SITE_REPLICATION_REPAIR_BUCKET_FAMILY: &str = "bucket";
|
||||||
|
|
||||||
|
pub(crate) const SITE_REPLICATION_REPAIR_BUCKET_METADATA_FAMILY: &str = "bucket-metadata";
|
||||||
|
|
||||||
|
pub(crate) const SITE_REPLICATION_REPAIR_REPLICATION_FAMILY: &str = "replication";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct SiteReplicationRepairState {
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub(crate) operations: BTreeMap<String, SiteReplicationRepairOperation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct SiteReplicationRepairOperation {
|
||||||
|
pub(crate) operation_id: String,
|
||||||
|
pub(crate) preflight_token: String,
|
||||||
|
pub(crate) plan_token: String,
|
||||||
|
pub(crate) status: String,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub(crate) sites: BTreeMap<String, SiteReplicationRepairSiteStatus>,
|
||||||
|
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) created_at: Option<OffsetDateTime>,
|
||||||
|
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) updated_at: Option<OffsetDateTime>,
|
||||||
|
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) completed_at: Option<OffsetDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct SiteReplicationRepairSiteStatus {
|
||||||
|
pub(crate) deployment_id: String,
|
||||||
|
pub(crate) name: String,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub(crate) families: BTreeMap<String, SiteReplicationRepairFamilyStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct SiteReplicationRepairFamilyStatus {
|
||||||
|
pub(crate) planned: usize,
|
||||||
|
pub(crate) succeeded: usize,
|
||||||
|
pub(crate) failed: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) retry_events: usize,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub(crate) tasks: Vec<SiteReplicationRepairTaskStatus>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub(crate) errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct SiteReplicationRepairTaskStatus {
|
||||||
|
pub(crate) task_id: String,
|
||||||
|
pub(crate) status: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub(crate) struct SiteReplicationRepairRequest {
|
||||||
|
pub(crate) mode: SiteReplicationRepairMode,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) preflight_token: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) operation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub(crate) enum SiteReplicationRepairMode {
|
||||||
|
DryRun,
|
||||||
|
Execute,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct SiteReplicationRepairExecutionRequest {
|
||||||
|
pub(crate) local_peer: PeerInfo,
|
||||||
|
pub(crate) preflight_token: String,
|
||||||
|
pub(crate) operation_id: String,
|
||||||
|
pub(crate) signing_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct SiteReplicationRepairPreflight {
|
||||||
|
pub(crate) mode: &'static str,
|
||||||
|
pub(crate) status: &'static str,
|
||||||
|
pub(crate) preflight_token: String,
|
||||||
|
pub(crate) retry_events: usize,
|
||||||
|
pub(crate) sites: BTreeMap<String, SiteReplicationRepairSiteStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct SiteReplicationRepairOperationResponse {
|
||||||
|
pub(crate) mode: &'static str,
|
||||||
|
pub(crate) operation_id: String,
|
||||||
|
pub(crate) status: String,
|
||||||
|
pub(crate) sites: BTreeMap<String, SiteReplicationRepairSiteResponse>,
|
||||||
|
#[serde(with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) created_at: Option<OffsetDateTime>,
|
||||||
|
#[serde(with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) updated_at: Option<OffsetDateTime>,
|
||||||
|
#[serde(with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) completed_at: Option<OffsetDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct SiteReplicationRepairSiteResponse {
|
||||||
|
pub(crate) deployment_id: String,
|
||||||
|
pub(crate) name: String,
|
||||||
|
pub(crate) families: BTreeMap<String, SiteReplicationRepairFamilyResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct SiteReplicationRepairFamilyResponse {
|
||||||
|
pub(crate) planned: usize,
|
||||||
|
pub(crate) succeeded: usize,
|
||||||
|
pub(crate) failed: usize,
|
||||||
|
pub(crate) retry_events: usize,
|
||||||
|
pub(crate) tasks: Vec<SiteReplicationRepairTaskStatus>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub(crate) errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn load_site_replication_repair_state_from_store(store: Arc<ECStore>) -> S3Result<SiteReplicationRepairState> {
|
||||||
|
match read_config_no_lock(store, SITE_REPLICATION_REPAIR_STATE_PATH).await {
|
||||||
|
Ok(data) => serde_json::from_slice(&data).map_err(|e| {
|
||||||
|
S3Error::with_message(S3ErrorCode::InternalError, format!("invalid site replication repair state: {e}"))
|
||||||
|
}),
|
||||||
|
Err(StorageError::ConfigNotFound) => Ok(SiteReplicationRepairState::default()),
|
||||||
|
Err(err) => Err(S3Error::with_message(
|
||||||
|
S3ErrorCode::InternalError,
|
||||||
|
format!("failed to load site replication repair state: {err}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn save_site_replication_repair_state_to_store(
|
||||||
|
store: Arc<ECStore>,
|
||||||
|
state: &SiteReplicationRepairState,
|
||||||
|
) -> S3Result<()> {
|
||||||
|
let data = serde_json::to_vec(state)
|
||||||
|
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize repair state failed: {e}")))?;
|
||||||
|
save_config_no_lock(store, SITE_REPLICATION_REPAIR_STATE_PATH, data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("save repair state failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn read_site_replication_repair_state() -> S3Result<SiteReplicationRepairState> {
|
||||||
|
let store =
|
||||||
|
current_object_store_handle().ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?;
|
||||||
|
let read_store = store.clone();
|
||||||
|
with_config_object_read_lock(store, SITE_REPLICATION_REPAIR_STATE_PATH.to_string(), move || async move {
|
||||||
|
load_site_replication_repair_state_from_store(read_store).await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("lock repair state failed: {e}")))?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn update_site_replication_repair_state<T, F>(update: F) -> S3Result<T>
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
F: FnOnce(&mut SiteReplicationRepairState) -> S3Result<T> + Send + 'static,
|
||||||
|
{
|
||||||
|
let store =
|
||||||
|
current_object_store_handle().ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?;
|
||||||
|
let read_store = store.clone();
|
||||||
|
let save_store = store.clone();
|
||||||
|
with_config_object_write_lock(store, SITE_REPLICATION_REPAIR_STATE_PATH.to_string(), move || async move {
|
||||||
|
let mut state = load_site_replication_repair_state_from_store(read_store).await?;
|
||||||
|
let result = update(&mut state)?;
|
||||||
|
save_site_replication_repair_state_to_store(save_store, &state).await?;
|
||||||
|
Ok(result)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("lock repair state failed: {e}")))?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum SiteReplicationRepairTask<'a> {
|
||||||
|
Iam(&'a SRIAMItem),
|
||||||
|
BucketMake(&'a str),
|
||||||
|
BucketMetadata(&'a SRBucketMeta),
|
||||||
|
Replication(&'a str),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SiteReplicationRepairTask<'_> {
|
||||||
|
pub(crate) fn family(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Iam(_) => SITE_REPLICATION_REPAIR_IAM_FAMILY,
|
||||||
|
Self::BucketMake(_) => SITE_REPLICATION_REPAIR_BUCKET_FAMILY,
|
||||||
|
Self::BucketMetadata(_) => SITE_REPLICATION_REPAIR_BUCKET_METADATA_FAMILY,
|
||||||
|
Self::Replication(_) => SITE_REPLICATION_REPAIR_REPLICATION_FAMILY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn path(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Iam(_) => "/rustfs/admin/v3/site-replication/peer/iam-item",
|
||||||
|
Self::BucketMake(path) | Self::Replication(path) => path,
|
||||||
|
Self::BucketMetadata(_) => "/rustfs/admin/v3/site-replication/peer/bucket-meta",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn id(&self) -> S3Result<String> {
|
||||||
|
let payload = match self {
|
||||||
|
Self::Iam(item) => serde_json::to_vec(item),
|
||||||
|
Self::BucketMake(_) | Self::Replication(_) => serde_json::to_vec(&serde_json::json!({})),
|
||||||
|
Self::BucketMetadata(item) => serde_json::to_vec(item),
|
||||||
|
}
|
||||||
|
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize repair task failed: {err}")))?;
|
||||||
|
let mut digest = Sha256::new();
|
||||||
|
digest.update(self.family().as_bytes());
|
||||||
|
digest.update([0]);
|
||||||
|
digest.update(self.path().as_bytes());
|
||||||
|
digest.update([0]);
|
||||||
|
digest.update(payload);
|
||||||
|
Ok(URL_SAFE_NO_PAD.encode_to_string(digest.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn send(&self, transport: &PeerTransport, access_key: &str, secret_key: &str) -> S3Result<Vec<u8>> {
|
||||||
|
match self {
|
||||||
|
Self::Iam(item) => {
|
||||||
|
send_peer_admin_request_with_client(
|
||||||
|
&transport.client,
|
||||||
|
&transport.connection,
|
||||||
|
self.path(),
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
item,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Self::BucketMetadata(item) => {
|
||||||
|
send_peer_admin_request_with_client(
|
||||||
|
&transport.client,
|
||||||
|
&transport.connection,
|
||||||
|
self.path(),
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
item,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Self::BucketMake(_) | Self::Replication(_) => {
|
||||||
|
send_peer_admin_request_with_client(
|
||||||
|
&transport.client,
|
||||||
|
&transport.connection,
|
||||||
|
self.path(),
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn site_replication_repair_tasks(plan: &SiteReplicationBootstrapPlan) -> Vec<(usize, SiteReplicationRepairTask<'_>)> {
|
||||||
|
let mut tasks = Vec::with_capacity(
|
||||||
|
plan.iam_items.len() + plan.bucket_make_ops.len() + plan.bucket_items.len() + plan.bucket_configure_ops.len(),
|
||||||
|
);
|
||||||
|
tasks.extend(
|
||||||
|
plan.iam_items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, item)| (index, SiteReplicationRepairTask::Iam(item))),
|
||||||
|
);
|
||||||
|
tasks.extend(
|
||||||
|
plan.bucket_make_ops
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, path)| (index, SiteReplicationRepairTask::BucketMake(path))),
|
||||||
|
);
|
||||||
|
tasks.extend(
|
||||||
|
plan.bucket_items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, item)| (index, SiteReplicationRepairTask::BucketMetadata(item))),
|
||||||
|
);
|
||||||
|
tasks.extend(
|
||||||
|
plan.bucket_configure_ops
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, path)| (index, SiteReplicationRepairTask::Replication(path))),
|
||||||
|
);
|
||||||
|
tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn site_replication_repair_plan_token(
|
||||||
|
state: &SiteReplicationState,
|
||||||
|
plan: &SiteReplicationBootstrapPlan,
|
||||||
|
) -> S3Result<String> {
|
||||||
|
let mut digest = Sha256::new();
|
||||||
|
let snapshot = serde_json::to_vec(&(
|
||||||
|
&state.name,
|
||||||
|
&state.service_account_access_key,
|
||||||
|
&state.peers,
|
||||||
|
state.updated_at,
|
||||||
|
state.sync_state_initialized,
|
||||||
|
))
|
||||||
|
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize repair snapshot failed: {err}")))?;
|
||||||
|
digest.update(snapshot);
|
||||||
|
for (_, task) in site_replication_repair_tasks(plan) {
|
||||||
|
digest.update(task.id()?.as_bytes());
|
||||||
|
}
|
||||||
|
Ok(URL_SAFE_NO_PAD.encode_to_string(digest.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn site_replication_repair_preflight_token(
|
||||||
|
state: &SiteReplicationState,
|
||||||
|
plan: &SiteReplicationBootstrapPlan,
|
||||||
|
signing_key: &[u8],
|
||||||
|
) -> S3Result<String> {
|
||||||
|
if signing_key.is_empty() {
|
||||||
|
return Err(S3Error::with_message(
|
||||||
|
S3ErrorCode::InternalError,
|
||||||
|
"repair signing key is empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut digest = <Hmac<Sha256> as hmac::digest::KeyInit>::new_from_slice(signing_key)
|
||||||
|
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "invalid repair signing key".to_string()))?;
|
||||||
|
digest.update(b"rustfs:site-replication:repair-preflight:v1\0");
|
||||||
|
digest.update(site_replication_repair_plan_token(state, plan)?.as_bytes());
|
||||||
|
for event in state
|
||||||
|
.retry_queue
|
||||||
|
.iter()
|
||||||
|
.filter(|event| retry_event_replayed_by_bootstrap(event))
|
||||||
|
{
|
||||||
|
digest.update(event.id.as_bytes());
|
||||||
|
digest.update(&[0]);
|
||||||
|
digest.update(event.peer_deployment_id.as_bytes());
|
||||||
|
digest.update(&[0]);
|
||||||
|
digest.update(event.path.as_bytes());
|
||||||
|
digest.update(&[0]);
|
||||||
|
}
|
||||||
|
Ok(URL_SAFE_NO_PAD.encode_to_string(digest.finalize().into_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn site_replication_repair_task_checkpoint_id(
|
||||||
|
signing_key: &[u8],
|
||||||
|
peer_deployment_id: &str,
|
||||||
|
task: &SiteReplicationRepairTask<'_>,
|
||||||
|
) -> S3Result<String> {
|
||||||
|
let mut digest = <Hmac<Sha256> as hmac::digest::KeyInit>::new_from_slice(signing_key)
|
||||||
|
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "invalid repair signing key".to_string()))?;
|
||||||
|
digest.update(b"rustfs:site-replication:repair-task:v1\0");
|
||||||
|
digest.update(peer_deployment_id.as_bytes());
|
||||||
|
digest.update(&[0]);
|
||||||
|
digest.update(task.id()?.as_bytes());
|
||||||
|
Ok(URL_SAFE_NO_PAD.encode_to_string(digest.finalize().into_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn site_replication_repair_sites(
|
||||||
|
state: &SiteReplicationState,
|
||||||
|
local_peer: &PeerInfo,
|
||||||
|
plan: &SiteReplicationBootstrapPlan,
|
||||||
|
signing_key: &[u8],
|
||||||
|
) -> S3Result<BTreeMap<String, SiteReplicationRepairSiteStatus>> {
|
||||||
|
let mut planned = BTreeMap::new();
|
||||||
|
let mut family_paths = BTreeMap::<String, BTreeSet<String>>::new();
|
||||||
|
for (_, task) in site_replication_repair_tasks(plan) {
|
||||||
|
let family = task.family().to_string();
|
||||||
|
let family_status = planned
|
||||||
|
.entry(task.family().to_string())
|
||||||
|
.or_insert_with(SiteReplicationRepairFamilyStatus::default);
|
||||||
|
family_status.planned += 1;
|
||||||
|
family_paths.entry(family).or_default().insert(task.path().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sites = BTreeMap::new();
|
||||||
|
for peer in state.peers.values().filter(|peer| {
|
||||||
|
peer.deployment_id != local_peer.deployment_id && !same_identity_endpoint(&peer.endpoint, &local_peer.endpoint)
|
||||||
|
}) {
|
||||||
|
let mut families = planned.clone();
|
||||||
|
for (_, task) in site_replication_repair_tasks(plan) {
|
||||||
|
let family = families
|
||||||
|
.get_mut(task.family())
|
||||||
|
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair task family is missing".to_string()))?;
|
||||||
|
family.tasks.push(SiteReplicationRepairTaskStatus {
|
||||||
|
task_id: site_replication_repair_task_checkpoint_id(signing_key, &peer.deployment_id, &task)?,
|
||||||
|
status: "planned".to_string(),
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (family, status) in &mut families {
|
||||||
|
status.retry_events = state
|
||||||
|
.retry_queue
|
||||||
|
.iter()
|
||||||
|
.filter(|event| {
|
||||||
|
event.peer_deployment_id == peer.deployment_id
|
||||||
|
&& retry_event_replayed_by_bootstrap(event)
|
||||||
|
&& family_paths.get(family).is_some_and(|paths| paths.contains(&event.path))
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
sites.insert(
|
||||||
|
peer.deployment_id.clone(),
|
||||||
|
SiteReplicationRepairSiteStatus {
|
||||||
|
deployment_id: peer.deployment_id.clone(),
|
||||||
|
name: peer.name.clone(),
|
||||||
|
families,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(sites)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn update_site_replication_repair_task(
|
||||||
|
operation: &mut SiteReplicationRepairOperation,
|
||||||
|
deployment_id: &str,
|
||||||
|
family: &str,
|
||||||
|
family_index: usize,
|
||||||
|
result: Result<(), &str>,
|
||||||
|
) -> S3Result<()> {
|
||||||
|
let site = operation
|
||||||
|
.sites
|
||||||
|
.get_mut(deployment_id)
|
||||||
|
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair operation site is missing".to_string()))?;
|
||||||
|
let family_status = site
|
||||||
|
.families
|
||||||
|
.get_mut(family)
|
||||||
|
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair operation family is missing".to_string()))?;
|
||||||
|
if family_status.succeeded != family_index {
|
||||||
|
return Err(S3Error::with_message(
|
||||||
|
S3ErrorCode::InternalError,
|
||||||
|
"repair operation task checkpoint is invalid".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let task_status = family_status.tasks.get_mut(family_index).ok_or_else(|| {
|
||||||
|
S3Error::with_message(S3ErrorCode::InternalError, "repair operation task checkpoint is missing".to_string())
|
||||||
|
})?;
|
||||||
|
family_status.failed = 0;
|
||||||
|
family_status.errors.clear();
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
family_status.succeeded = family_status.succeeded.saturating_add(1);
|
||||||
|
task_status.status = "succeeded".to_string();
|
||||||
|
task_status.error = None;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let error = classify_site_replication_repair_error(error).to_string();
|
||||||
|
family_status.failed = 1;
|
||||||
|
family_status.errors.push(error.clone());
|
||||||
|
task_status.status = "failed".to_string();
|
||||||
|
task_status.error = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn site_replication_repair_task_pending(
|
||||||
|
operation: &SiteReplicationRepairOperation,
|
||||||
|
deployment_id: &str,
|
||||||
|
family: &str,
|
||||||
|
family_index: usize,
|
||||||
|
) -> S3Result<bool> {
|
||||||
|
let site = operation
|
||||||
|
.sites
|
||||||
|
.get(deployment_id)
|
||||||
|
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair operation site is missing".to_string()))?;
|
||||||
|
let family = site
|
||||||
|
.families
|
||||||
|
.get(family)
|
||||||
|
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair operation family is missing".to_string()))?;
|
||||||
|
if family.succeeded > family_index {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
if family.succeeded < family_index {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
Ok(family.failed == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prepare_site_replication_repair_retry(operation: &mut SiteReplicationRepairOperation) {
|
||||||
|
for family in operation.sites.values_mut().flat_map(|site| site.families.values_mut()) {
|
||||||
|
family.failed = 0;
|
||||||
|
family.errors.clear();
|
||||||
|
for task in &mut family.tasks {
|
||||||
|
match task.status.as_str() {
|
||||||
|
"succeeded" => task.status = "skipped".to_string(),
|
||||||
|
"failed" => {
|
||||||
|
task.status = "planned".to_string();
|
||||||
|
task.error = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn classify_site_replication_repair_error(error: &str) -> &'static str {
|
||||||
|
let error = error.to_ascii_lowercase();
|
||||||
|
if error.contains("accessdenied")
|
||||||
|
|| error.contains("signaturedoesnotmatch")
|
||||||
|
|| error.contains("unauthorized")
|
||||||
|
|| error.contains("forbidden")
|
||||||
|
|| error.contains("401")
|
||||||
|
|| error.contains("403")
|
||||||
|
{
|
||||||
|
"authorization-failed"
|
||||||
|
} else if error.contains("timeout") {
|
||||||
|
"remote-timeout"
|
||||||
|
} else if error.contains("dns") {
|
||||||
|
"remote-dns-failed"
|
||||||
|
} else if error.contains("tls") || error.contains("certificate") {
|
||||||
|
"remote-tls-failed"
|
||||||
|
} else if error.contains("connect") {
|
||||||
|
"remote-connect-failed"
|
||||||
|
} else {
|
||||||
|
"remote-operation-failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn summarize_site_replication_repair_operation(operation: &mut SiteReplicationRepairOperation) {
|
||||||
|
let failed = operation
|
||||||
|
.sites
|
||||||
|
.values()
|
||||||
|
.flat_map(|site| site.families.values())
|
||||||
|
.any(|family| family.failed > 0);
|
||||||
|
let complete = operation
|
||||||
|
.sites
|
||||||
|
.values()
|
||||||
|
.all(|site| site.families.values().all(|family| family.succeeded == family.planned));
|
||||||
|
operation.status = if complete {
|
||||||
|
"success"
|
||||||
|
} else if failed {
|
||||||
|
"partial"
|
||||||
|
} else {
|
||||||
|
"running"
|
||||||
|
}
|
||||||
|
.to_string();
|
||||||
|
operation.updated_at = Some(OffsetDateTime::now_utc());
|
||||||
|
operation.completed_at = complete.then_some(OffsetDateTime::now_utc());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn site_replication_repair_operation_response(
|
||||||
|
operation: &SiteReplicationRepairOperation,
|
||||||
|
) -> SiteReplicationRepairOperationResponse {
|
||||||
|
SiteReplicationRepairOperationResponse {
|
||||||
|
mode: "execute",
|
||||||
|
operation_id: operation.operation_id.clone(),
|
||||||
|
status: operation.status.clone(),
|
||||||
|
sites: operation
|
||||||
|
.sites
|
||||||
|
.iter()
|
||||||
|
.map(|(deployment_id, site)| {
|
||||||
|
(
|
||||||
|
deployment_id.clone(),
|
||||||
|
SiteReplicationRepairSiteResponse {
|
||||||
|
deployment_id: site.deployment_id.clone(),
|
||||||
|
name: site.name.clone(),
|
||||||
|
families: site
|
||||||
|
.families
|
||||||
|
.iter()
|
||||||
|
.map(|(family, status)| {
|
||||||
|
(
|
||||||
|
family.clone(),
|
||||||
|
SiteReplicationRepairFamilyResponse {
|
||||||
|
planned: status.planned,
|
||||||
|
succeeded: status.succeeded,
|
||||||
|
failed: status.failed,
|
||||||
|
retry_events: status.retry_events,
|
||||||
|
tasks: status.tasks.clone(),
|
||||||
|
errors: status.errors.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
created_at: operation.created_at,
|
||||||
|
updated_at: operation.updated_at,
|
||||||
|
completed_at: operation.completed_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prune_site_replication_repair_operations(operations: &mut BTreeMap<String, SiteReplicationRepairOperation>) {
|
||||||
|
while operations.len() > SITE_REPLICATION_REPAIR_OPERATION_LIMIT {
|
||||||
|
let Some(oldest) = operations
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, operation)| operation.status == "success")
|
||||||
|
.min_by_key(|(_, operation)| operation.created_at)
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
operations.remove(&oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn persist_site_replication_repair_operation(operation: &SiteReplicationRepairOperation) -> S3Result<()> {
|
||||||
|
let operation = operation.clone();
|
||||||
|
update_site_replication_repair_state(move |state| {
|
||||||
|
if let Some(existing) = state.operations.get(&operation.operation_id)
|
||||||
|
&& !constant_time_eq(&existing.preflight_token, &operation.preflight_token)
|
||||||
|
{
|
||||||
|
return Err(S3Error::with_message(
|
||||||
|
S3ErrorCode::ClientTokenConflict,
|
||||||
|
"repair operation ID is already bound to a different preflight".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
state.operations.insert(operation.operation_id.clone(), operation);
|
||||||
|
prune_site_replication_repair_operations(&mut state.operations);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn persist_site_replication_repair_task(
|
||||||
|
operation: &SiteReplicationRepairOperation,
|
||||||
|
peer: &PeerInfo,
|
||||||
|
family: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> S3Result<()> {
|
||||||
|
persist_site_replication_repair_operation(operation).await?;
|
||||||
|
|
||||||
|
let family_status = operation
|
||||||
|
.sites
|
||||||
|
.get(&peer.deployment_id)
|
||||||
|
.and_then(|site| site.families.get(family))
|
||||||
|
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair task status is missing".to_string()))?;
|
||||||
|
let failure = (family_status.failed > 0).then(|| {
|
||||||
|
family_status
|
||||||
|
.errors
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "remote-operation-failed".to_string())
|
||||||
|
});
|
||||||
|
let peer = peer.clone();
|
||||||
|
let path = path.to_string();
|
||||||
|
update_site_replication_state(move |state| {
|
||||||
|
match failure.as_deref() {
|
||||||
|
Some(error) => upsert_site_replication_retry_event(&mut state.retry_queue, &peer, &path, error, None),
|
||||||
|
None => {
|
||||||
|
dequeue_site_replication_retry_events_including_escalated(&mut state.retry_queue, &peer, &path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn admit_site_replication_repair_operation(
|
||||||
|
repair_state: &mut SiteReplicationRepairState,
|
||||||
|
operation_id: String,
|
||||||
|
supplied_token: &str,
|
||||||
|
candidate: SiteReplicationRepairOperation,
|
||||||
|
) -> S3Result<SiteReplicationRepairOperation> {
|
||||||
|
if let Some(existing) = repair_state.operations.get(&operation_id) {
|
||||||
|
if !constant_time_eq(&existing.preflight_token, supplied_token) {
|
||||||
|
return Err(S3Error::with_message(
|
||||||
|
S3ErrorCode::ClientTokenConflict,
|
||||||
|
"repair operation ID is already bound to a different preflight".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !constant_time_eq(&existing.plan_token, &candidate.plan_token) {
|
||||||
|
return Err(S3Error::with_message(
|
||||||
|
S3ErrorCode::PreconditionFailed,
|
||||||
|
"site replication repair plan changed after partial execution".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(existing.clone());
|
||||||
|
}
|
||||||
|
if repair_state
|
||||||
|
.operations
|
||||||
|
.values()
|
||||||
|
.any(|operation| operation.status == "running")
|
||||||
|
{
|
||||||
|
return Err(S3Error::with_message(
|
||||||
|
S3ErrorCode::ClientTokenConflict,
|
||||||
|
"another site replication repair is active".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
repair_state.operations.insert(operation_id, candidate.clone());
|
||||||
|
prune_site_replication_repair_operations(&mut repair_state.operations);
|
||||||
|
Ok(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn execute_site_replication_repair(
|
||||||
|
request: SiteReplicationRepairExecutionRequest,
|
||||||
|
) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||||
|
let store =
|
||||||
|
current_object_store_handle().ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?;
|
||||||
|
with_config_object_write_lock(store, SITE_REPLICATION_REPAIR_EXECUTION_LOCK_PATH.to_string(), move || async move {
|
||||||
|
execute_site_replication_repair_locked(request).await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
S3Error::with_message(S3ErrorCode::ClientTokenConflict, "another site replication repair is active".to_string())
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn execute_site_replication_repair_locked(
|
||||||
|
request: SiteReplicationRepairExecutionRequest,
|
||||||
|
) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||||
|
let state = load_site_replication_state().await?;
|
||||||
|
if !state.enabled() || state.service_account_access_key.is_empty() {
|
||||||
|
return Err(s3_error!(InvalidRequest, "site replication is not configured"));
|
||||||
|
}
|
||||||
|
let info = build_sr_info(&state, &request.local_peer).await?;
|
||||||
|
let plan = site_replication_bootstrap_plan(&info)?;
|
||||||
|
let plan_token = site_replication_repair_plan_token(&state, &plan)?;
|
||||||
|
let preflight_token = site_replication_repair_preflight_token(&state, &plan, request.signing_key.as_bytes())?;
|
||||||
|
let sites = site_replication_repair_sites(&state, &request.local_peer, &plan, request.signing_key.as_bytes())?;
|
||||||
|
|
||||||
|
let repair_state = read_site_replication_repair_state().await?;
|
||||||
|
if let Some(existing) = repair_state.operations.get(&request.operation_id) {
|
||||||
|
if !constant_time_eq(&existing.preflight_token, &request.preflight_token) {
|
||||||
|
return Err(S3Error::with_message(
|
||||||
|
S3ErrorCode::ClientTokenConflict,
|
||||||
|
"repair operation ID is already bound to a different preflight".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if existing.status == "success" {
|
||||||
|
return json_response(StatusCode::OK, &site_replication_repair_operation_response(existing));
|
||||||
|
}
|
||||||
|
if !constant_time_eq(&existing.plan_token, &plan_token) {
|
||||||
|
return Err(S3Error::with_message(
|
||||||
|
S3ErrorCode::PreconditionFailed,
|
||||||
|
"site replication repair plan changed after partial execution".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if !constant_time_eq(&request.preflight_token, &preflight_token) {
|
||||||
|
return Err(S3Error::with_message(
|
||||||
|
S3ErrorCode::PreconditionFailed,
|
||||||
|
"site replication repair preflight is stale".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
let candidate = SiteReplicationRepairOperation {
|
||||||
|
operation_id: request.operation_id.clone(),
|
||||||
|
preflight_token,
|
||||||
|
plan_token,
|
||||||
|
status: "running".to_string(),
|
||||||
|
sites,
|
||||||
|
created_at: Some(now),
|
||||||
|
updated_at: Some(now),
|
||||||
|
completed_at: None,
|
||||||
|
};
|
||||||
|
let supplied_token = request.preflight_token;
|
||||||
|
let operation_id = request.operation_id;
|
||||||
|
let mut operation = update_site_replication_repair_state(move |repair_state| {
|
||||||
|
admit_site_replication_repair_operation(repair_state, operation_id, &supplied_token, candidate)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
if operation.status == "success" {
|
||||||
|
return json_response(StatusCode::OK, &site_replication_repair_operation_response(&operation));
|
||||||
|
}
|
||||||
|
|
||||||
|
let service_account_secret_key = site_replicator_service_account_secret(&state.service_account_access_key).await?;
|
||||||
|
prepare_site_replication_repair_retry(&mut operation);
|
||||||
|
operation.status = "running".to_string();
|
||||||
|
operation.completed_at = None;
|
||||||
|
operation.updated_at = Some(OffsetDateTime::now_utc());
|
||||||
|
persist_site_replication_repair_operation(&operation).await?;
|
||||||
|
|
||||||
|
let tasks = site_replication_repair_tasks(&plan);
|
||||||
|
for peer in state.peers.values().filter(|peer| {
|
||||||
|
peer.deployment_id != request.local_peer.deployment_id
|
||||||
|
&& !same_identity_endpoint(&peer.endpoint, &request.local_peer.endpoint)
|
||||||
|
}) {
|
||||||
|
let transport = match PeerTransport::for_runtime_peer(peer).await {
|
||||||
|
Ok(transport) => transport,
|
||||||
|
Err(err) => {
|
||||||
|
let error = err.to_string();
|
||||||
|
for (family_index, task) in &tasks {
|
||||||
|
if !site_replication_repair_task_pending(&operation, &peer.deployment_id, task.family(), *family_index)? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
update_site_replication_repair_task(
|
||||||
|
&mut operation,
|
||||||
|
&peer.deployment_id,
|
||||||
|
task.family(),
|
||||||
|
*family_index,
|
||||||
|
Err(&error),
|
||||||
|
)?;
|
||||||
|
summarize_site_replication_repair_operation(&mut operation);
|
||||||
|
persist_site_replication_repair_task(&operation, peer, task.family(), task.path()).await?;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (family_index, task) in &tasks {
|
||||||
|
if !site_replication_repair_task_pending(&operation, &peer.deployment_id, task.family(), *family_index)? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let result = task
|
||||||
|
.send(&transport, &state.service_account_access_key, &service_account_secret_key)
|
||||||
|
.await;
|
||||||
|
let error = result.err().map(|err| err.to_string());
|
||||||
|
update_site_replication_repair_task(
|
||||||
|
&mut operation,
|
||||||
|
&peer.deployment_id,
|
||||||
|
task.family(),
|
||||||
|
*family_index,
|
||||||
|
match error.as_deref() {
|
||||||
|
Some(error) => Err(error),
|
||||||
|
None => Ok(()),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
summarize_site_replication_repair_operation(&mut operation);
|
||||||
|
persist_site_replication_repair_task(&operation, peer, task.family(), task.path()).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summarize_site_replication_repair_operation(&mut operation);
|
||||||
|
persist_site_replication_repair_operation(&operation).await?;
|
||||||
|
json_response(StatusCode::OK, &site_replication_repair_operation_response(&operation))
|
||||||
|
}
|
||||||
@@ -0,0 +1,928 @@
|
|||||||
|
// Copyright 2024 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(crate) const SITE_REPLICATION_RETRY_QUEUE_LIMIT: usize = 256;
|
||||||
|
|
||||||
|
pub(crate) const SITE_REPLICATION_RETRY_FAILED_AFTER: u32 = 3;
|
||||||
|
|
||||||
|
pub(crate) const SITE_REPLICATION_ENDPOINT_REFRESH_RETRY_PATH: &str = "internal:endpoint-target-refresh";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub(crate) struct SiteReplicationRetryEvent {
|
||||||
|
pub(crate) id: String,
|
||||||
|
pub(crate) peer_deployment_id: String,
|
||||||
|
pub(crate) peer_endpoint: String,
|
||||||
|
pub(crate) path: String,
|
||||||
|
pub(crate) retry_count: u32,
|
||||||
|
pub(crate) failed: bool,
|
||||||
|
pub(crate) last_error: String,
|
||||||
|
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) updated_at: Option<OffsetDateTime>,
|
||||||
|
/// Peer-edit generation whose delivery failed, when the failing send
|
||||||
|
/// carried one. Settling a *later* success for the same (peer, path) must
|
||||||
|
/// not erase a failure recorded for a NEWER generation — see
|
||||||
|
/// [`settle_site_replication_retry_events`].
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) edit_generation: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn retry_event_matches(event: &SiteReplicationRetryEvent, peer: &PeerInfo, path: &str) -> bool {
|
||||||
|
(event.peer_deployment_id == peer.deployment_id || event.peer_endpoint == peer.endpoint) && event.path == path
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH: &str = "internal:retry-snapshot:iam";
|
||||||
|
|
||||||
|
pub(crate) const SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH: &str = "internal:retry-snapshot:bucket-metadata";
|
||||||
|
|
||||||
|
pub(crate) fn collapsed_retry_queue_path(path: &str) -> Option<&'static str> {
|
||||||
|
let base_path = path.split_once('?').map(|(base, _)| base).unwrap_or(path);
|
||||||
|
match base_path {
|
||||||
|
"/rustfs/admin/v3/site-replication/peer/iam-item" | SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH => {
|
||||||
|
Some(SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH)
|
||||||
|
}
|
||||||
|
"/rustfs/admin/v3/site-replication/peer/bucket-meta" | SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH => {
|
||||||
|
Some(SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_collapsed_retry_queue_paths(queue: &mut Vec<SiteReplicationRetryEvent>) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
let mut normalized: Vec<SiteReplicationRetryEvent> = Vec::with_capacity(queue.len());
|
||||||
|
for mut event in queue.drain(..) {
|
||||||
|
if let Some(path) = collapsed_retry_queue_path(&event.path)
|
||||||
|
&& event.path != path
|
||||||
|
{
|
||||||
|
event.path = path.to_string();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let duplicate = normalized.iter().position(|existing| {
|
||||||
|
existing.path == event.path
|
||||||
|
&& (existing.peer_deployment_id == event.peer_deployment_id || existing.peer_endpoint == event.peer_endpoint)
|
||||||
|
});
|
||||||
|
let Some(index) = duplicate else {
|
||||||
|
normalized.push(event);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
let existing = &mut normalized[index];
|
||||||
|
let event_is_newer = match (event.updated_at, existing.updated_at) {
|
||||||
|
(Some(event), Some(existing)) => event >= existing,
|
||||||
|
(Some(_), None) => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if event_is_newer {
|
||||||
|
let retry_count = existing.retry_count.max(event.retry_count);
|
||||||
|
*existing = event;
|
||||||
|
existing.retry_count = retry_count;
|
||||||
|
} else {
|
||||||
|
existing.retry_count = existing.retry_count.max(event.retry_count);
|
||||||
|
}
|
||||||
|
existing.failed = existing.retry_count >= SITE_REPLICATION_RETRY_FAILED_AFTER;
|
||||||
|
}
|
||||||
|
*queue = normalized;
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn migrate_collapsed_retry_queue_paths() -> S3Result<()> {
|
||||||
|
update_site_replication_state_when_changed(|state| {
|
||||||
|
Ok(if normalize_collapsed_retry_queue_paths(&mut state.retry_queue) {
|
||||||
|
StateCommit::Changed(())
|
||||||
|
} else {
|
||||||
|
StateCommit::Unchanged(())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn dequeue_site_replication_retry_events(
|
||||||
|
queue: &mut Vec<SiteReplicationRetryEvent>,
|
||||||
|
peer: &PeerInfo,
|
||||||
|
path: &str,
|
||||||
|
) -> usize {
|
||||||
|
settle_site_replication_retry_events(queue, peer, path, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repair-path settlement: also clears snapshot-escalated entries. Running a
|
||||||
|
/// repair is the operator's explicit accountability transfer for the
|
||||||
|
/// possibly-unreplayed deletion the marker records; ordinary delivery
|
||||||
|
/// successes must not clear it (see [`settle_site_replication_retry_events`]).
|
||||||
|
pub(crate) fn dequeue_site_replication_retry_events_including_escalated(
|
||||||
|
queue: &mut Vec<SiteReplicationRetryEvent>,
|
||||||
|
peer: &PeerInfo,
|
||||||
|
path: &str,
|
||||||
|
) -> usize {
|
||||||
|
let before = queue.len();
|
||||||
|
let collapsed_path = collapsed_retry_queue_path(path);
|
||||||
|
queue.retain(|event| {
|
||||||
|
!retry_event_matches(event, peer, path)
|
||||||
|
&& !collapsed_path.is_some_and(|collapsed_path| retry_event_matches(event, peer, collapsed_path))
|
||||||
|
});
|
||||||
|
before.saturating_sub(queue.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the retry events for (peer, path) that `generation` is entitled to
|
||||||
|
/// settle. A successful delivery only proves the peer reached the state the
|
||||||
|
/// delivery carried: while it was in flight another edit can commit, fail its
|
||||||
|
/// own delivery, and enqueue for the same (peer, path). Erasing that event
|
||||||
|
/// would leave the peer on the older edit with no retry left, so an event
|
||||||
|
/// stamped with a NEWER generation survives. `None` settles unconditionally —
|
||||||
|
/// the broadcast paths that carry no generation, whose retry events live under
|
||||||
|
/// their own paths and never collide with peer-edit deliveries.
|
||||||
|
pub(crate) fn settle_site_replication_retry_events(
|
||||||
|
queue: &mut Vec<SiteReplicationRetryEvent>,
|
||||||
|
peer: &PeerInfo,
|
||||||
|
path: &str,
|
||||||
|
generation: Option<u64>,
|
||||||
|
) -> usize {
|
||||||
|
let before = queue.len();
|
||||||
|
let collapsed_path = collapsed_retry_queue_path(path);
|
||||||
|
queue.retain(|event| {
|
||||||
|
if !retry_event_matches(event, peer, path) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// A wire-path success identifies no IAM or bucket-metadata entity.
|
||||||
|
// This also protects legacy rows until the startup migration moves
|
||||||
|
// them under their internal snapshot path.
|
||||||
|
if collapsed_path.is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// A snapshot-escalated entry records a possibly-unreplayed deletion.
|
||||||
|
// Collapsed paths are shared by every entity, so a later successful
|
||||||
|
// delivery of a DIFFERENT item proves nothing about the deleted one —
|
||||||
|
// only a repair settles it (dequeue_..._including_escalated).
|
||||||
|
if event.last_error == SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match (generation, event.edit_generation) {
|
||||||
|
(Some(settled), Some(failed)) => failed > settled,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
before.saturating_sub(queue.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn upsert_site_replication_retry_event(
|
||||||
|
queue: &mut Vec<SiteReplicationRetryEvent>,
|
||||||
|
peer: &PeerInfo,
|
||||||
|
path: &str,
|
||||||
|
error: &str,
|
||||||
|
generation: Option<u64>,
|
||||||
|
) {
|
||||||
|
let path = collapsed_retry_queue_path(path).unwrap_or(path);
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
let detail = summarize_peer_error_detail(error);
|
||||||
|
if let Some(event) = queue.iter_mut().find(|event| retry_event_matches(event, peer, path)) {
|
||||||
|
event.retry_count = event.retry_count.saturating_add(1);
|
||||||
|
event.failed = event.retry_count >= SITE_REPLICATION_RETRY_FAILED_AFTER;
|
||||||
|
event.last_error = detail;
|
||||||
|
event.updated_at = Some(now);
|
||||||
|
// Keep the newest generation: an older delivery that fails afterwards
|
||||||
|
// must not lower the fence and let its own success settle the event.
|
||||||
|
event.edit_generation = event.edit_generation.max(generation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.push(SiteReplicationRetryEvent {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
peer_deployment_id: peer.deployment_id.clone(),
|
||||||
|
peer_endpoint: peer.endpoint.clone(),
|
||||||
|
path: path.to_string(),
|
||||||
|
retry_count: 1,
|
||||||
|
failed: false,
|
||||||
|
last_error: detail,
|
||||||
|
updated_at: Some(now),
|
||||||
|
edit_generation: generation,
|
||||||
|
});
|
||||||
|
if queue.len() > SITE_REPLICATION_RETRY_QUEUE_LIMIT {
|
||||||
|
let overflow = queue.len() - SITE_REPLICATION_RETRY_QUEUE_LIMIT;
|
||||||
|
queue.drain(0..overflow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn retry_stats_for_state(state: &SiteReplicationState) -> Option<SRRetryStats> {
|
||||||
|
if state.retry_queue.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(SRRetryStats {
|
||||||
|
pending: state.retry_queue.iter().filter(|event| !event.failed).count(),
|
||||||
|
failed: state.retry_queue.iter().filter(|event| event.failed).count(),
|
||||||
|
last_error: state
|
||||||
|
.retry_queue
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find_map(|event| (!event.last_error.is_empty()).then(|| event.last_error.clone()))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn enqueue_site_replication_retry_event(peer: &PeerInfo, path: &str, error: &S3Error) {
|
||||||
|
enqueue_site_replication_retry_event_for_generation(peer, path, error, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn enqueue_site_replication_retry_event_for_generation(
|
||||||
|
peer: &PeerInfo,
|
||||||
|
path: &str,
|
||||||
|
error: &S3Error,
|
||||||
|
generation: Option<u64>,
|
||||||
|
) {
|
||||||
|
let peer_owned = peer.clone();
|
||||||
|
let path_owned = path.to_string();
|
||||||
|
let error_text = error.to_string();
|
||||||
|
let result = update_site_replication_state(move |state| {
|
||||||
|
// A peer that left the state can never drain its entries again
|
||||||
|
// (remove_sites already pruned them); recording a late failure for it
|
||||||
|
// would only pollute retry_stats until the queue cap evicts it.
|
||||||
|
if state.peers.contains_key(&peer_owned.deployment_id) {
|
||||||
|
upsert_site_replication_retry_event(&mut state.retry_queue, &peer_owned, &path_owned, &error_text, generation);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(err) = result {
|
||||||
|
warn!(
|
||||||
|
component = LOG_COMPONENT_ADMIN,
|
||||||
|
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
|
||||||
|
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
|
||||||
|
peer = %peer.endpoint,
|
||||||
|
path,
|
||||||
|
error = ?err,
|
||||||
|
"failed to persist site replication retry event"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn retry_bucket_operation(path: &str) -> Option<String> {
|
||||||
|
let (base_path, query) = path.split_once('?')?;
|
||||||
|
if base_path != SITE_REPLICATION_PEER_BUCKET_OPS_PATH {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
form_urlencoded::parse(query.as_bytes()).find_map(|(key, value)| (key == "operation").then(|| value.into_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn retry_event_replayed_by_bootstrap(event: &SiteReplicationRetryEvent) -> bool {
|
||||||
|
matches!(
|
||||||
|
retry_bucket_operation(&event.path).as_deref(),
|
||||||
|
Some(SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING | SITE_REPLICATION_BUCKET_OP_CONFIGURE_REPLICATION)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exponential backoff base for the background retry drain, aligned with the
|
||||||
|
/// reconcile cadence (`site_replication_reconcile::RECONCILE_INTERVAL`).
|
||||||
|
pub(crate) const SITE_REPLICATION_RETRY_DRAIN_BASE_BACKOFF_SECS: i64 = 600;
|
||||||
|
|
||||||
|
/// Backoff ceiling: a permanently failed peer is still probed daily.
|
||||||
|
pub(crate) const SITE_REPLICATION_RETRY_DRAIN_MAX_BACKOFF_SECS: i64 = 86_400;
|
||||||
|
|
||||||
|
/// What the background drain may do for one retry event. Everything not
|
||||||
|
/// representable here is operator territory (manual repair).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) enum RetryDrainAction {
|
||||||
|
/// Constant-path IAM item deliveries collapse into one queue entry per
|
||||||
|
/// peer and their bodies are not persisted; the only faithful replay is
|
||||||
|
/// the current IAM snapshot from the bootstrap plan.
|
||||||
|
IamSnapshot,
|
||||||
|
/// Same collapse for bucket-meta deliveries: replay the bucket metadata
|
||||||
|
/// snapshot from the bootstrap plan.
|
||||||
|
BucketMetadataSnapshot,
|
||||||
|
/// A self-contained bucket op the bootstrap plan can re-derive for its
|
||||||
|
/// bucket (`make-with-versioning` / `configure-replication`).
|
||||||
|
BucketOpReplay { operation: String, bucket: String },
|
||||||
|
/// Re-send the current peer records under a fresh edit generation.
|
||||||
|
PeerEdit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) enum RetrySnapshot {
|
||||||
|
Iam(Vec<SRIAMItem>),
|
||||||
|
BucketMetadata(Vec<SRBucketMeta>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RetrySnapshot {
|
||||||
|
pub(crate) fn from_plan(action: &RetryDrainAction, plan: &SiteReplicationBootstrapPlan) -> Option<Self> {
|
||||||
|
match action {
|
||||||
|
RetryDrainAction::IamSnapshot => Some(Self::Iam(plan.iam_items.clone())),
|
||||||
|
RetryDrainAction::BucketMetadataSnapshot => Some(Self::BucketMetadata(plan.bucket_items.clone())),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fingerprint(&self) -> S3Result<Vec<Vec<u8>>> {
|
||||||
|
let mut payloads = match self {
|
||||||
|
Self::Iam(items) => items.iter().map(serde_json::to_vec).collect::<Result<Vec<_>, _>>(),
|
||||||
|
Self::BucketMetadata(items) => items.iter().map(serde_json::to_vec).collect::<Result<Vec<_>, _>>(),
|
||||||
|
}
|
||||||
|
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize retry snapshot failed: {err}")))?;
|
||||||
|
payloads.sort_unstable();
|
||||||
|
Ok(payloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn replay_after_change(previous: &Self, fresh: &Self, observed_at: OffsetDateTime) -> Self {
|
||||||
|
match (previous, fresh) {
|
||||||
|
(Self::Iam(previous), Self::Iam(fresh)) => {
|
||||||
|
let fresh_keys: HashSet<IamSnapshotKey> = fresh.iter().filter_map(iam_snapshot_key).collect();
|
||||||
|
let mut replay = fresh.clone();
|
||||||
|
for item in previous {
|
||||||
|
if iam_snapshot_key(item).is_some_and(|key| !fresh_keys.contains(&key)) {
|
||||||
|
replay.extend(iam_snapshot_tombstones(item, observed_at));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::Iam(replay)
|
||||||
|
}
|
||||||
|
(Self::BucketMetadata(previous), Self::BucketMetadata(fresh)) => {
|
||||||
|
let fresh_keys: HashSet<(&str, &str)> = fresh
|
||||||
|
.iter()
|
||||||
|
.map(|item| (item.bucket.as_str(), item.r#type.as_str()))
|
||||||
|
.collect();
|
||||||
|
let mut replay = fresh.clone();
|
||||||
|
for item in previous {
|
||||||
|
if !fresh_keys.contains(&(item.bucket.as_str(), item.r#type.as_str())) {
|
||||||
|
replay.push(bucket_metadata_snapshot_tombstone(item, observed_at));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::BucketMetadata(replay)
|
||||||
|
}
|
||||||
|
_ => fresh.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn send(&self, transport: &PeerTransport, access_key: &str, secret_key: &str) -> S3Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Iam(items) => {
|
||||||
|
for item in items {
|
||||||
|
SiteReplicationRepairTask::Iam(item)
|
||||||
|
.send(transport, access_key, secret_key)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::BucketMetadata(items) => {
|
||||||
|
for item in items {
|
||||||
|
SiteReplicationRepairTask::BucketMetadata(item)
|
||||||
|
.send(transport, access_key, secret_key)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Hash, PartialEq, Eq)]
|
||||||
|
pub(crate) enum IamSnapshotKey {
|
||||||
|
Policy(String),
|
||||||
|
User(String),
|
||||||
|
Group(String),
|
||||||
|
PolicyMapping { target: String, user_type: i64, is_group: bool },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn iam_snapshot_key(item: &SRIAMItem) -> Option<IamSnapshotKey> {
|
||||||
|
match item.r#type.as_str() {
|
||||||
|
"policy" => Some(IamSnapshotKey::Policy(item.name.clone())),
|
||||||
|
"iam-user" => item
|
||||||
|
.iam_user
|
||||||
|
.as_ref()
|
||||||
|
.map(|user| IamSnapshotKey::User(user.access_key.clone())),
|
||||||
|
"group-info" => item
|
||||||
|
.group_info
|
||||||
|
.as_ref()
|
||||||
|
.map(|group| IamSnapshotKey::Group(group.update_req.group.clone())),
|
||||||
|
"policy-mapping" => item.policy_mapping.as_ref().map(|mapping| IamSnapshotKey::PolicyMapping {
|
||||||
|
target: mapping.user_or_group.clone(),
|
||||||
|
user_type: mapping.user_type,
|
||||||
|
is_group: mapping.is_group,
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn iam_snapshot_tombstones(item: &SRIAMItem, observed_at: OffsetDateTime) -> Vec<SRIAMItem> {
|
||||||
|
let mut tombstone = item.clone();
|
||||||
|
tombstone.updated_at = Some(observed_at);
|
||||||
|
match item.r#type.as_str() {
|
||||||
|
"policy" => tombstone.policy = None,
|
||||||
|
"iam-user" => {
|
||||||
|
if let Some(user) = tombstone.iam_user.as_mut() {
|
||||||
|
user.is_delete_req = true;
|
||||||
|
user.user_req = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"group-info" => {
|
||||||
|
let Some(group) = tombstone.group_info.as_mut() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
group.update_req.is_remove = true;
|
||||||
|
if group.update_req.members.is_empty() {
|
||||||
|
return vec![tombstone];
|
||||||
|
}
|
||||||
|
let mut delete = tombstone.clone();
|
||||||
|
if let Some(group) = delete.group_info.as_mut() {
|
||||||
|
group.update_req.members.clear();
|
||||||
|
}
|
||||||
|
return vec![tombstone, delete];
|
||||||
|
}
|
||||||
|
"policy-mapping" => {
|
||||||
|
if let Some(mapping) = tombstone.policy_mapping.as_mut() {
|
||||||
|
mapping.policy.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Vec::new(),
|
||||||
|
}
|
||||||
|
vec![tombstone]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn bucket_metadata_snapshot_tombstone(item: &SRBucketMeta, observed_at: OffsetDateTime) -> SRBucketMeta {
|
||||||
|
SRBucketMeta {
|
||||||
|
r#type: item.r#type.clone(),
|
||||||
|
bucket: item.bucket.clone(),
|
||||||
|
updated_at: Some(observed_at),
|
||||||
|
expiry_updated_at: Some(observed_at),
|
||||||
|
api_version: item.api_version.clone(),
|
||||||
|
derived_rule_contract: item.derived_rule_contract,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const SITE_REPLICATION_RETRY_SNAPSHOT_STABILITY_ATTEMPTS: usize = 3;
|
||||||
|
|
||||||
|
pub(crate) fn classify_site_replication_retry_event(event: &SiteReplicationRetryEvent) -> Option<RetryDrainAction> {
|
||||||
|
let snapshot_action = match event.path.as_str() {
|
||||||
|
SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH => Some(RetryDrainAction::IamSnapshot),
|
||||||
|
SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH => Some(RetryDrainAction::BucketMetadataSnapshot),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if snapshot_action.is_some() && event.last_error != SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER {
|
||||||
|
return snapshot_action;
|
||||||
|
}
|
||||||
|
if event.path.starts_with("internal:") {
|
||||||
|
// Marker records store payloads in `last_error` (legacy
|
||||||
|
// pending-endpoint-refresh backup and snapshot liabilities); they are
|
||||||
|
// not drainable delivery failures.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if event.last_error == SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER {
|
||||||
|
// Already snapshot-replayed once for this failure episode; a possible
|
||||||
|
// deletion cannot be replayed from a snapshot, so re-sending daily
|
||||||
|
// proves nothing. A new hook failure overwrites the marker.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let base_path = event.path.split_once('?').map(|(base, _)| base).unwrap_or(&event.path);
|
||||||
|
match base_path {
|
||||||
|
"/rustfs/admin/v3/site-replication/peer/iam-item" => Some(RetryDrainAction::IamSnapshot),
|
||||||
|
"/rustfs/admin/v3/site-replication/peer/bucket-meta" => Some(RetryDrainAction::BucketMetadataSnapshot),
|
||||||
|
SITE_REPLICATION_PEER_EDIT_PATH => Some(RetryDrainAction::PeerEdit),
|
||||||
|
SITE_REPLICATION_PEER_BUCKET_OPS_PATH => {
|
||||||
|
let operation = retry_bucket_operation(&event.path)?;
|
||||||
|
if !matches!(
|
||||||
|
operation.as_str(),
|
||||||
|
SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING | SITE_REPLICATION_BUCKET_OP_CONFIGURE_REPLICATION
|
||||||
|
) {
|
||||||
|
// Destructive ops (delete-bucket / force-delete-bucket) are
|
||||||
|
// operator territory: replaying them against a peer whose
|
||||||
|
// bucket was since recreated is irreversible.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bucket = retry_bucket_name(&event.path)?;
|
||||||
|
Some(RetryDrainAction::BucketOpReplay { operation, bucket })
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn retry_bucket_name(path: &str) -> Option<String> {
|
||||||
|
let (_, query) = path.split_once('?')?;
|
||||||
|
form_urlencoded::parse(query.as_bytes())
|
||||||
|
.find_map(|(key, value)| (key == "bucket" && !value.is_empty()).then(|| value.into_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A collapsed retry event after a stable snapshot resend is escalated with
|
||||||
|
/// this marker instead of being cleared: the snapshot contains no task for a
|
||||||
|
/// failed deletion, so remote absence remains operator-visible. Collapsed
|
||||||
|
/// failures use an internal queue path so ordinary successes and older nodes
|
||||||
|
/// cannot settle an unrelated entity's liability.
|
||||||
|
pub(crate) const SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER: &str = "snapshot replayed; a failed deletion cannot be replayed from a snapshot — run site replication repair or re-deliver to settle";
|
||||||
|
|
||||||
|
/// Escalate a collapsed retry event after its snapshot resend succeeded,
|
||||||
|
/// unless a newer failure was recorded after `snapshot_updated_at` (that
|
||||||
|
/// failure belongs to a newer local commit the snapshot did not contain and
|
||||||
|
/// must keep the entry drain-eligible).
|
||||||
|
pub(crate) fn escalate_site_replication_retry_events_up_to(
|
||||||
|
queue: &mut Vec<SiteReplicationRetryEvent>,
|
||||||
|
peer: &PeerInfo,
|
||||||
|
path: &str,
|
||||||
|
snapshot_updated_at: Option<OffsetDateTime>,
|
||||||
|
) -> usize {
|
||||||
|
let Some(marker_path) = collapsed_retry_queue_path(path) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
if path != marker_path {
|
||||||
|
queue.retain(|event| {
|
||||||
|
if !retry_event_matches(event, peer, path) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
matches!((event.updated_at, snapshot_updated_at), (Some(current), Some(seen)) if current > seen)
|
||||||
|
|| matches!((event.updated_at, snapshot_updated_at), (Some(_), None))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let marker_index = queue.iter().position(|event| retry_event_matches(event, peer, marker_path));
|
||||||
|
let marker_index = marker_index.unwrap_or_else(|| {
|
||||||
|
queue.push(SiteReplicationRetryEvent {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
peer_deployment_id: peer.deployment_id.clone(),
|
||||||
|
peer_endpoint: peer.endpoint.clone(),
|
||||||
|
path: marker_path.to_string(),
|
||||||
|
updated_at: snapshot_updated_at,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
queue.len() - 1
|
||||||
|
});
|
||||||
|
let event = &mut queue[marker_index];
|
||||||
|
let newer_failure_recorded = match (event.updated_at, snapshot_updated_at) {
|
||||||
|
(Some(current), Some(seen)) => current > seen,
|
||||||
|
(Some(_), None) => true,
|
||||||
|
(None, _) => false,
|
||||||
|
};
|
||||||
|
if newer_failure_recorded && event.last_error != SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
event.failed = true;
|
||||||
|
event.retry_count = event.retry_count.max(SITE_REPLICATION_RETRY_FAILED_AFTER);
|
||||||
|
event.last_error = SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER.to_string();
|
||||||
|
event.updated_at = Some(OffsetDateTime::now_utc());
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn escalate_site_replication_retry_event_up_to(
|
||||||
|
peer: &PeerInfo,
|
||||||
|
path: &str,
|
||||||
|
snapshot_updated_at: Option<OffsetDateTime>,
|
||||||
|
) {
|
||||||
|
let peer_owned = peer.clone();
|
||||||
|
let path_owned = path.to_string();
|
||||||
|
let result = update_site_replication_state(move |state| {
|
||||||
|
escalate_site_replication_retry_events_up_to(&mut state.retry_queue, &peer_owned, &path_owned, snapshot_updated_at);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(err) = result {
|
||||||
|
warn!(
|
||||||
|
component = LOG_COMPONENT_ADMIN,
|
||||||
|
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
|
||||||
|
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
|
||||||
|
peer = %peer.endpoint,
|
||||||
|
deployment_id = %peer.deployment_id,
|
||||||
|
path,
|
||||||
|
error = ?err,
|
||||||
|
"failed to escalate site replication retry event"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the drain may attempt this event now.
|
||||||
|
pub(crate) fn site_replication_retry_backoff_elapsed(event: &SiteReplicationRetryEvent, now: OffsetDateTime) -> bool {
|
||||||
|
let Some(updated_at) = event.updated_at else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
// 600 * 2^8 already exceeds the daily ceiling; capping the shift keeps
|
||||||
|
// the arithmetic overflow-free for any persisted retry_count.
|
||||||
|
let exponent = event.retry_count.saturating_sub(1).min(8);
|
||||||
|
let delay = (SITE_REPLICATION_RETRY_DRAIN_BASE_BACKOFF_SECS << exponent).min(SITE_REPLICATION_RETRY_DRAIN_MAX_BACKOFF_SECS);
|
||||||
|
now.unix_timestamp().saturating_sub(updated_at.unix_timestamp()) >= delay
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The subset of the retry queue the background drain is allowed to touch.
|
||||||
|
pub(crate) fn actionable_site_replication_retry_events(
|
||||||
|
state: &SiteReplicationState,
|
||||||
|
now: OffsetDateTime,
|
||||||
|
) -> Vec<SiteReplicationRetryEvent> {
|
||||||
|
state
|
||||||
|
.retry_queue
|
||||||
|
.iter()
|
||||||
|
.filter(|event| classify_site_replication_retry_event(event).is_some())
|
||||||
|
.filter(|event| state.peers.contains_key(&event.peer_deployment_id))
|
||||||
|
.filter(|event| site_replication_retry_backoff_elapsed(event, now))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background consumer for the retry queue, run from the reconcile tick.
|
||||||
|
///
|
||||||
|
/// Scope: this settles "delivered once and failed" entries whose replay is
|
||||||
|
/// faithful (bucket ops, peer edits). Collapsed iam-item / bucket-meta
|
||||||
|
/// entries are snapshot-resent and then *escalated*, not cleared — a failed
|
||||||
|
/// deletion leaves no task in the snapshot, so remote absence stays unproven
|
||||||
|
/// until a later delivery or a manual repair. A hook that never fired (crash
|
||||||
|
/// between the local commit and the send) leaves no entry at all, so the
|
||||||
|
/// drain is not a full cross-site diff-heal; manual repair remains the
|
||||||
|
/// authoritative catch-all.
|
||||||
|
pub(crate) async fn drain_site_replication_retry_queue() {
|
||||||
|
if let Err(err) = drain_site_replication_retry_queue_inner().await {
|
||||||
|
warn!(
|
||||||
|
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
|
||||||
|
component = LOG_COMPONENT_ADMIN,
|
||||||
|
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
|
||||||
|
result = "retry_drain_failed",
|
||||||
|
error = ?err,
|
||||||
|
"admin site replication state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn drain_site_replication_retry_queue_inner() -> S3Result<()> {
|
||||||
|
let Some(runtime) = runtime_site_replication_targets().await? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let actionable = actionable_site_replication_retry_events(&runtime.state, OffsetDateTime::now_utc());
|
||||||
|
if actionable.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let Some(store) = current_object_store_handle() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if runtime.state.pending_endpoint_refresh.is_some()
|
||||||
|
|| runtime.state.pending_remove.is_some()
|
||||||
|
|| runtime.state.pending_rotation.is_some()
|
||||||
|
{
|
||||||
|
// The tick-level gate ran before the reconcilers; a multi-step flow
|
||||||
|
// (endpoint refresh commits its pending marker without the lifecycle
|
||||||
|
// guard) may have started since. Re-check on the fresh state.
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Serialize against operator repair execution. This does NOT close the
|
||||||
|
// dry-run -> execute window (dry-run takes no lock): a drain settling a
|
||||||
|
// replayable bucket-op entry in that window changes the preflight token
|
||||||
|
// and execute fails safe with "preflight is stale" — the operator
|
||||||
|
// re-runs the dry-run. Lock order matches repair: lifecycle guard (held
|
||||||
|
// by the reconcile tick) -> repair execution lock -> state object lock
|
||||||
|
// inside the send bookkeeping. An operator repair holding the lock makes
|
||||||
|
// this tick skip after the lock-acquire timeout.
|
||||||
|
with_config_object_write_lock(store, SITE_REPLICATION_REPAIR_EXECUTION_LOCK_PATH.to_string(), move || async move {
|
||||||
|
drain_site_replication_retry_queue_locked(runtime, actionable).await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::from)?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn drain_site_replication_retry_queue_locked(
|
||||||
|
runtime: SiteReplicationRuntime,
|
||||||
|
events: Vec<SiteReplicationRetryEvent>,
|
||||||
|
) -> S3Result<()> {
|
||||||
|
let needs_plan = events
|
||||||
|
.iter()
|
||||||
|
.any(|event| !matches!(classify_site_replication_retry_event(event), Some(RetryDrainAction::PeerEdit)));
|
||||||
|
// The plan is a full local snapshot (buckets + IAM); build it once per
|
||||||
|
// tick and only when a snapshot resend is actually due.
|
||||||
|
let plan = if needs_plan {
|
||||||
|
let info = build_sr_info(&runtime.state, &runtime.local_peer).await?;
|
||||||
|
Some(site_replication_bootstrap_plan(&info)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut events_by_peer: BTreeMap<String, Vec<SiteReplicationRetryEvent>> = BTreeMap::new();
|
||||||
|
for event in events {
|
||||||
|
events_by_peer
|
||||||
|
.entry(event.peer_deployment_id.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut settled = 0usize;
|
||||||
|
let mut failures = 0usize;
|
||||||
|
for (deployment_id, peer_events) in events_by_peer {
|
||||||
|
let Some(peer) = runtime.state.peers.get(&deployment_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if deployment_id == runtime.local_peer.deployment_id
|
||||||
|
|| same_identity_endpoint(&peer.endpoint, &runtime.local_peer.endpoint)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let transport = match PeerTransport::for_runtime_peer(peer).await {
|
||||||
|
Ok(transport) => transport,
|
||||||
|
Err(err) => {
|
||||||
|
// Record the attempt so backoff advances for an unreachable
|
||||||
|
// peer instead of re-dialing it every tick.
|
||||||
|
for event in &peer_events {
|
||||||
|
enqueue_site_replication_retry_event(peer, &event.path, &err).await;
|
||||||
|
}
|
||||||
|
failures += peer_events.len();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for event in peer_events {
|
||||||
|
let Some(action) = classify_site_replication_retry_event(&event) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match drain_one_site_replication_retry_event(&runtime, peer, &transport, &event, action, plan.as_ref()).await {
|
||||||
|
Ok(true) => settled += 1,
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(_) => failures += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if settled > 0 || failures > 0 {
|
||||||
|
info!(
|
||||||
|
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
|
||||||
|
component = LOG_COMPONENT_ADMIN,
|
||||||
|
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
|
||||||
|
result = "retry_drain_settled",
|
||||||
|
settled,
|
||||||
|
failures,
|
||||||
|
"admin site replication state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replay one retry event against its peer. Returns `Ok(true)` when the
|
||||||
|
/// event was settled (delivered, or provably stale), `Ok(false)` when it was
|
||||||
|
/// skipped, and `Err` after a failed delivery (already re-queued with an
|
||||||
|
/// incremented retry count).
|
||||||
|
pub(crate) async fn drain_one_site_replication_retry_event(
|
||||||
|
runtime: &SiteReplicationRuntime,
|
||||||
|
peer: &PeerInfo,
|
||||||
|
transport: &PeerTransport,
|
||||||
|
event: &SiteReplicationRetryEvent,
|
||||||
|
action: RetryDrainAction,
|
||||||
|
plan: Option<&SiteReplicationBootstrapPlan>,
|
||||||
|
) -> S3Result<bool> {
|
||||||
|
let access_key = &runtime.state.service_account_access_key;
|
||||||
|
let secret_key = &runtime.service_account_secret_key;
|
||||||
|
match action.clone() {
|
||||||
|
RetryDrainAction::IamSnapshot | RetryDrainAction::BucketMetadataSnapshot => {
|
||||||
|
let Some(plan) = plan else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let mut current_snapshot = RetrySnapshot::from_plan(&action, plan).expect("snapshot action has a snapshot");
|
||||||
|
let mut replay = current_snapshot.clone();
|
||||||
|
for _ in 0..SITE_REPLICATION_RETRY_SNAPSHOT_STABILITY_ATTEMPTS {
|
||||||
|
let current_fingerprint = current_snapshot.fingerprint()?;
|
||||||
|
if let Err(err) = replay.send(transport, access_key, secret_key).await {
|
||||||
|
enqueue_site_replication_retry_event(peer, &event.path, &err).await;
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
let fresh_info = build_sr_info(&runtime.state, &runtime.local_peer).await?;
|
||||||
|
let fresh_plan = site_replication_bootstrap_plan(&fresh_info)?;
|
||||||
|
let fresh_snapshot = RetrySnapshot::from_plan(&action, &fresh_plan).expect("snapshot action has a snapshot");
|
||||||
|
if fresh_snapshot.fingerprint()? == current_fingerprint {
|
||||||
|
escalate_site_replication_retry_event_up_to(peer, &event.path, event.updated_at).await;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
replay = RetrySnapshot::replay_after_change(¤t_snapshot, &fresh_snapshot, OffsetDateTime::now_utc());
|
||||||
|
current_snapshot = fresh_snapshot;
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
RetryDrainAction::BucketOpReplay { operation, bucket } => {
|
||||||
|
let Some(plan) = plan else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
// Replay from the CURRENT plan, never the recorded path: the
|
||||||
|
// recorded query can carry an expired one-shot bootstrap token or
|
||||||
|
// a stale createdAt.
|
||||||
|
let make_op = operation == SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING;
|
||||||
|
let paths = if make_op {
|
||||||
|
&plan.bucket_make_ops
|
||||||
|
} else {
|
||||||
|
&plan.bucket_configure_ops
|
||||||
|
};
|
||||||
|
let tasks: Vec<SiteReplicationRepairTask<'_>> = paths
|
||||||
|
.iter()
|
||||||
|
.filter(|path| retry_bucket_name(path).as_deref() == Some(bucket.as_str()))
|
||||||
|
.map(|path| {
|
||||||
|
if make_op {
|
||||||
|
SiteReplicationRepairTask::BucketMake(path)
|
||||||
|
} else {
|
||||||
|
SiteReplicationRepairTask::Replication(path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if tasks.is_empty() {
|
||||||
|
// The bucket left the plan (deleted, or replication no longer
|
||||||
|
// configured): the recorded intent is stale, settle it.
|
||||||
|
dequeue_site_replication_retry_event(peer, &event.path).await;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
for task in &tasks {
|
||||||
|
if let Err(err) = task.send(transport, access_key, secret_key).await {
|
||||||
|
enqueue_site_replication_retry_event(peer, &event.path, &err).await;
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dequeue_site_replication_retry_event(peer, &event.path).await;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
RetryDrainAction::PeerEdit => {
|
||||||
|
// The recorded generation is stale by definition — the receiver
|
||||||
|
// fences it. Allocate a fresh generation and re-send the current
|
||||||
|
// peer records (a superset of the failed body; the receiver
|
||||||
|
// upserts), all inside one state transaction so the fence and the
|
||||||
|
// bodies agree.
|
||||||
|
let target_id = peer.deployment_id.clone();
|
||||||
|
let (generation, bodies) = update_site_replication_state(move |state| {
|
||||||
|
if !state.peers.contains_key(&target_id) {
|
||||||
|
return Ok((None, Vec::new()));
|
||||||
|
}
|
||||||
|
Ok((Some(next_peer_edit_generation(state)), state.peers.values().cloned().collect::<Vec<_>>()))
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let Some(generation) = generation else {
|
||||||
|
// Peer left between the snapshot and now; the queue entry was
|
||||||
|
// already pruned by remove_sites.
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let local_deployment_id = Some(runtime.local_peer.deployment_id.as_str()).filter(|id| !id.is_empty());
|
||||||
|
let edit_path = peer_edit_path_with_fence(local_deployment_id, generation);
|
||||||
|
let delivery_fence = local_deployment_id.is_some().then_some(generation);
|
||||||
|
for body in &bodies {
|
||||||
|
if let Err(err) = send_peer_admin_request_with_client(
|
||||||
|
&transport.client,
|
||||||
|
&transport.connection,
|
||||||
|
&edit_path,
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
enqueue_site_replication_retry_event_for_generation(
|
||||||
|
peer,
|
||||||
|
SITE_REPLICATION_PEER_EDIT_PATH,
|
||||||
|
&err,
|
||||||
|
delivery_fence,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dequeue_site_replication_retry_event_for_generation(peer, SITE_REPLICATION_PEER_EDIT_PATH, delivery_fence).await;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a retry event for (peer, path) from the queue on successful delivery.
|
||||||
|
/// This is a no-op (load + no-op persist skipped) when no matching entry exists,
|
||||||
|
/// avoiding unnecessary I/O on the common path.
|
||||||
|
pub(crate) async fn dequeue_site_replication_retry_event(peer: &PeerInfo, path: &str) {
|
||||||
|
dequeue_site_replication_retry_event_for_generation(peer, path, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn dequeue_site_replication_retry_event_for_generation(peer: &PeerInfo, path: &str, generation: Option<u64>) {
|
||||||
|
let result = async {
|
||||||
|
// Fast path: this sits on every successful hook broadcast, so probe
|
||||||
|
// with a plain read first and only enter the locked RMW on a hit
|
||||||
|
// (the transaction re-checks under the lock).
|
||||||
|
let mut probe = load_site_replication_state().await?;
|
||||||
|
if settle_site_replication_retry_events(&mut probe.retry_queue, peer, path, generation) == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let peer_owned = peer.clone();
|
||||||
|
let path_owned = path.to_string();
|
||||||
|
update_site_replication_state(move |state| {
|
||||||
|
settle_site_replication_retry_events(&mut state.retry_queue, &peer_owned, &path_owned, generation);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok::<_, S3Error>(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(err) = result {
|
||||||
|
warn!(
|
||||||
|
component = LOG_COMPONENT_ADMIN,
|
||||||
|
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
|
||||||
|
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
|
||||||
|
peer = %peer.endpoint,
|
||||||
|
deployment_id = %peer.deployment_id,
|
||||||
|
path,
|
||||||
|
error = ?err,
|
||||||
|
"failed to dequeue site replication retry event"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
// Copyright 2024 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(crate) const SITE_REPLICATION_PEER_EDIT_PATH: &str = "/rustfs/admin/v3/site-replication/peer/edit";
|
||||||
|
|
||||||
|
/// Peer-edit fencing token, carried as query parameters so a peer that predates
|
||||||
|
/// the fence simply ignores them (unknown query keys are dropped) and keeps the
|
||||||
|
/// previous last-writer-wins behaviour.
|
||||||
|
pub(crate) const SITE_REPLICATION_EDIT_ORIGIN_QUERY: &str = "editOrigin";
|
||||||
|
|
||||||
|
pub(crate) const SITE_REPLICATION_EDIT_GENERATION_QUERY: &str = "editGeneration";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub(crate) struct SiteReplicationState {
|
||||||
|
pub(crate) name: String,
|
||||||
|
pub(crate) service_account_access_key: String,
|
||||||
|
#[serde(default, skip_serializing)]
|
||||||
|
pub(crate) service_account_secret_key: String,
|
||||||
|
pub(crate) service_account_parent: String,
|
||||||
|
pub(crate) peers: BTreeMap<String, PeerInfo>,
|
||||||
|
pub(crate) updated_at: Option<OffsetDateTime>,
|
||||||
|
pub(crate) resync_status: BTreeMap<String, SRResyncOpStatus>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) pending_rotation: Option<PendingRotation>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) pending_remove: Option<PendingRemove>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) pending_endpoint_refresh: Option<PendingEndpointRefresh>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub(crate) retry_queue: Vec<SiteReplicationRetryEvent>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) sync_state_initialized: bool,
|
||||||
|
/// Fencing token for peer-edit delivery, allocated inside the state
|
||||||
|
/// transaction (the distributed state-object lock). Two nodes of THIS
|
||||||
|
/// site that accept admin edits concurrently therefore get strictly
|
||||||
|
/// ordered generations, and a delivery that stalls can be recognised as
|
||||||
|
/// stale by the receiving site.
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) edit_generation: u64,
|
||||||
|
/// Per-origin high-water mark of the peer edits already applied here,
|
||||||
|
/// keyed by the origin site's deployment id. A delivery whose generation
|
||||||
|
/// is not above the mark arrived out of order and must not overwrite the
|
||||||
|
/// newer edit that already landed.
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub(crate) applied_edit_generations: BTreeMap<String, u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub(crate) struct PendingEndpointRefresh {
|
||||||
|
pub(crate) id: String,
|
||||||
|
pub(crate) peer: PeerInfo,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub(crate) remote_peers: BTreeMap<String, PeerInfo>,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||||
|
pub(crate) acked_deployment_ids: BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub(crate) struct PendingRotation {
|
||||||
|
pub(crate) id: String,
|
||||||
|
pub(crate) access_key: String,
|
||||||
|
pub(crate) parent: String,
|
||||||
|
pub(crate) new_secret_key: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub(crate) secret_candidates: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub(crate) peers: BTreeMap<String, PeerInfo>,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||||
|
pub(crate) acked_deployment_ids: BTreeSet<String>,
|
||||||
|
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) updated_at: Option<OffsetDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub(crate) struct PendingRemove {
|
||||||
|
pub(crate) id: String,
|
||||||
|
pub(crate) req: SRRemoveReq,
|
||||||
|
pub(crate) service_account_access_key: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub(crate) secret_candidates: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub(crate) original_peers: BTreeMap<String, PeerInfo>,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||||
|
pub(crate) acked_deployment_ids: BTreeSet<String>,
|
||||||
|
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) updated_at: Option<OffsetDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SiteReplicationState {
|
||||||
|
pub(crate) fn enabled(&self) -> bool {
|
||||||
|
self.peers.len() > 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_site_replication_state(data: &[u8]) -> S3Result<SiteReplicationState> {
|
||||||
|
let mut state: SiteReplicationState = serde_json::from_slice(data)
|
||||||
|
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("invalid site replication state: {e}")))?;
|
||||||
|
state.peers = normalize_peer_map_by_identity(state.peers);
|
||||||
|
// A peer-edit high-water mark only fences a CURRENT peer. A site that
|
||||||
|
// leaves drops below two peers, which clears its own state object and
|
||||||
|
// restarts its generation counter — a mark left over from the previous
|
||||||
|
// membership must not reject the edits it sends after it rejoins. This
|
||||||
|
// pruning covers departures THIS site observed; an origin removed
|
||||||
|
// unilaterally elsewhere stays in this peer map with its mark, and the
|
||||||
|
// wall-clock floor in `next_peer_edit_generation` is what lifts its
|
||||||
|
// restarted counter over that mark. Dropping departed origins on load
|
||||||
|
// also keeps the map bounded.
|
||||||
|
state
|
||||||
|
.applied_edit_generations
|
||||||
|
.retain(|origin, _| state.peers.contains_key(origin));
|
||||||
|
if !state.sync_state_initialized {
|
||||||
|
if state.enabled() {
|
||||||
|
mark_unknown_peer_sync_enabled(&mut state.peers);
|
||||||
|
}
|
||||||
|
state.sync_state_initialized = true;
|
||||||
|
}
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn load_site_replication_state() -> S3Result<SiteReplicationState> {
|
||||||
|
let Some(store) = current_object_store_handle() else {
|
||||||
|
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||||
|
};
|
||||||
|
|
||||||
|
match read_admin_config(store, SITE_REPLICATION_STATE_PATH).await {
|
||||||
|
Ok(data) => parse_site_replication_state(&data),
|
||||||
|
Err(StorageError::ConfigNotFound) => Ok(SiteReplicationState::default()),
|
||||||
|
Err(err) => Err(S3Error::with_message(
|
||||||
|
S3ErrorCode::InternalError,
|
||||||
|
format!("failed to load site replication state: {err}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this deployment participates in site replication (two or more
|
||||||
|
/// peers in the persisted state). Read by the S3 interface layer to gate
|
||||||
|
/// replication-config edits (MinIO `ErrReplicationDenyEditError` semantics,
|
||||||
|
/// issue #1948); a state-read failure propagates so the gate fails closed.
|
||||||
|
pub(crate) async fn site_replication_enabled() -> S3Result<bool> {
|
||||||
|
Ok(load_site_replication_state().await?.enabled())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deployment ids of the remote peers the reconciler derives a
|
||||||
|
/// `site-repl-<id>` rule for on every bucket (the same peer filter as
|
||||||
|
/// `build_site_replication_config`); empty when site replication is not
|
||||||
|
/// enabled. Read by the bucket usecase so an S3 replication-config edit keeps
|
||||||
|
/// exactly the reconciler-owned rules (issue #1948); a state-read failure
|
||||||
|
/// propagates so the edit fails closed.
|
||||||
|
pub(crate) async fn site_replication_edit_context() -> S3Result<(HashSet<String>, OperatorRuleContract)> {
|
||||||
|
let Some(runtime) = runtime_site_replication_targets().await? else {
|
||||||
|
// Enabled without a service account is a state this site cannot
|
||||||
|
// broadcast from either; the peers are still the reconciler's.
|
||||||
|
let state = load_site_replication_state().await?;
|
||||||
|
if !state.enabled() {
|
||||||
|
return Ok((HashSet::new(), OperatorRuleContract::Derived));
|
||||||
|
}
|
||||||
|
let peers = remote_peer_deployment_ids(&state, ¤t_local_runtime_peer(&state));
|
||||||
|
return Ok((peers, OperatorRuleContract::Legacy));
|
||||||
|
};
|
||||||
|
let peers = remote_peer_deployment_ids(&runtime.state, &runtime.local_peer);
|
||||||
|
let contract = site_replication_operator_rule_contract(&runtime).await;
|
||||||
|
Ok((peers, contract))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether every remote peer merges replication configs under the derived
|
||||||
|
/// contract, probed through the peer capability endpoint. A peer that does
|
||||||
|
/// not (or cannot be asked) pins the cluster to [`OperatorRuleContract::Legacy`]
|
||||||
|
/// for this edit: consistency across sites wins over keeping the operator's
|
||||||
|
/// priority values, and the legacy merge keeps their order anyway.
|
||||||
|
pub(crate) async fn site_replication_operator_rule_contract(runtime: &SiteReplicationRuntime) -> OperatorRuleContract {
|
||||||
|
let remote_peers: Vec<&PeerInfo> = runtime
|
||||||
|
.state
|
||||||
|
.peers
|
||||||
|
.values()
|
||||||
|
.filter(|peer| {
|
||||||
|
peer.deployment_id != runtime.local_peer.deployment_id
|
||||||
|
&& !same_identity_endpoint(&peer.endpoint, &runtime.local_peer.endpoint)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let probes = futures::future::join_all(remote_peers.iter().map(|peer| async move {
|
||||||
|
let transport = PeerTransport::for_runtime_peer(peer).await?;
|
||||||
|
let (status, body) = send_peer_admin_request_raw_with_client(
|
||||||
|
&transport.client,
|
||||||
|
&transport.connection,
|
||||||
|
SITE_REPLICATION_PEER_DERIVED_RULE_CONTRACT_CAPABILITY_PATH,
|
||||||
|
&runtime.state.service_account_access_key,
|
||||||
|
&runtime.service_account_secret_key,
|
||||||
|
&(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
peer_capability_response_supported(peer, status, &body)
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
operator_rule_contract_from_probes(remote_peers.into_iter().zip(probes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn operator_rule_contract_from_probes<'a>(
|
||||||
|
probes: impl IntoIterator<Item = (&'a PeerInfo, S3Result<bool>)>,
|
||||||
|
) -> OperatorRuleContract {
|
||||||
|
for (peer, probe) in probes {
|
||||||
|
match probe {
|
||||||
|
Ok(true) => {}
|
||||||
|
Ok(false) => return OperatorRuleContract::Legacy,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
|
||||||
|
component = LOG_COMPONENT_ADMIN,
|
||||||
|
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
|
||||||
|
result = "derived_rule_contract_probe_failed",
|
||||||
|
peer = %peer.endpoint,
|
||||||
|
error = %err,
|
||||||
|
"admin site replication state"
|
||||||
|
);
|
||||||
|
return OperatorRuleContract::Legacy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OperatorRuleContract::Derived
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn remote_peer_deployment_ids(state: &SiteReplicationState, local_peer: &PeerInfo) -> HashSet<String> {
|
||||||
|
state
|
||||||
|
.peers
|
||||||
|
.values()
|
||||||
|
.filter(|peer| {
|
||||||
|
peer.deployment_id != local_peer.deployment_id && !same_identity_endpoint(&peer.endpoint, &local_peer.endpoint)
|
||||||
|
})
|
||||||
|
.map(|peer| peer.deployment_id.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deployment ids of every site in the cluster, this one included: the set
|
||||||
|
/// a peer's derived rules can name (its rule towards this site carries this
|
||||||
|
/// site's id). Empty when site replication is not enabled.
|
||||||
|
pub(crate) async fn site_replication_deployment_ids() -> S3Result<HashSet<String>> {
|
||||||
|
let state = load_site_replication_state().await?;
|
||||||
|
if !state.enabled() {
|
||||||
|
return Ok(HashSet::new());
|
||||||
|
}
|
||||||
|
Ok(state.peers.values().map(|peer| peer.deployment_id.clone()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn load_site_replication_state_no_lock(store: Arc<ECStore>) -> S3Result<SiteReplicationState> {
|
||||||
|
match read_config_no_lock(store, SITE_REPLICATION_STATE_PATH).await {
|
||||||
|
Ok(data) => parse_site_replication_state(&data),
|
||||||
|
Err(StorageError::ConfigNotFound) => Ok(SiteReplicationState::default()),
|
||||||
|
Err(err) => Err(S3Error::with_message(
|
||||||
|
S3ErrorCode::InternalError,
|
||||||
|
format!("failed to load site replication state: {err}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist-or-clear under an already-held state object lock. Normalizes the
|
||||||
|
/// peer map exactly once (the historical persist path normalized twice with
|
||||||
|
/// two full clones — P2-22).
|
||||||
|
pub(crate) async fn persist_site_replication_state_no_lock(store: Arc<ECStore>, mut state: SiteReplicationState) -> S3Result<()> {
|
||||||
|
state.peers = normalize_peer_map_by_identity(state.peers);
|
||||||
|
if state.peers.len() <= 1 && state.pending_rotation.is_none() && state.pending_remove.is_none() {
|
||||||
|
match delete_config_no_lock(store, SITE_REPLICATION_STATE_PATH).await {
|
||||||
|
Ok(()) | Err(StorageError::ConfigNotFound) => Ok(()),
|
||||||
|
Err(err) => Err(S3Error::with_message(S3ErrorCode::InternalError, format!("clear state failed: {err}"))),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let data = serde_json::to_vec(&state)
|
||||||
|
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize state failed: {e}")))?;
|
||||||
|
save_config_no_lock(store, SITE_REPLICATION_STATE_PATH, data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("save state failed: {e}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a state transaction closure decided to do with the state it was
|
||||||
|
/// handed. `Unchanged` skips the write entirely: the ack markers and the
|
||||||
|
/// pending-clearing paths run on every retry and mostly find their pending id
|
||||||
|
/// already gone, and the retry queue shares this object — rewriting it byte
|
||||||
|
/// for byte only makes those misses contend with the writers that do have
|
||||||
|
/// something to say.
|
||||||
|
pub(crate) enum StateCommit<T> {
|
||||||
|
Changed(T),
|
||||||
|
Unchanged(T),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The site-replication state RMW transaction: load, mutate, persist — all
|
||||||
|
/// under the distributed state-object write lock (see
|
||||||
|
/// crate::site_replication::state_lock). No peer network calls and no other
|
||||||
|
/// config locks inside `update`; anything that has to talk to a peer belongs
|
||||||
|
/// between two transactions, with the precondition re-checked inside the
|
||||||
|
/// second one.
|
||||||
|
pub(crate) async fn update_site_replication_state<T, F>(update: F) -> S3Result<T>
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
F: FnOnce(&mut SiteReplicationState) -> S3Result<T> + Send + 'static,
|
||||||
|
{
|
||||||
|
update_site_replication_state_when_changed(move |state| update(state).map(StateCommit::Changed)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`update_site_replication_state`] for closures that may find nothing to
|
||||||
|
/// do — see [`StateCommit`].
|
||||||
|
pub(crate) async fn update_site_replication_state_when_changed<T, F>(update: F) -> S3Result<T>
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
F: FnOnce(&mut SiteReplicationState) -> S3Result<StateCommit<T>> + Send + 'static,
|
||||||
|
{
|
||||||
|
with_site_replication_state_lock(move || async move {
|
||||||
|
let store = current_object_store_handle()
|
||||||
|
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?;
|
||||||
|
let mut state = load_site_replication_state_no_lock(store.clone()).await?;
|
||||||
|
match update(&mut state)? {
|
||||||
|
StateCommit::Changed(result) => {
|
||||||
|
persist_site_replication_state_no_lock(store, state).await?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
StateCommit::Unchanged(result) => Ok(result),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-only seeding of the state object. Every production write goes through
|
||||||
|
/// [`update_site_replication_state`] — this helper is `cfg(test)` so a new
|
||||||
|
/// call site cannot reintroduce the pre-P1-15 shape (load through one object
|
||||||
|
/// lock, save through another, with the mutation in between unprotected).
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) async fn save_site_replication_state(state: &SiteReplicationState) -> S3Result<()> {
|
||||||
|
let Some(store) = current_object_store_handle() else {
|
||||||
|
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut normalized = state.clone();
|
||||||
|
normalized.peers = normalize_peer_map_by_identity(normalized.peers);
|
||||||
|
|
||||||
|
let data = serde_json::to_vec(&normalized)
|
||||||
|
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize state failed: {e}")))?;
|
||||||
|
save_admin_config(store, SITE_REPLICATION_STATE_PATH, data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("save state failed: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn request_endpoint(uri: &Uri, headers: &HeaderMap) -> String {
|
||||||
|
let scheme = get_source_scheme(headers)
|
||||||
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_ascii_lowercase)
|
||||||
|
})
|
||||||
|
.or_else(|| uri.scheme_str().map(str::to_ascii_lowercase))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
if runtime_tls_enabled() {
|
||||||
|
"https".to_string()
|
||||||
|
} else {
|
||||||
|
"http".to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let host = headers
|
||||||
|
.get(http::header::HOST)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.or_else(|| uri.authority().map(|value| value.as_str().to_string()))
|
||||||
|
.or_else(|| {
|
||||||
|
current_endpoints_handle().and_then(|endpoints| {
|
||||||
|
endpoints
|
||||||
|
.as_ref()
|
||||||
|
.iter()
|
||||||
|
.flat_map(|pool| pool.endpoints.as_ref().iter())
|
||||||
|
.find(|endpoint| endpoint.is_local)
|
||||||
|
.map(|endpoint| endpoint.host_port())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| format!("127.0.0.1:{}", current_runtime_port()));
|
||||||
|
|
||||||
|
format!("{scheme}://{host}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn runtime_console_port() -> Option<u16> {
|
||||||
|
let console_address = get_config_snapshot()
|
||||||
|
.map(|snapshot| snapshot.console_address.clone())
|
||||||
|
.unwrap_or_else(|| rustfs_utils::get_env_str(ENV_RUSTFS_CONSOLE_ADDRESS, DEFAULT_CONSOLE_ADDRESS));
|
||||||
|
|
||||||
|
let parse_target = if console_address.starts_with(':') {
|
||||||
|
format!("127.0.0.1{console_address}")
|
||||||
|
} else {
|
||||||
|
console_address
|
||||||
|
};
|
||||||
|
|
||||||
|
Url::parse(&format!("http://{parse_target}"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|parsed| parsed.port_or_known_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn site_replication_local_endpoint(uri: &Uri, headers: &HeaderMap) -> String {
|
||||||
|
let endpoint = request_endpoint(uri, headers);
|
||||||
|
match Url::parse(&endpoint) {
|
||||||
|
Ok(mut parsed) => {
|
||||||
|
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
|
||||||
|
return request_endpoint(&Uri::from_static("/"), &HeaderMap::new());
|
||||||
|
}
|
||||||
|
if parsed.port_or_known_default() == runtime_console_port() && parsed.set_port(Some(current_runtime_port())).is_ok() {
|
||||||
|
parsed.to_string().trim_end_matches('/').to_string()
|
||||||
|
} else {
|
||||||
|
endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => request_endpoint(&Uri::from_static("/"), &HeaderMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn current_local_runtime_endpoint() -> String {
|
||||||
|
site_replication_local_endpoint(&Uri::from_static("/"), &HeaderMap::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn infer_site_name(endpoint: &str) -> String {
|
||||||
|
endpoint
|
||||||
|
.trim_start_matches("http://")
|
||||||
|
.trim_start_matches("https://")
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.split(':')
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stored_peer_tls_settings(stored_peer: Option<&PeerInfo>) -> (bool, String) {
|
||||||
|
stored_peer
|
||||||
|
.map(|peer| (peer.skip_tls_verify, peer.ca_cert_pem.clone()))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local peer record as the given state describes it. Split out of
|
||||||
|
/// [`current_local_peer`] so a state transaction can rebuild it against the
|
||||||
|
/// state it just loaded: the request the endpoint came from cannot cross into
|
||||||
|
/// the transaction closure, but the endpoint itself can.
|
||||||
|
pub(crate) fn local_peer_at_endpoint(endpoint: String, state: &SiteReplicationState) -> PeerInfo {
|
||||||
|
let deployment_id = current_deployment_id().unwrap_or_else(|| deployment_id_for_endpoint(&endpoint));
|
||||||
|
let stored_peer = state.peers.get(&deployment_id);
|
||||||
|
let (skip_tls_verify, ca_cert_pem) = stored_peer_tls_settings(stored_peer);
|
||||||
|
|
||||||
|
PeerInfo {
|
||||||
|
endpoint: endpoint.clone(),
|
||||||
|
name: if state.name.is_empty() {
|
||||||
|
stored_peer
|
||||||
|
.map(|peer| peer.name.clone())
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.unwrap_or_else(|| infer_site_name(&endpoint))
|
||||||
|
} else {
|
||||||
|
state.name.clone()
|
||||||
|
},
|
||||||
|
deployment_id,
|
||||||
|
sync_state: stored_peer.map(|peer| peer.sync_state.clone()).unwrap_or(SyncStatus::Unknown),
|
||||||
|
default_bandwidth: stored_peer.map(|peer| peer.default_bandwidth.clone()).unwrap_or_default(),
|
||||||
|
replicate_ilm_expiry: stored_peer.is_some_and(|peer| peer.replicate_ilm_expiry),
|
||||||
|
object_naming_mode: stored_peer.map(|peer| peer.object_naming_mode.clone()).unwrap_or_default(),
|
||||||
|
skip_tls_verify,
|
||||||
|
ca_cert_pem,
|
||||||
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn current_local_runtime_peer(state: &SiteReplicationState) -> PeerInfo {
|
||||||
|
local_peer_at_endpoint(current_local_runtime_endpoint(), state)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_peer_map_by_identity(peers: BTreeMap<String, PeerInfo>) -> BTreeMap<String, PeerInfo> {
|
||||||
|
normalize_peer_map_by_identity_with(peers, normalize_peer_info)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_peer_info(mut peer: PeerInfo) -> PeerInfo {
|
||||||
|
if peer.deployment_id.is_empty() {
|
||||||
|
peer.deployment_id = deployment_id_for_endpoint(&peer.endpoint);
|
||||||
|
}
|
||||||
|
if peer.name.is_empty() {
|
||||||
|
peer.name = infer_site_name(&peer.endpoint);
|
||||||
|
}
|
||||||
|
if peer.api_version.is_none() {
|
||||||
|
peer.api_version = Some(SITE_REPL_API_VERSION.to_string());
|
||||||
|
}
|
||||||
|
peer
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn site_replicator_service_account_secret(access_key: &str) -> S3Result<String> {
|
||||||
|
let Some(iam_sys) = current_iam_handle() else {
|
||||||
|
return Err(s3_error!(InvalidRequest, "iam not init"));
|
||||||
|
};
|
||||||
|
|
||||||
|
iam_sys
|
||||||
|
.get_site_replicator_service_account_secret(access_key)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::from)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn legacy_site_replicator_state_secret(state: &SiteReplicationState) -> Option<String> {
|
||||||
|
(state.service_account_access_key == SITE_REPLICATOR_SERVICE_ACCOUNT && !state.service_account_secret_key.is_empty())
|
||||||
|
.then(|| state.service_account_secret_key.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn pending_endpoint_refresh(state: &SiteReplicationState) -> Option<PendingEndpointRefresh> {
|
||||||
|
state.pending_endpoint_refresh.clone().or_else(|| {
|
||||||
|
state
|
||||||
|
.retry_queue
|
||||||
|
.iter()
|
||||||
|
.find(|event| event.path == SITE_REPLICATION_ENDPOINT_REFRESH_RETRY_PATH)
|
||||||
|
.and_then(|event| serde_json::from_str(&event.last_error).ok())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wall clock in unix nanoseconds, clamped into u64. A pre-1970 (or
|
||||||
|
/// post-2554) clock yields 0, which makes the hybrid allocation below
|
||||||
|
/// degrade to the plain `previous + 1` counter — monotone, never panicking.
|
||||||
|
pub(crate) fn edit_generation_wall_clock() -> u64 {
|
||||||
|
u64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate the next peer-edit generation as a hybrid logical clock:
|
||||||
|
/// `max(wall clock in unix nanoseconds, previous + 1)`. Called inside the
|
||||||
|
/// state transaction, so the value is handed out under the distributed
|
||||||
|
/// state-object lock and two nodes of this site can never take the same one
|
||||||
|
/// (`previous + 1` keeps the sequence strictly increasing even when two
|
||||||
|
/// allocations land in one clock tick, and keeps it monotone on a node
|
||||||
|
/// whose clock stepped backwards mid-lifetime).
|
||||||
|
///
|
||||||
|
/// The wall-clock floor is what survives the counter's death. A site
|
||||||
|
/// removed while unreachable — the receiver never dropped it from its peer
|
||||||
|
/// map, so the load-time mark pruning in `parse_site_replication_state`
|
||||||
|
/// never fired — that later rejoins recreates its state object with the
|
||||||
|
/// counter back at zero. A plain counter would then hand out generations
|
||||||
|
/// below the receiver's stale high-water mark and every delivery would be
|
||||||
|
/// silently fenced until the counter caught up. Jumping to wall time clears
|
||||||
|
/// that mark: every value the deleted lifetime handed out was capped by the
|
||||||
|
/// wall clock at its own allocation (or by a prior lifetime's cap, applied
|
||||||
|
/// inductively), so the recreated lifetime's first allocation exceeds them
|
||||||
|
/// all — while a pre-removal delivery still in flight stays below the new
|
||||||
|
/// floor and remains correctly fenced. Marks recorded by pre-hybrid
|
||||||
|
/// receivers (small plain-counter values) sit far below any wall-clock
|
||||||
|
/// value, so a restarted origin passes those too — the fix needs only the
|
||||||
|
/// sender upgraded, nothing on the wire or in the receiver changed.
|
||||||
|
///
|
||||||
|
/// A wall clock that regresses across a delete/recreate (the recreating
|
||||||
|
/// node's clock behind the clock that fed the previous lifetime) mints
|
||||||
|
/// below the stale mark and the origin stays fenced — but only until real
|
||||||
|
/// time passes the previous lifetime's last allocation, because every later
|
||||||
|
/// allocation takes the wall-clock floor again (and never longer than
|
||||||
|
/// [`PEER_EDIT_FENCE_STALENESS_WINDOW_NANOS`]: a regression past the window
|
||||||
|
/// leaves the mark implausibly distant and the origin runs unfenced
|
||||||
|
/// immediately). Bounded by the skew,
|
||||||
|
/// self-healing, and no rollback window beyond the plain counter's: a
|
||||||
|
/// delivery applies only at or above the receiver's mark, so the one
|
||||||
|
/// cross-lifetime interleaving that can apply stale content — a
|
||||||
|
/// pre-removal delivery whose generation lands above everything the
|
||||||
|
/// regressed new lifetime has minted — required the same straggler landing
|
||||||
|
/// above the mark under the plain counter, where the recreated counter's
|
||||||
|
/// low restart made it strictly easier to hit.
|
||||||
|
pub(crate) fn next_peer_edit_generation(state: &mut SiteReplicationState) -> u64 {
|
||||||
|
state.edit_generation = edit_generation_wall_clock().max(state.edit_generation.saturating_add(1));
|
||||||
|
state.edit_generation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the peer-edit request path carrying the fencing token. The bare
|
||||||
|
/// constant stays the retry-queue key: the query only fences the wire
|
||||||
|
/// delivery, and a per-generation key would make every retry event unique.
|
||||||
|
/// Without a local deployment id there is nothing to fence against, so the
|
||||||
|
/// unstamped path is sent and the receiver keeps its pre-fence behaviour.
|
||||||
|
pub(crate) fn peer_edit_path_with_fence(origin: Option<&str>, generation: u64) -> String {
|
||||||
|
let Some(origin) = origin.filter(|origin| !origin.is_empty()) else {
|
||||||
|
return SITE_REPLICATION_PEER_EDIT_PATH.to_string();
|
||||||
|
};
|
||||||
|
let query = form_urlencoded::Serializer::new(String::new())
|
||||||
|
.append_pair(SITE_REPLICATION_EDIT_ORIGIN_QUERY, origin)
|
||||||
|
.append_pair(SITE_REPLICATION_EDIT_GENERATION_QUERY, &generation.to_string())
|
||||||
|
.finish();
|
||||||
|
format!("{SITE_REPLICATION_PEER_EDIT_PATH}?{query}")
|
||||||
|
}
|
||||||
+3
-4
@@ -34,12 +34,11 @@
|
|||||||
//! Lock order: lifecycle -> bucket operation -> repair admission
|
//! Lock order: lifecycle -> bucket operation -> repair admission
|
||||||
//! -> state object lock -> per-bucket metadata.
|
//! -> state object lock -> per-bucket metadata.
|
||||||
|
|
||||||
use crate::admin::storage_api::runtime::ECStore;
|
use super::{S3Error, S3ErrorCode, S3Result};
|
||||||
use crate::admin::storage_api::s3::{S3Error, S3ErrorCode, S3Result};
|
use crate::storage_api::site_replication::{ECStore, with_config_object_write_lock};
|
||||||
use crate::storage::storage_api::with_config_object_write_lock;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use super::runtime_sources::current_object_store_handle;
|
use crate::runtime_sources::current_object_store_handle;
|
||||||
|
|
||||||
/// Config object holding the whole site-replication state, including the
|
/// Config object holding the whole site-replication state, including the
|
||||||
/// retry-event queue. Shared by the typed handler-side accessors and the
|
/// retry-event queue. Shared by the typed handler-side accessors and the
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -216,6 +216,54 @@ pub(crate) mod server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Storage surface of the site-replication service module
|
||||||
|
/// (`crate::site_replication`, backlog#1840): bucket metadata, bucket
|
||||||
|
/// targets, replication-config primitives, and the config-object lock
|
||||||
|
/// helpers its state transaction runs on.
|
||||||
|
pub(crate) mod site_replication {
|
||||||
|
pub(crate) use super::storage_contracts::{BucketOperations, BucketOptions};
|
||||||
|
pub(crate) use crate::storage::storage_api::ecstore_bucket::bucket_target_sys::BucketTargetSys;
|
||||||
|
pub(crate) use crate::storage::storage_api::ecstore_bucket::metadata::{
|
||||||
|
BUCKET_REPLICATION_CONFIG, BUCKET_TARGETS_FILE, BUCKET_VERSIONING_CONFIG, BucketMetadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) use crate::storage::storage_api::ecstore_bucket::replication::{
|
||||||
|
OperatorRuleContract, assign_site_replication_rule_priorities, is_site_replication_role,
|
||||||
|
replication_target_arn_deployment_id, site_replication_rule_deployment_id,
|
||||||
|
};
|
||||||
|
pub(crate) use crate::storage::storage_api::ecstore_bucket::target::{
|
||||||
|
ARN, BucketTarget, BucketTargetType, BucketTargets, Credentials,
|
||||||
|
};
|
||||||
|
pub(crate) use crate::storage::storage_api::ecstore_bucket::utils::{deserialize, serialize};
|
||||||
|
pub(crate) use crate::storage::storage_api::ecstore_bucket::versioning::VersioningApi;
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) use crate::storage::storage_api::ecstore_config::com::save_config;
|
||||||
|
|
||||||
|
pub(crate) use crate::storage::storage_api::{
|
||||||
|
ECStore, EndpointServerPools, StorageError, delete_config_no_lock, lock_bucket_targets_metadata, read_config,
|
||||||
|
read_config_no_lock, save_config_no_lock, with_config_object_read_lock, with_config_object_write_lock,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) mod metadata_sys {
|
||||||
|
pub(crate) use crate::storage::storage_api::ecstore_bucket::metadata_sys::{
|
||||||
|
capture_bucket_metadata_incarnation, get, get_replication_config, get_versioning_config, list_bucket_targets,
|
||||||
|
update_if_incarnation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// S3 wire types for the service module, funneled here so the module
|
||||||
|
/// itself stays off the direct s3s surface (s3s footprint ratchet).
|
||||||
|
pub(crate) mod s3 {
|
||||||
|
pub(crate) use s3s::dto::{
|
||||||
|
BucketLifecycleConfiguration, BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus,
|
||||||
|
DeleteReplication, DeleteReplicationStatus, Destination, ExistingObjectReplication, ExistingObjectReplicationStatus,
|
||||||
|
LifecycleRule, ReplicaModifications, ReplicaModificationsStatus, ReplicationConfiguration, ReplicationRule,
|
||||||
|
ReplicationRuleStatus, SourceSelectionCriteria, VersioningConfiguration,
|
||||||
|
};
|
||||||
|
pub(crate) use s3s::{Body, S3Error, S3ErrorCode, S3Response, S3Result, s3_error};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) mod startup {
|
pub(crate) mod startup {
|
||||||
pub(crate) mod heal_control {
|
pub(crate) mod heal_control {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ checked_files=(
|
|||||||
"rustfs/src/admin/handlers/kms_keys.rs"
|
"rustfs/src/admin/handlers/kms_keys.rs"
|
||||||
"rustfs/src/admin/handlers/kms_key_lifecycle.rs"
|
"rustfs/src/admin/handlers/kms_key_lifecycle.rs"
|
||||||
"rustfs/src/admin/handlers/site_replication.rs"
|
"rustfs/src/admin/handlers/site_replication.rs"
|
||||||
|
"rustfs/src/site_replication/mod.rs"
|
||||||
|
"rustfs/src/site_replication/identity.rs"
|
||||||
|
"rustfs/src/site_replication/state_lock.rs"
|
||||||
|
"rustfs/src/site_replication/state.rs"
|
||||||
|
"rustfs/src/site_replication/transport.rs"
|
||||||
|
"rustfs/src/site_replication/retry.rs"
|
||||||
|
"rustfs/src/site_replication/repair.rs"
|
||||||
|
"rustfs/src/site_replication/hooks.rs"
|
||||||
"rustfs/src/admin/handlers/group.rs"
|
"rustfs/src/admin/handlers/group.rs"
|
||||||
"rustfs/src/admin/handlers/quota.rs"
|
"rustfs/src/admin/handlers/quota.rs"
|
||||||
"rustfs/src/admin/handlers/rebalance.rs"
|
"rustfs/src/admin/handlers/rebalance.rs"
|
||||||
|
|||||||
@@ -38,8 +38,14 @@ cd "$(dirname "$0")/.."
|
|||||||
# files, zero new s3s code — the same handler-layer surface redistributed).
|
# files, zero new s3s code — the same handler-layer surface redistributed).
|
||||||
# The file counter is split-sensitive; the s3_error! line counter confirms
|
# The file counter is split-sensitive; the s3_error! line counter confirms
|
||||||
# no growth (unchanged at 1620).
|
# no growth (unchanged at 1620).
|
||||||
|
# 1620 → 1616 on 2026-08-27: backlog#1840 moved the site-replication service
|
||||||
|
# subsystem to rustfs/src/site_replication/ (s3s access funneled through the
|
||||||
|
# root storage facade's s3 shim, keeping the file count at 215). The move
|
||||||
|
# inlined one s3_error! call in transport.rs (+1); measured 1615 on the
|
||||||
|
# pre-move main (after #6694) and 1616 after, so the slack 1620 baseline is
|
||||||
|
# retightened to the measured 1616.
|
||||||
S3S_IMPORT_FILES_BASELINE=215
|
S3S_IMPORT_FILES_BASELINE=215
|
||||||
S3_ERROR_LINES_BASELINE=1620
|
S3_ERROR_LINES_BASELINE=1616
|
||||||
# ecstore-scoped ratchet (rustfs/backlog#1842): the storage engine must not
|
# ecstore-scoped ratchet (rustfs/backlog#1842): the storage engine must not
|
||||||
# know S3 wire/DTO types (ARCHITECTURE.md invariant 4). The S3-*consuming*
|
# know S3 wire/DTO types (ARCHITECTURE.md invariant 4). The S3-*consuming*
|
||||||
# client was extracted to crates/s3-client, where s3s usage is legitimate;
|
# client was extracted to crates/s3-client, where s3s usage is legitimate;
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ cycle|app<->infra
|
|||||||
cycle|app<->interface
|
cycle|app<->interface
|
||||||
cycle|infra<->interface
|
cycle|infra<->interface
|
||||||
dep|rustfs/src/app/admin_usecase.rs|app->interface|crate::server::collect_dependency_readiness_report
|
dep|rustfs/src/app/admin_usecase.rs|app->interface|crate::server::collect_dependency_readiness_report
|
||||||
dep|rustfs/src/app/bucket_usecase.rs|app->interface|crate::admin::handlers::site_replication::site_replication_bucket_meta_hook
|
|
||||||
dep|rustfs/src/app/bucket_usecase.rs|app->interface|crate::admin::handlers::site_replication::site_replication_delete_bucket_hook
|
|
||||||
dep|rustfs/src/app/bucket_usecase.rs|app->interface|crate::admin::handlers::site_replication::site_replication_make_bucket_hook
|
|
||||||
dep|rustfs/src/cluster_snapshot.rs|infra->interface|crate::server::snapshot_dependency_readiness_report
|
dep|rustfs/src/cluster_snapshot.rs|infra->interface|crate::server::snapshot_dependency_readiness_report
|
||||||
dep|rustfs/src/runtime_sources.rs|infra->app|crate::app::context
|
dep|rustfs/src/runtime_sources.rs|infra->app|crate::app::context
|
||||||
dep|rustfs/src/storage/ecfs_extend.rs|infra->interface|crate::server::cors
|
dep|rustfs/src/storage/ecfs_extend.rs|infra->interface|crate::server::cors
|
||||||
|
|||||||
Reference in New Issue
Block a user