mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-06 03:59:14 +00:00
bbd7b9ef17
* fix(site-replication): wake retry drain after peer recovery * fix(site-replication): replay configure after bucket make * fix(site-replication): serialize retry replay state * fix(site-replication): persist destructive retry intents * fix(site-replication): bound retry recovery rounds * fix(site-replication): keep recovery replay live * fix(site-replication): preserve retry ordering * fix(site-replication): bound retry coordination * fix(site-replication): serialize topology replay * fix(site-replication): fence distributed retry state * fix(site-replication): bound outage retry drain * fix(site-replication): drop unsafe delete retry intents * fix(site-replication): order bucket mutation replay * fix(site-replication): harden outage retry replay * fix(site-replication): fence destructive peer delivery * fix(site-replication): avoid peer edit retry deadlock * fix(site-replication): fence retry error classification * fix(site-replication): classify connect timeouts * fix(site-replication): close recovery review races * test(site-replication): cover timeout endpoint text * fix(site-replication): close destructive recovery gaps * fix(site-replication): fence recovery revisions * fix(site-replication): replay bucket metadata on recovery * fix(site-replication): preserve s3gate boundary --------- Co-authored-by: overtrue <anzhengchao@gmail.com>
1586 lines
63 KiB
Rust
1586 lines
63 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use super::*;
|
|
|
|
pub(crate) const SITE_REPLICATION_PEER_BUCKET_OPS_PATH: &str = "/rustfs/admin/v3/site-replication/peer/bucket-ops";
|
|
|
|
pub(crate) const SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING: &str = "make-with-versioning";
|
|
|
|
pub(crate) const SITE_REPLICATION_BUCKET_OP_CONFIGURE_REPLICATION: &str = "configure-replication";
|
|
|
|
pub(crate) static SITE_REPLICATION_BUCKET_OP_LOCK: LazyLock<RwLock<()>> = LazyLock::new(|| RwLock::new(()));
|
|
|
|
const SITE_REPLICATION_BUCKET_MUTATION_LOCK_PREFIX: &str = "config/site-replication/bucket-mutation";
|
|
pub(crate) const SITE_REPLICATION_BUCKET_MUTATION_ADMISSION_LOCK_PATH: &str =
|
|
"config/site-replication/bucket-mutation-admission.lock";
|
|
|
|
pub(crate) fn site_replication_bucket_mutation_lock_path(bucket: &str) -> String {
|
|
format!("{SITE_REPLICATION_BUCKET_MUTATION_LOCK_PREFIX}/{bucket}.lock")
|
|
}
|
|
|
|
pub(crate) async fn with_site_replication_bucket_mutation_lock<F, Fut, T>(
|
|
store: Arc<ECStore>,
|
|
bucket: &str,
|
|
operation: F,
|
|
) -> S3Result<T>
|
|
where
|
|
F: FnOnce() -> Fut + Send + 'static,
|
|
Fut: std::future::Future<Output = T> + Send + 'static,
|
|
T: Send + 'static,
|
|
{
|
|
let mutation_store = store.clone();
|
|
let mutation_path = site_replication_bucket_mutation_lock_path(bucket);
|
|
with_config_object_read_lock(
|
|
store,
|
|
SITE_REPLICATION_BUCKET_MUTATION_ADMISSION_LOCK_PATH.to_string(),
|
|
move || async move {
|
|
with_config_object_write_lock(mutation_store, mutation_path, operation)
|
|
.await
|
|
.map_err(|err| S3Error::from(ApiError::from(err)))
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|err| S3Error::from(ApiError::from(err)))?
|
|
}
|
|
|
|
/// Exclude every local bucket namespace mutation from an add's local preflight
|
|
/// snapshot until its topology commit. Peer bootstrap callbacks do not enter
|
|
/// this public-mutation admission path, so they can finish while the writer is
|
|
/// held; post-commit fan-out and backfill must run after it is released.
|
|
pub(crate) async fn with_site_replication_bucket_mutation_admission_lock<F, Fut, T>(
|
|
store: Arc<ECStore>,
|
|
operation: F,
|
|
) -> S3Result<T>
|
|
where
|
|
F: FnOnce() -> Fut + Send + 'static,
|
|
Fut: std::future::Future<Output = S3Result<T>> + Send + 'static,
|
|
T: Send + 'static,
|
|
{
|
|
with_config_object_write_lock(store, SITE_REPLICATION_BUCKET_MUTATION_ADMISSION_LOCK_PATH.to_string(), operation)
|
|
.await
|
|
.map_err(|err| S3Error::from(ApiError::from(err)))?
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub(crate) struct SiteReplicationBootstrapPlan {
|
|
pub(crate) iam_items: Vec<SRIAMItem>,
|
|
pub(crate) bucket_make_ops: Vec<String>,
|
|
pub(crate) bucket_items: Vec<SRBucketMeta>,
|
|
pub(crate) bucket_configure_ops: Vec<String>,
|
|
}
|
|
|
|
pub(crate) fn bootstrap_bucket_op_path(bucket: &str, operation: &str) -> String {
|
|
format!(
|
|
"/rustfs/admin/v3/site-replication/peer/bucket-ops?{}",
|
|
form_urlencoded::Serializer::new(String::new())
|
|
.append_pair("bucket", bucket)
|
|
.append_pair("operation", operation)
|
|
.finish()
|
|
)
|
|
}
|
|
|
|
pub(crate) fn with_site_replication_bootstrap_token(path: &str, token: &str) -> String {
|
|
let separator = if path.contains('?') { '&' } else { '?' };
|
|
let query = form_urlencoded::Serializer::new(String::new())
|
|
.append_pair("bootstrapToken", token)
|
|
.finish();
|
|
format!("{path}{separator}{query}")
|
|
}
|
|
|
|
/// Query for a peer `make-with-versioning` bucket op. `versioningEnabled`
|
|
/// always travels so the outbound query matches MinIO's site-replication
|
|
/// make-bucket wire contract: MinIO's own create-bucket hook sends
|
|
/// `versioningEnabled=true` on this op. RustFS's inbound handler
|
|
/// force-enables versioning either way.
|
|
pub(crate) fn make_with_versioning_bucket_op_path(bucket: &str, created_at: Option<&str>, lock_enabled: bool) -> String {
|
|
let mut query = form_urlencoded::Serializer::new(String::new());
|
|
query.append_pair("bucket", bucket);
|
|
query.append_pair("operation", SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING);
|
|
query.append_pair("versioningEnabled", "true");
|
|
if let Some(created_at) = created_at {
|
|
query.append_pair("createdAt", created_at);
|
|
}
|
|
if lock_enabled {
|
|
query.append_pair("lockEnabled", "true");
|
|
}
|
|
format!("{SITE_REPLICATION_PEER_BUCKET_OPS_PATH}?{}", query.finish())
|
|
}
|
|
|
|
pub(crate) fn bootstrap_bucket_make_op_path(bucket: &SRBucketInfo) -> String {
|
|
let created_at = bucket
|
|
.created_at
|
|
.and_then(|value| value.format(&time::format_description::well_known::Rfc3339).ok());
|
|
make_with_versioning_bucket_op_path(&bucket.bucket, created_at.as_deref(), bucket.object_lock_config.is_some())
|
|
}
|
|
|
|
pub(crate) fn bootstrap_bucket_meta_item(
|
|
bucket: &SRBucketInfo,
|
|
item_type: &str,
|
|
updated_at: Option<OffsetDateTime>,
|
|
) -> SRBucketMeta {
|
|
SRBucketMeta {
|
|
bucket: bucket.bucket.clone(),
|
|
r#type: item_type.to_string(),
|
|
updated_at,
|
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
|
derived_rule_contract: true,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub(crate) fn bootstrap_bucket_quota_value(bucket: &str, raw: &str) -> S3Result<Value> {
|
|
serde_json::from_slice(&decode_bucket_meta_wire_value(raw))
|
|
.map_err(|e| s3_error!(InvalidRequest, "invalid quota metadata for bootstrap bucket `{bucket}`: {e}"))
|
|
}
|
|
|
|
pub(crate) fn append_bootstrap_bucket_item(
|
|
items: &mut Vec<SRBucketMeta>,
|
|
bucket: &SRBucketInfo,
|
|
item_type: &str,
|
|
value: Option<String>,
|
|
updated_at: Option<OffsetDateTime>,
|
|
apply: impl FnOnce(&mut SRBucketMeta, String) -> S3Result<()>,
|
|
) -> S3Result<()> {
|
|
if let Some(value) = value {
|
|
let mut item = bootstrap_bucket_meta_item(bucket, item_type, updated_at);
|
|
apply(&mut item, value)?;
|
|
items.push(item);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn append_bootstrap_bucket_items(
|
|
plan: &mut SiteReplicationBootstrapPlan,
|
|
bucket: &SRBucketInfo,
|
|
replicate_ilm_expiry: bool,
|
|
) -> S3Result<()> {
|
|
append_bootstrap_bucket_item(
|
|
&mut plan.bucket_items,
|
|
bucket,
|
|
"policy",
|
|
bucket.policy.clone().map(|value| value.to_string()),
|
|
bucket.policy_updated_at,
|
|
|item, value| {
|
|
item.policy =
|
|
Some(serde_json::from_str(&value).map_err(|e| {
|
|
s3_error!(InvalidRequest, "invalid bucket policy for bootstrap bucket `{}`: {e}", item.bucket)
|
|
})?);
|
|
Ok(())
|
|
},
|
|
)?;
|
|
append_bootstrap_bucket_item(
|
|
&mut plan.bucket_items,
|
|
bucket,
|
|
"version-config",
|
|
bucket.versioning.clone(),
|
|
bucket.versioning_config_updated_at,
|
|
|item, value| {
|
|
item.versioning = Some(value);
|
|
Ok(())
|
|
},
|
|
)?;
|
|
append_bootstrap_bucket_item(
|
|
&mut plan.bucket_items,
|
|
bucket,
|
|
"tags",
|
|
bucket.tags.clone(),
|
|
bucket.tag_config_updated_at,
|
|
|item, value| {
|
|
item.tags = Some(value);
|
|
Ok(())
|
|
},
|
|
)?;
|
|
append_bootstrap_bucket_item(
|
|
&mut plan.bucket_items,
|
|
bucket,
|
|
"object-lock-config",
|
|
bucket.object_lock_config.clone(),
|
|
bucket.object_lock_config_updated_at,
|
|
|item, value| {
|
|
item.object_lock_config = Some(value);
|
|
Ok(())
|
|
},
|
|
)?;
|
|
append_bootstrap_bucket_item(
|
|
&mut plan.bucket_items,
|
|
bucket,
|
|
"sse-config",
|
|
bucket.sse_config.clone(),
|
|
bucket.sse_config_updated_at,
|
|
|item, value| {
|
|
item.sse_config = Some(value);
|
|
Ok(())
|
|
},
|
|
)?;
|
|
append_bootstrap_bucket_item(
|
|
&mut plan.bucket_items,
|
|
bucket,
|
|
"replication-config",
|
|
bucket.replication_config.clone(),
|
|
bucket.replication_config_updated_at,
|
|
|item, value| {
|
|
item.replication_config = Some(value);
|
|
Ok(())
|
|
},
|
|
)?;
|
|
append_bootstrap_bucket_item(
|
|
&mut plan.bucket_items,
|
|
bucket,
|
|
"quota-config",
|
|
bucket.quota_config.clone(),
|
|
bucket.quota_config_updated_at,
|
|
|item, value| {
|
|
item.quota = Some(bootstrap_bucket_quota_value(&item.bucket, &value)?);
|
|
Ok(())
|
|
},
|
|
)?;
|
|
if replicate_ilm_expiry {
|
|
if bucket.expiry_lc_config.is_some() {
|
|
append_bootstrap_bucket_item(
|
|
&mut plan.bucket_items,
|
|
bucket,
|
|
"lc-config",
|
|
bucket.expiry_lc_config.clone(),
|
|
bucket.expiry_lc_config_updated_at,
|
|
|item, value| {
|
|
item.expiry_lc_config = Some(value);
|
|
// `updated_at` here is the entry's expiry axis (see the
|
|
// SRBucketInfo construction), not the wall clock.
|
|
item.expiry_updated_at = item.updated_at;
|
|
Ok(())
|
|
},
|
|
)?;
|
|
} else if bucket.expiry_lc_config_updated_at.is_some() {
|
|
// Expiry rules were removed at this axis (lifecycle_expiry_statement):
|
|
// an explicit timestamped delete item, so a peer that missed the
|
|
// live delete converges on bootstrap/repair instead of keeping
|
|
// stale expiry rules. The receiver's staleness guard protects a
|
|
// peer whose expiry state is newer.
|
|
let mut item = bootstrap_bucket_meta_item(bucket, "lc-config", bucket.expiry_lc_config_updated_at);
|
|
item.expiry_updated_at = item.updated_at;
|
|
plan.bucket_items.push(item);
|
|
}
|
|
}
|
|
append_bootstrap_bucket_item(
|
|
&mut plan.bucket_items,
|
|
bucket,
|
|
"cors-config",
|
|
bucket.cors_config.clone(),
|
|
bucket.cors_config_updated_at,
|
|
|item, value| {
|
|
item.cors = Some(value);
|
|
Ok(())
|
|
},
|
|
)
|
|
}
|
|
|
|
pub(crate) fn group_status_from_desc(status: &str) -> GroupStatus {
|
|
if status.eq_ignore_ascii_case("disabled") {
|
|
GroupStatus::Disabled
|
|
} else {
|
|
GroupStatus::Enabled
|
|
}
|
|
}
|
|
|
|
pub(crate) fn site_replication_info_replicates_ilm_expiry(info: &SRInfo) -> bool {
|
|
info.state.peers.values().any(|peer| peer.replicate_ilm_expiry)
|
|
}
|
|
|
|
pub(crate) fn site_replication_state_replicates_ilm_expiry(state: &SiteReplicationState) -> bool {
|
|
state.peers.values().any(|peer| peer.replicate_ilm_expiry)
|
|
}
|
|
|
|
pub(crate) fn site_replication_bootstrap_plan(info: &SRInfo) -> S3Result<SiteReplicationBootstrapPlan> {
|
|
let mut plan = SiteReplicationBootstrapPlan::default();
|
|
let replicate_ilm_expiry = site_replication_info_replicates_ilm_expiry(info);
|
|
|
|
for (name, policy) in &info.policies {
|
|
plan.iam_items.push(SRIAMItem {
|
|
r#type: "policy".to_string(),
|
|
name: name.clone(),
|
|
policy: policy.policy.clone(),
|
|
updated_at: policy.updated_at,
|
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
for (access_key, user) in &info.user_info_map {
|
|
if let Some(secret_key) = &user.secret_key {
|
|
plan.iam_items.push(SRIAMItem {
|
|
r#type: "iam-user".to_string(),
|
|
iam_user: Some(rustfs_madmin::SRIAMUser {
|
|
access_key: access_key.clone(),
|
|
is_delete_req: false,
|
|
user_req: Some(AddOrUpdateUserReq {
|
|
secret_key: secret_key.clone(),
|
|
policy: user.policy_name.clone(),
|
|
status: user.status.clone(),
|
|
}),
|
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
|
}),
|
|
updated_at: user.updated_at,
|
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
|
..Default::default()
|
|
});
|
|
}
|
|
}
|
|
|
|
for (name, desc) in &info.group_desc_map {
|
|
plan.iam_items.push(SRIAMItem {
|
|
r#type: "group-info".to_string(),
|
|
group_info: Some(SRGroupInfo {
|
|
update_req: GroupAddRemove {
|
|
group: if desc.name.is_empty() {
|
|
name.clone()
|
|
} else {
|
|
desc.name.clone()
|
|
},
|
|
members: desc.members.clone(),
|
|
status: group_status_from_desc(&desc.status),
|
|
is_remove: false,
|
|
},
|
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
|
}),
|
|
updated_at: desc.updated_at,
|
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
for mapping in info.user_policies.values().chain(info.group_policies.values()) {
|
|
plan.iam_items.push(SRIAMItem {
|
|
r#type: "policy-mapping".to_string(),
|
|
policy_mapping: Some(mapping.clone()),
|
|
updated_at: mapping.updated_at,
|
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
for bucket in info.buckets.values() {
|
|
plan.bucket_make_ops.push(bootstrap_bucket_make_op_path(bucket));
|
|
append_bootstrap_bucket_items(&mut plan, bucket, replicate_ilm_expiry)?;
|
|
plan.bucket_configure_ops
|
|
.push(bootstrap_bucket_op_path(&bucket.bucket, "configure-replication"));
|
|
}
|
|
|
|
Ok(plan)
|
|
}
|
|
|
|
/// Build only the two bucket operations needed by the lightweight retry
|
|
/// drain. The full bootstrap plan scans every bucket and IAM record; doing
|
|
/// that on a 30-second recovery cadence would make lifecycle admission scale
|
|
/// with the whole site instead of the one queued bucket.
|
|
pub(crate) fn site_replication_bucket_retry_plan_for(
|
|
bucket: &SRBucketInfo,
|
|
replicate_ilm_expiry: bool,
|
|
) -> S3Result<SiteReplicationBootstrapPlan> {
|
|
let mut plan = SiteReplicationBootstrapPlan {
|
|
bucket_make_ops: vec![bootstrap_bucket_make_op_path(bucket)],
|
|
bucket_configure_ops: vec![bootstrap_bucket_op_path(
|
|
&bucket.bucket,
|
|
SITE_REPLICATION_BUCKET_OP_CONFIGURE_REPLICATION,
|
|
)],
|
|
..Default::default()
|
|
};
|
|
append_bootstrap_bucket_items(&mut plan, bucket, replicate_ilm_expiry)?;
|
|
Ok(plan)
|
|
}
|
|
|
|
pub(crate) fn site_replication_bucket_retry_plan_from_info(
|
|
bucket: &SRBucketInfo,
|
|
replicate_ilm_expiry: bool,
|
|
) -> S3Result<SiteReplicationBootstrapPlan> {
|
|
let mut plan = site_replication_bucket_retry_plan_for(bucket, replicate_ilm_expiry)?;
|
|
// Omit only metadata the make/configure operations can reproduce exactly.
|
|
// Non-default versioning fields and operator-authored replication rules
|
|
// remain in the plan; their extra request cost intentionally defers the
|
|
// event to the complete drain when the lightweight budget is too small.
|
|
plan.bucket_items.retain(|item| !retry_bucket_metadata_is_redundant(item));
|
|
Ok(plan)
|
|
}
|
|
|
|
fn retry_bucket_metadata_is_redundant(item: &SRBucketMeta) -> bool {
|
|
match item.r#type.as_str() {
|
|
"version-config" => item.versioning.as_deref().is_some_and(|raw| {
|
|
deserialize::<VersioningConfiguration>(&decode_bucket_meta_wire_value(raw)).is_ok_and(|config| {
|
|
config
|
|
== VersioningConfiguration {
|
|
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
|
..Default::default()
|
|
}
|
|
})
|
|
}),
|
|
"replication-config" => item.replication_config.as_deref().is_some_and(|raw| {
|
|
deserialize::<ReplicationConfiguration>(&decode_bucket_meta_wire_value(raw))
|
|
.is_ok_and(|config| config.role.trim().is_empty() && config.rules.iter().all(is_derived_site_replication_rule))
|
|
}),
|
|
// `Some("")` is the in-memory sentinel used when the bucket is lock
|
|
// enabled but has no object-lock configuration body. The make query
|
|
// carries lockEnabled=true; sending an empty metadata body is neither
|
|
// useful nor parseable.
|
|
"object-lock-config" => item.object_lock_config.as_deref() == Some(""),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn site_replication_bucket_retry_plan(
|
|
bucket: &str,
|
|
replicate_ilm_expiry: bool,
|
|
) -> S3Result<SiteReplicationBootstrapPlan> {
|
|
let Some(store) = current_object_store_handle() else {
|
|
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
|
};
|
|
let bucket_info = match store.get_bucket_info(bucket, &BucketOptions::default()).await {
|
|
Ok(bucket_info) => bucket_info,
|
|
Err(err) if is_err_bucket_not_found(&err) => return Ok(SiteReplicationBootstrapPlan::default()),
|
|
Err(err) => return Err(ApiError::from(err).into()),
|
|
};
|
|
let lock_enabled = bucket_info.object_locking;
|
|
let metadata = metadata_sys::get(bucket).await.map_err(ApiError::from)?;
|
|
let mut bucket_info = SRBucketInfo {
|
|
bucket: bucket.to_string(),
|
|
created_at: bucket_info.created,
|
|
location: current_region().map(|region| region.to_string()).unwrap_or_default(),
|
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
|
..Default::default()
|
|
};
|
|
populate_sr_bucket_info_from_metadata(&mut bucket_info, &metadata).await;
|
|
if lock_enabled && bucket_info.object_lock_config.is_none() {
|
|
bucket_info.object_lock_config = Some(String::new());
|
|
}
|
|
site_replication_bucket_retry_plan_from_info(&bucket_info, replicate_ilm_expiry)
|
|
}
|
|
|
|
pub async fn site_replication_make_bucket_hook(bucket: &str, lock_enabled: bool) -> S3Result<()> {
|
|
let _bucket_op_guard = SITE_REPLICATION_BUCKET_OP_LOCK.read().await;
|
|
let runtime = {
|
|
// The bucket-op lock is what orders this against add/remove. The
|
|
// state is only read here (through the runtime snapshot), and the
|
|
// bucket setup below writes bucket metadata, never the state object —
|
|
// holding the state transaction across it would put local metadata
|
|
// IO inside a distributed lock for nothing.
|
|
let Some(runtime) = runtime_site_replication_targets().await? else {
|
|
return Ok(());
|
|
};
|
|
|
|
ensure_site_replication_bucket_versioning(bucket).await?;
|
|
ensure_site_replication_bucket_setup_with_runtime(bucket, &runtime).await?;
|
|
runtime
|
|
};
|
|
|
|
broadcast_site_replication_make_bucket(bucket, lock_enabled, Some(&runtime), None).await
|
|
}
|
|
|
|
pub(crate) async fn broadcast_site_replication_json_using_runtime<T: Serialize>(
|
|
runtime: Option<&SiteReplicationRuntime>,
|
|
path: &str,
|
|
body: &T,
|
|
) -> S3Result<()> {
|
|
match runtime {
|
|
Some(runtime) => broadcast_site_replication_json_with_runtime(runtime, path, body).await,
|
|
None => broadcast_site_replication_json(path, body).await,
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn broadcast_site_replication_make_bucket(
|
|
bucket: &str,
|
|
lock_enabled: bool,
|
|
runtime: Option<&SiteReplicationRuntime>,
|
|
bootstrap_token: Option<&str>,
|
|
) -> S3Result<()> {
|
|
let created_at = current_object_store_handle()
|
|
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?
|
|
.get_bucket_info(bucket, &BucketOptions::default())
|
|
.await
|
|
.map_err(ApiError::from)?
|
|
.created
|
|
.unwrap_or_else(OffsetDateTime::now_utc)
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap_or_default();
|
|
|
|
let path = make_with_versioning_bucket_op_path(bucket, Some(&created_at), lock_enabled);
|
|
let path = if let Some(token) = bootstrap_token {
|
|
with_site_replication_bootstrap_token(&path, token)
|
|
} else {
|
|
path
|
|
};
|
|
broadcast_site_replication_json_using_runtime(runtime, &path, &serde_json::json!({})).await?;
|
|
|
|
let configure_path = bootstrap_bucket_op_path(bucket, "configure-replication");
|
|
let configure_path = if let Some(token) = bootstrap_token {
|
|
with_site_replication_bootstrap_token(&configure_path, token)
|
|
} else {
|
|
configure_path
|
|
};
|
|
broadcast_site_replication_json_using_runtime(runtime, &configure_path, &serde_json::json!({})).await
|
|
}
|
|
|
|
const SITE_REPLICATION_DELETE_INTENT_PENDING: &str =
|
|
"bucket deletion reserved; local completion and peer delivery are not yet known";
|
|
|
|
#[derive(Clone)]
|
|
struct SiteReplicationDeleteBucketReservation {
|
|
peer: PeerInfo,
|
|
previous: Option<SiteReplicationRetryEvent>,
|
|
observed: SiteReplicationRetryEvent,
|
|
}
|
|
|
|
pub(crate) struct SiteReplicationDeleteBucketIntent {
|
|
path: String,
|
|
reservations: Vec<SiteReplicationDeleteBucketReservation>,
|
|
displaced: Vec<SiteReplicationRetryEvent>,
|
|
}
|
|
|
|
fn site_replication_delete_bucket_path(bucket: &str, force_delete: bool) -> String {
|
|
let operation = if force_delete {
|
|
"force-delete-bucket"
|
|
} else {
|
|
"delete-bucket"
|
|
};
|
|
format!(
|
|
"/rustfs/admin/v3/site-replication/peer/bucket-ops?{}",
|
|
form_urlencoded::Serializer::new(String::new())
|
|
.append_pair("bucket", bucket)
|
|
.append_pair("operation", operation)
|
|
.finish()
|
|
)
|
|
}
|
|
|
|
/// Reserve every destructive peer delivery before the local namespace is
|
|
/// changed. The state transaction either persists the complete set or writes
|
|
/// nothing, so a full/unreadable queue fails the S3 delete closed.
|
|
pub(crate) async fn prepare_site_replication_delete_bucket(
|
|
bucket: &str,
|
|
force_delete: bool,
|
|
) -> S3Result<Option<SiteReplicationDeleteBucketIntent>> {
|
|
let path = site_replication_delete_bucket_path(bucket, force_delete);
|
|
let reservation_path = path.clone();
|
|
update_site_replication_state_when_changed(move |state| {
|
|
if !state.enabled() {
|
|
return Ok(StateCommit::Unchanged(None));
|
|
}
|
|
let local_peer = current_local_runtime_peer(state);
|
|
let peers = state
|
|
.peers
|
|
.values()
|
|
.filter(|peer| {
|
|
peer.deployment_id != local_peer.deployment_id && !same_identity_endpoint(&peer.endpoint, &local_peer.endpoint)
|
|
})
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
if peers.is_empty() {
|
|
return Ok(StateCommit::Unchanged(None));
|
|
}
|
|
|
|
let mut reservations = Vec::with_capacity(peers.len());
|
|
let mut displaced = Vec::new();
|
|
for peer in peers {
|
|
let previous = state
|
|
.retry_queue
|
|
.iter()
|
|
.find(|event| retry_event_matches(event, &peer, &reservation_path))
|
|
.cloned();
|
|
displaced.extend(upsert_site_replication_retry_event(
|
|
&mut state.retry_queue,
|
|
&peer,
|
|
&reservation_path,
|
|
SITE_REPLICATION_DELETE_INTENT_PENDING,
|
|
None,
|
|
)?);
|
|
let observed = state
|
|
.retry_queue
|
|
.iter()
|
|
.find(|event| retry_event_matches(event, &peer, &reservation_path))
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
S3Error::with_message(
|
|
S3ErrorCode::InternalError,
|
|
"site replication delete reservation disappeared before commit".to_string(),
|
|
)
|
|
})?;
|
|
reservations.push(SiteReplicationDeleteBucketReservation {
|
|
peer,
|
|
previous,
|
|
observed,
|
|
});
|
|
}
|
|
Ok(StateCommit::Changed(Some(SiteReplicationDeleteBucketIntent {
|
|
path: reservation_path,
|
|
reservations,
|
|
displaced,
|
|
})))
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Roll back a reservation when the local storage delete definitively failed.
|
|
/// A concurrently revised reservation is preserved; it belongs to a newer
|
|
/// observation and this operation has no authority to settle it.
|
|
pub(crate) async fn cancel_site_replication_delete_bucket(intent: SiteReplicationDeleteBucketIntent) {
|
|
let path = intent.path.clone();
|
|
let result = update_site_replication_state_when_changed(move |state| {
|
|
let mut changed = false;
|
|
for reservation in intent.reservations {
|
|
let Some(index) = state.retry_queue.iter().position(|event| {
|
|
retry_event_matches(event, &reservation.peer, &reservation.observed.path)
|
|
&& event.id == reservation.observed.id
|
|
&& event.updated_at == reservation.observed.updated_at
|
|
}) else {
|
|
continue;
|
|
};
|
|
if let Some(previous) = reservation.previous {
|
|
state.retry_queue[index] = previous;
|
|
} else {
|
|
state.retry_queue.remove(index);
|
|
}
|
|
changed = true;
|
|
}
|
|
|
|
let mut restored_all = true;
|
|
for displaced in intent.displaced {
|
|
let duplicate = state.retry_queue.iter().any(|event| {
|
|
event.id == displaced.id
|
|
|| (event.peer_deployment_id == displaced.peer_deployment_id && event.path == displaced.path)
|
|
});
|
|
if duplicate {
|
|
continue;
|
|
}
|
|
if state.retry_queue.len() >= SITE_REPLICATION_RETRY_QUEUE_LIMIT {
|
|
restored_all = false;
|
|
continue;
|
|
}
|
|
state.retry_queue.push(displaced);
|
|
changed = true;
|
|
}
|
|
Ok(if changed {
|
|
StateCommit::Changed(restored_all)
|
|
} else {
|
|
StateCommit::Unchanged(restored_all)
|
|
})
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok(true) => {}
|
|
Ok(false) => warn!(
|
|
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
|
|
component = LOG_COMPONENT_ADMIN,
|
|
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
|
|
path,
|
|
result = "delete_intent_cancel_incomplete",
|
|
"admin site replication state"
|
|
),
|
|
Err(err) => warn!(
|
|
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
|
|
component = LOG_COMPONENT_ADMIN,
|
|
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
|
|
path,
|
|
result = "delete_intent_cancel_failed",
|
|
error = ?err,
|
|
"admin site replication state"
|
|
),
|
|
}
|
|
}
|
|
|
|
async fn broadcast_site_replication_delete_bucket(intent: &SiteReplicationDeleteBucketIntent) -> S3Result<()> {
|
|
let sends = intent.reservations.iter().cloned().map(|reservation| {
|
|
let request_path = intent.path.clone();
|
|
async move {
|
|
let fallback_peer = reservation.peer.clone();
|
|
let observed = reservation.observed.clone();
|
|
let delivery_path = request_path.clone();
|
|
let delivery = with_site_replication_state_read_lock(move |state| async move {
|
|
let Some(current_peer) = state.peers.get(&fallback_peer.deployment_id).cloned() else {
|
|
return Ok(None);
|
|
};
|
|
let service_account_secret_key =
|
|
match site_replicator_service_account_secret(&state.service_account_access_key).await {
|
|
Ok(secret) => secret,
|
|
Err(err) => {
|
|
let Some(secret) = legacy_site_replicator_state_secret(&state) else {
|
|
return Err(err);
|
|
};
|
|
warn!(
|
|
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
|
|
component = LOG_COMPONENT_ADMIN,
|
|
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
|
|
result = "legacy_state_service_account_secret_fallback",
|
|
error = ?err,
|
|
"admin site replication state"
|
|
);
|
|
secret
|
|
}
|
|
};
|
|
let result = async {
|
|
let transport = PeerTransport::for_runtime_peer(¤t_peer).await?;
|
|
PeerAdminRequest::put(&transport.connection, &delivery_path, &state.service_account_access_key)
|
|
.with_client(&transport.client)
|
|
.send(&service_account_secret_key, &serde_json::json!({}))
|
|
.await
|
|
}
|
|
.await;
|
|
Ok(Some((current_peer, result)))
|
|
})
|
|
.await;
|
|
match delivery {
|
|
Ok(Some((current_peer, Ok(_)))) => {
|
|
dequeue_observed_site_replication_retry_event(¤t_peer, &observed).await;
|
|
None
|
|
}
|
|
Ok(Some((current_peer, Err(err)))) => {
|
|
// Keep the failed deletion operator-visible, but never
|
|
// replay it automatically: without a bucket-incarnation
|
|
// fence, a delayed delete could erase a recreated bucket.
|
|
enqueue_site_replication_retry_event(¤t_peer, &request_path, &err).await;
|
|
Some(err)
|
|
}
|
|
Ok(None) => {
|
|
dequeue_observed_site_replication_retry_event(&reservation.peer, &observed).await;
|
|
None
|
|
}
|
|
Err(err) => {
|
|
enqueue_site_replication_retry_event(&reservation.peer, &request_path, &err).await;
|
|
Some(err)
|
|
}
|
|
}
|
|
}
|
|
});
|
|
futures::future::join_all(sends)
|
|
.await
|
|
.into_iter()
|
|
.flatten()
|
|
.next()
|
|
.map_or(Ok(()), Err)
|
|
}
|
|
|
|
pub(crate) async fn commit_site_replication_delete_bucket(intent: &SiteReplicationDeleteBucketIntent) -> S3Result<()> {
|
|
let _bucket_op_guard = SITE_REPLICATION_BUCKET_OP_LOCK.read().await;
|
|
let store =
|
|
current_object_store_handle().ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?;
|
|
let retry_peers = intent
|
|
.reservations
|
|
.iter()
|
|
.map(|reservation| reservation.peer.clone())
|
|
.collect::<Vec<_>>();
|
|
let retry_path = intent.path.clone();
|
|
let delivery_intent = SiteReplicationDeleteBucketIntent {
|
|
path: intent.path.clone(),
|
|
reservations: intent.reservations.clone(),
|
|
displaced: Vec::new(),
|
|
};
|
|
match with_config_object_write_lock(store, SITE_REPLICATION_REPAIR_EXECUTION_LOCK_PATH.to_string(), move || async move {
|
|
broadcast_site_replication_delete_bucket(&delivery_intent).await
|
|
})
|
|
.await
|
|
{
|
|
Ok(result) => result,
|
|
Err(err) => {
|
|
let err: S3Error = ApiError::from(err).into();
|
|
for peer in &retry_peers {
|
|
enqueue_site_replication_retry_event(peer, &retry_path, &err).await;
|
|
}
|
|
Err(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn site_replication_bucket_meta_hook(mut item: SRBucketMeta) -> S3Result<()> {
|
|
let Some(runtime) = runtime_site_replication_targets().await? else {
|
|
return Ok(());
|
|
};
|
|
if item.r#type == "lc-config" && !site_replication_state_replicates_ilm_expiry(&runtime.state) {
|
|
return Ok(());
|
|
}
|
|
if item.r#type == "lc-config" {
|
|
// Only the expiry subset travels (MinIO peers install incoming rules
|
|
// verbatim, so transition rules must never leave this site). An empty
|
|
// subset becomes a delete, which the receiver merges with the empty
|
|
// set — local transition rules there survive.
|
|
item.expiry_lc_config = item
|
|
.expiry_lc_config
|
|
.and_then(|raw| lifecycle_expiry_subset_xml(raw.as_bytes()))
|
|
.map(|data| String::from_utf8_lossy(&data).into_owned());
|
|
}
|
|
broadcast_site_replication_json_with_runtime(
|
|
&runtime,
|
|
"/rustfs/admin/v3/site-replication/peer/bucket-meta",
|
|
&encode_bucket_meta_wire_item(item),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Broadcast one IAM change to every peer. Unlike the generic JSON broadcast
|
|
/// this attempts ALL peers instead of failing fast, and books every failure —
|
|
/// transport construction included — through the deletion-aware recorder: a
|
|
/// deletion body that failed to reach a peer must be persisted for the retry
|
|
/// drain, or the peer keeps the deleted entity forever (backlog#2071).
|
|
pub async fn site_replication_iam_change_hook(item: SRIAMItem) -> S3Result<()> {
|
|
let Some(runtime) = runtime_site_replication_targets().await? else {
|
|
return Ok(());
|
|
};
|
|
let mut first_error: Option<S3Error> = None;
|
|
for peer in runtime.state.peers.values() {
|
|
if peer.deployment_id == runtime.local_peer.deployment_id
|
|
|| same_identity_endpoint(&peer.endpoint, &runtime.local_peer.endpoint)
|
|
{
|
|
continue;
|
|
}
|
|
let sent = async {
|
|
let transport = PeerTransport::for_runtime_peer(peer).await?;
|
|
PeerAdminRequest::put(
|
|
&transport.connection,
|
|
SITE_REPLICATION_PEER_IAM_ITEM_WIRE_PATH,
|
|
&runtime.state.service_account_access_key,
|
|
)
|
|
.with_client(&transport.client)
|
|
.send(&runtime.service_account_secret_key, &item)
|
|
.await
|
|
}
|
|
.await;
|
|
match sent {
|
|
Ok(_) => dequeue_site_replication_retry_event(peer, SITE_REPLICATION_PEER_IAM_ITEM_WIRE_PATH).await,
|
|
Err(err) => {
|
|
record_failed_site_replication_iam_delivery(peer, &item, &err).await;
|
|
first_error.get_or_insert(err);
|
|
}
|
|
}
|
|
}
|
|
first_error.map_or(Ok(()), Err)
|
|
}
|
|
|
|
pub(crate) fn raw_config_to_string(raw: &[u8]) -> Option<String> {
|
|
if raw.is_empty() {
|
|
return None;
|
|
}
|
|
String::from_utf8(raw.to_vec()).ok()
|
|
}
|
|
|
|
pub(crate) fn raw_config_to_base64(raw: &[u8]) -> Option<String> {
|
|
(!raw.is_empty()).then(|| BASE64_STANDARD.encode_to_string(raw))
|
|
}
|
|
|
|
pub(crate) fn encode_bucket_meta_wire_value(value: Option<String>) -> Option<String> {
|
|
value.map(|raw| BASE64_STANDARD.encode_to_string(raw.as_bytes()))
|
|
}
|
|
|
|
pub(crate) fn encode_bucket_meta_wire_item(mut item: SRBucketMeta) -> SRBucketMeta {
|
|
item.versioning = encode_bucket_meta_wire_value(item.versioning);
|
|
item.tags = encode_bucket_meta_wire_value(item.tags);
|
|
item.object_lock_config = encode_bucket_meta_wire_value(item.object_lock_config);
|
|
item.sse_config = encode_bucket_meta_wire_value(item.sse_config);
|
|
item.replication_config = encode_bucket_meta_wire_value(item.replication_config);
|
|
item.expiry_lc_config = encode_bucket_meta_wire_value(item.expiry_lc_config);
|
|
item.cors = encode_bucket_meta_wire_value(item.cors);
|
|
item
|
|
}
|
|
|
|
pub(crate) fn decode_bucket_meta_wire_value(raw: &str) -> Vec<u8> {
|
|
BASE64_STANDARD
|
|
.decode_to_vec(raw.as_bytes())
|
|
.ok()
|
|
.filter(|decoded| std::str::from_utf8(decoded).is_ok())
|
|
.unwrap_or_else(|| raw.as_bytes().to_vec())
|
|
}
|
|
|
|
pub(crate) fn decode_bucket_meta_wire_option(value: Option<String>) -> Option<Vec<u8>> {
|
|
value.map(|raw| decode_bucket_meta_wire_value(&raw))
|
|
}
|
|
|
|
pub(crate) fn maybe_time(value: OffsetDateTime) -> Option<OffsetDateTime> {
|
|
(value != OffsetDateTime::UNIX_EPOCH).then_some(value)
|
|
}
|
|
|
|
async fn populate_sr_bucket_info_from_metadata(entry: &mut SRBucketInfo, metadata: &BucketMetadata) {
|
|
entry.policy = raw_config_to_string(&metadata.policy_config_json).and_then(|raw| serde_json::from_str(&raw).ok());
|
|
entry.versioning = raw_config_to_base64(&metadata.versioning_config_xml);
|
|
entry.tags = raw_config_to_base64(&metadata.tagging_config_xml);
|
|
entry.object_lock_config = raw_config_to_base64(&metadata.object_lock_config_xml);
|
|
entry.sse_config = raw_config_to_base64(&metadata.encryption_config_xml);
|
|
entry.replication_config = raw_config_to_base64(&metadata.replication_config_xml);
|
|
entry.quota_config = raw_config_to_base64(&metadata.quota_config_json);
|
|
// Expiry subset only: this entry feeds both the bootstrap/repair plan
|
|
// (peers must not receive transition rules) and cross-site consistency
|
|
// views (transition rules are site-local and would read as false
|
|
// mismatches). A deleted expiry state is a `None` value with the
|
|
// deletion's axis so repair can converge peers that missed the live
|
|
// delete.
|
|
let expiry_statement = lifecycle_expiry_statement(metadata);
|
|
entry.expiry_lc_config = expiry_statement.as_ref().and_then(|(subset, _)| subset.clone());
|
|
entry.cors_config = raw_config_to_base64(&metadata.cors_config_xml);
|
|
entry.policy_updated_at = maybe_time(metadata.policy_config_updated_at);
|
|
entry.tag_config_updated_at = maybe_time(metadata.tagging_config_updated_at);
|
|
entry.object_lock_config_updated_at = maybe_time(metadata.object_lock_config_updated_at);
|
|
entry.sse_config_updated_at = maybe_time(metadata.encryption_config_updated_at);
|
|
entry.versioning_config_updated_at = maybe_time(metadata.versioning_config_updated_at);
|
|
entry.replication_config_updated_at = maybe_time(metadata.replication_config_updated_at);
|
|
entry.quota_config_updated_at = maybe_time(metadata.quota_config_updated_at);
|
|
// The expiry axis, not the whole-config write time: local transition-only
|
|
// edits inflate the latter, and a repair item stamped with it could
|
|
// out-rank a newer real expiry edit on a third site.
|
|
entry.expiry_lc_config_updated_at = expiry_statement.map(|(_, axis)| axis);
|
|
entry.cors_config_updated_at = maybe_time(metadata.cors_config_updated_at);
|
|
entry.replication_targets_online =
|
|
Some(site_replication_targets_online(&entry.bucket, &metadata.replication_config_xml).await);
|
|
}
|
|
|
|
pub(crate) async fn build_sr_info(state: &SiteReplicationState, local_peer: &PeerInfo) -> S3Result<SRInfo> {
|
|
let Some(store) = current_object_store_handle() else {
|
|
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
|
};
|
|
|
|
let mut info = SRInfo {
|
|
enabled: state.enabled(),
|
|
name: local_peer.name.clone(),
|
|
deployment_id: local_peer.deployment_id.clone(),
|
|
state: SRStateInfo {
|
|
name: local_peer.name.clone(),
|
|
peers: state.peers.clone(),
|
|
updated_at: state.updated_at,
|
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
|
},
|
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let buckets = store.list_bucket(&BucketOptions::default()).await.map_err(ApiError::from)?;
|
|
for bucket in buckets {
|
|
let metadata = metadata_sys::get(&bucket.name).await.ok();
|
|
let mut entry = SRBucketInfo {
|
|
bucket: bucket.name.clone(),
|
|
created_at: bucket.created,
|
|
location: current_region().map(|region| region.to_string()).unwrap_or_default(),
|
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
if let Some(metadata) = metadata {
|
|
populate_sr_bucket_info_from_metadata(&mut entry, &metadata).await;
|
|
}
|
|
|
|
info.buckets.insert(bucket.name, entry);
|
|
}
|
|
|
|
if let Some(iam_sys) = current_iam_handle() {
|
|
for (name, policy_doc) in iam_sys.list_policy_docs("").await.map_err(ApiError::from)? {
|
|
info.policies.insert(
|
|
name,
|
|
SRIAMPolicy {
|
|
policy: serde_json::to_value(policy_doc.policy).ok(),
|
|
updated_at: policy_doc.update_date,
|
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
|
},
|
|
);
|
|
}
|
|
|
|
let users = iam_sys.list_users().await.map_err(ApiError::from)?;
|
|
for (name, user) in users {
|
|
info.user_info_map.insert(name, user);
|
|
}
|
|
|
|
let groups = iam_sys.list_groups_load().await.map_err(ApiError::from)?;
|
|
for group in groups {
|
|
let desc = iam_sys.get_group_description(&group).await.map_err(ApiError::from)?;
|
|
info.group_desc_map.insert(group.clone(), desc);
|
|
}
|
|
|
|
let mut user_policies = HashMap::<String, MappedPolicy>::new();
|
|
iam_sys
|
|
.load_mapped_policies(UserType::Reg, false, &mut user_policies)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
for (name, mapping) in user_policies {
|
|
info.user_policies
|
|
.insert(name.clone(), mapped_policy_to_sr_mapping(name, false, UserType::Reg, mapping));
|
|
}
|
|
|
|
let mut group_policies = HashMap::<String, MappedPolicy>::new();
|
|
iam_sys
|
|
.load_mapped_policies(UserType::None, true, &mut group_policies)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
for (name, mapping) in group_policies {
|
|
info.group_policies
|
|
.insert(name.clone(), mapped_policy_to_sr_mapping(name, true, UserType::None, mapping));
|
|
}
|
|
}
|
|
|
|
for (name, bucket_info) in &info.buckets {
|
|
if let Some(raw) = bucket_info
|
|
.replication_config
|
|
.as_ref()
|
|
.and_then(|value| serde_json::from_str::<Value>(value).ok())
|
|
{
|
|
info.replication_cfg.insert(name.clone(), raw);
|
|
}
|
|
}
|
|
|
|
Ok(info)
|
|
}
|
|
|
|
pub(crate) fn mapped_policy_to_sr_mapping(
|
|
name: String,
|
|
is_group: bool,
|
|
user_type: UserType,
|
|
mapping: MappedPolicy,
|
|
) -> SRPolicyMapping {
|
|
SRPolicyMapping {
|
|
user_or_group: name,
|
|
user_type: sr_wire_user_type(user_type, is_group),
|
|
is_group,
|
|
policy: mapping.policies,
|
|
updated_at: Some(mapping.update_at),
|
|
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub(crate) fn bucket_target_endpoint(target: &BucketTarget) -> String {
|
|
let scheme = if target.secure { "https" } else { "http" };
|
|
canonical_endpoint(&format!("{scheme}://{}", target.endpoint))
|
|
}
|
|
|
|
pub(crate) fn bucket_target_matches_peer(target: &BucketTarget, peer: &PeerInfo) -> bool {
|
|
if !target.deployment_id.is_empty() {
|
|
return target.deployment_id == peer.deployment_id;
|
|
}
|
|
bucket_target_endpoint(target) == canonical_endpoint(&peer.endpoint)
|
|
}
|
|
|
|
pub(crate) fn site_replication_target_arns_by_peer(config: Option<&ReplicationConfiguration>) -> HashMap<String, String> {
|
|
let mut arns_by_peer = HashMap::new();
|
|
let Some(config) = config else {
|
|
return arns_by_peer;
|
|
};
|
|
|
|
let mut configured_arns = Vec::new();
|
|
if !config.role.trim().is_empty() {
|
|
configured_arns.push(config.role.clone());
|
|
}
|
|
for rule in &config.rules {
|
|
let arn = rule.destination.bucket.trim();
|
|
if !arn.is_empty() {
|
|
configured_arns.push(arn.to_string());
|
|
}
|
|
}
|
|
|
|
for arn in configured_arns {
|
|
if let Some(deployment_id) = replication_target_arn_deployment_id(&arn) {
|
|
arns_by_peer.entry(deployment_id).or_insert(arn);
|
|
}
|
|
}
|
|
|
|
arns_by_peer
|
|
}
|
|
|
|
pub(crate) fn site_replication_bucket_target_for_peer(
|
|
bucket: &str,
|
|
state: &SiteReplicationState,
|
|
peer: &PeerInfo,
|
|
service_account_secret_key: &str,
|
|
arn_override: Option<String>,
|
|
) -> S3Result<Option<BucketTarget>> {
|
|
if state.service_account_access_key.is_empty() || service_account_secret_key.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let parsed = Url::parse(&peer.endpoint)
|
|
.ok()
|
|
.or_else(|| Url::parse(&format!("http://{}", peer.endpoint.trim())).ok())
|
|
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid peer endpoint: {}", peer.endpoint)))?;
|
|
let host = parsed.host_str().ok_or_else(|| {
|
|
S3Error::with_message(S3ErrorCode::InvalidRequest, format!("peer endpoint missing host: {}", peer.endpoint))
|
|
})?;
|
|
let port = parsed.port_or_known_default().ok_or_else(|| {
|
|
S3Error::with_message(S3ErrorCode::InvalidRequest, format!("peer endpoint missing port: {}", peer.endpoint))
|
|
})?;
|
|
let region = current_region()
|
|
.map(|region| region.to_string())
|
|
.filter(|region| !region.is_empty())
|
|
.unwrap_or_else(|| "us-east-1".to_string());
|
|
let arn = arn_override.unwrap_or_else(|| {
|
|
ARN::new(
|
|
BucketTargetType::ReplicationService,
|
|
peer.deployment_id.clone(),
|
|
String::new(),
|
|
bucket.to_string(),
|
|
)
|
|
.to_string()
|
|
});
|
|
|
|
Ok(Some(BucketTarget {
|
|
source_bucket: bucket.to_string(),
|
|
endpoint: format!("{host}:{port}"),
|
|
credentials: Some(Credentials {
|
|
access_key: state.service_account_access_key.clone(),
|
|
secret_key: service_account_secret_key.to_string(),
|
|
session_token: None,
|
|
expiration: None,
|
|
}),
|
|
target_bucket: bucket.to_string(),
|
|
secure: parsed.scheme().eq_ignore_ascii_case("https"),
|
|
arn,
|
|
region,
|
|
target_type: BucketTargetType::ReplicationService,
|
|
deployment_id: peer.deployment_id.clone(),
|
|
skip_tls_verify: peer.skip_tls_verify,
|
|
ca_cert_pem: peer.ca_cert_pem.clone(),
|
|
..Default::default()
|
|
}))
|
|
}
|
|
|
|
pub(crate) fn reconcile_site_replication_bucket_targets(
|
|
existing: BucketTargets,
|
|
bucket: &str,
|
|
state: &SiteReplicationState,
|
|
local_peer: &PeerInfo,
|
|
config: Option<&ReplicationConfiguration>,
|
|
service_account_secret_key: &str,
|
|
) -> S3Result<BucketTargets> {
|
|
if !state.enabled() || state.service_account_access_key.is_empty() || service_account_secret_key.is_empty() {
|
|
return Ok(existing);
|
|
}
|
|
|
|
let configured_arns = site_replication_target_arns_by_peer(config);
|
|
let mut targets = existing.targets;
|
|
|
|
for peer in state.peers.values() {
|
|
if peer.deployment_id == local_peer.deployment_id || same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) {
|
|
continue;
|
|
}
|
|
|
|
let Some(mut target) = site_replication_bucket_target_for_peer(
|
|
bucket,
|
|
state,
|
|
peer,
|
|
service_account_secret_key,
|
|
configured_arns.get(&peer.deployment_id).cloned(),
|
|
)?
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
if let Some(index) = targets.iter().position(|existing| {
|
|
existing.target_type == BucketTargetType::ReplicationService
|
|
&& (bucket_target_matches_peer(existing, peer) || existing.arn == target.arn)
|
|
}) {
|
|
let existing = targets[index].clone();
|
|
target.path = existing.path;
|
|
target.region = existing.region;
|
|
target.bandwidth_limit = existing.bandwidth_limit;
|
|
target.replication_sync = existing.replication_sync;
|
|
target.storage_class = existing.storage_class;
|
|
target.health_check_duration = existing.health_check_duration;
|
|
target.disable_proxy = existing.disable_proxy;
|
|
target.reset_before_date = existing.reset_before_date;
|
|
target.reset_id = existing.reset_id;
|
|
target.total_downtime = existing.total_downtime;
|
|
target.last_online = existing.last_online;
|
|
target.online = existing.online;
|
|
target.latency = existing.latency;
|
|
target.edge = existing.edge;
|
|
target.edge_sync_before_expiry = existing.edge_sync_before_expiry;
|
|
target.offline_count = existing.offline_count;
|
|
targets[index] = target;
|
|
} else {
|
|
targets.push(target);
|
|
}
|
|
}
|
|
|
|
Ok(BucketTargets { targets })
|
|
}
|
|
|
|
/// Whether every `site-repl-*` rule on this bucket resolves to a live remote target.
|
|
///
|
|
/// The rule set alone cannot answer this: a rule can be perfectly formed while the endpoint
|
|
/// recorded for its peer is one this site cannot reach, so `update_all_targets` never built
|
|
/// a client for it and `replicate_object` drops every object against that ARN. Reads the
|
|
/// already-resolved client map rather than rebuilding clients, so it stays cheap enough for
|
|
/// the status path.
|
|
pub(crate) async fn site_replication_targets_online(bucket: &str, replication_config_xml: &[u8]) -> bool {
|
|
let Ok(config) = deserialize::<ReplicationConfiguration>(replication_config_xml) else {
|
|
return true;
|
|
};
|
|
|
|
for rule in config.rules.iter().filter(|rule| is_derived_site_replication_rule(rule)) {
|
|
if BucketTargetSys::get()
|
|
.get_remote_target_client_by_arn(bucket, &rule.destination.bucket)
|
|
.await
|
|
.is_none()
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
/// True when the rule carries the expiry semantics that `replicateILMExpiry`
|
|
/// propagates. Del-marker expiration and abort-multipart are deliberately
|
|
/// excluded: MinIO's sender never emits them (`CloneNonTransition` drops
|
|
/// both), so treating them as traveling state would let a MinIO peer's
|
|
/// broadcast delete this site's del-marker-only rules.
|
|
pub(crate) fn lifecycle_rule_has_expiry(rule: &LifecycleRule) -> bool {
|
|
rule.expiration.is_some() || rule.noncurrent_version_expiration.is_some()
|
|
}
|
|
|
|
/// Remove the fields that never travel between sites (MinIO
|
|
/// `CloneNonTransition` parity).
|
|
pub(crate) fn strip_site_local_lifecycle_fields(rule: &mut LifecycleRule) {
|
|
rule.transitions = None;
|
|
rule.noncurrent_version_transitions = None;
|
|
rule.abort_incomplete_multipart_upload = None;
|
|
rule.del_marker_expiration = None;
|
|
}
|
|
|
|
/// Reduce a lifecycle XML document to the expiry subset that is allowed to
|
|
/// travel between sites (what MinIO's sender emits): transition fields are
|
|
/// stripped and rules left with no expiry semantics are dropped. Returns
|
|
/// `None` when nothing remains — the receiver then merges with the empty set,
|
|
/// which is exactly the "no expiry rules here" statement. A document that
|
|
/// fails to parse is forwarded unfiltered (`Some(original)`): the receiver
|
|
/// merge strips it anyway, and turning a local parse error into a `None`
|
|
/// would delete the peers' replicated expiry rules.
|
|
pub(crate) fn lifecycle_expiry_subset_xml(raw: &[u8]) -> Option<Vec<u8>> {
|
|
if raw.is_empty() {
|
|
return None;
|
|
}
|
|
let config: BucketLifecycleConfiguration = match deserialize(raw) {
|
|
Ok(config) => config,
|
|
Err(err) => {
|
|
warn!("failed to parse local lifecycle config for expiry replication; forwarding unfiltered: {err}");
|
|
return Some(raw.to_vec());
|
|
}
|
|
};
|
|
let expiry_updated_at = config.expiry_updated_at.clone();
|
|
let rules: Vec<LifecycleRule> = config
|
|
.rules
|
|
.into_iter()
|
|
.filter_map(|mut rule| {
|
|
strip_site_local_lifecycle_fields(&mut rule);
|
|
lifecycle_rule_has_expiry(&rule).then_some(rule)
|
|
})
|
|
.collect();
|
|
if rules.is_empty() {
|
|
return None;
|
|
}
|
|
let subset = BucketLifecycleConfiguration {
|
|
rules,
|
|
expiry_updated_at,
|
|
};
|
|
match serialize(&subset) {
|
|
Ok(data) => Some(data),
|
|
Err(err) => {
|
|
warn!("failed to serialize lifecycle expiry subset; forwarding unfiltered: {err}");
|
|
Some(raw.to_vec())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The expiry replication axis persisted in a lifecycle XML document, if any.
|
|
/// Used for the SRInfo bucket entry so bootstrap/repair items carry the
|
|
/// expiry axis instead of the whole-config write time (which local
|
|
/// transition-only edits inflate).
|
|
pub(crate) fn lifecycle_expiry_updated_at(raw: &[u8]) -> Option<OffsetDateTime> {
|
|
if raw.is_empty() {
|
|
return None;
|
|
}
|
|
deserialize::<BucketLifecycleConfiguration>(raw)
|
|
.ok()
|
|
.and_then(|config| config.expiry_updated_at)
|
|
.map(OffsetDateTime::from)
|
|
}
|
|
|
|
/// The ILM expiry statement this site contributes to its SRInfo bucket entry
|
|
/// (feeding bootstrap/repair and consistency views), if any.
|
|
/// `Some((subset_b64, axis))` — a `None` subset means "expiry rules were
|
|
/// removed at `axis`" and travels as an explicit timestamped delete item, so
|
|
/// a peer that missed the live delete still converges on repair.
|
|
pub(crate) fn lifecycle_expiry_statement(
|
|
metadata: &crate::storage_api::site_replication::BucketMetadata,
|
|
) -> Option<(Option<String>, OffsetDateTime)> {
|
|
if metadata.lifecycle_config_xml.is_empty() {
|
|
// Deleted vs never configured: the whole-config write time survives
|
|
// deletion in bucket metadata and strictly exceeds the created-time
|
|
// backfill only after a real write.
|
|
return (metadata.lifecycle_config_updated_at > metadata.created).then_some((None, metadata.lifecycle_config_updated_at));
|
|
}
|
|
let axis = lifecycle_expiry_updated_at(&metadata.lifecycle_config_xml);
|
|
match lifecycle_expiry_subset_xml(&metadata.lifecycle_config_xml) {
|
|
Some(subset) => {
|
|
// Legacy documents predate the axis field; their whole-config
|
|
// write time bounds the last expiry edit.
|
|
let axis = axis.unwrap_or(metadata.lifecycle_config_updated_at);
|
|
Some((raw_config_to_base64(&subset), axis))
|
|
}
|
|
// Transition-only config: with an expiry axis the site once had
|
|
// expiry rules and properly removed them — the delete travels at
|
|
// that axis. Without one there is nothing to say (a delete stamped
|
|
// off the whole-config time would let a local transition edit erase
|
|
// newer peer expiry state).
|
|
None => axis.map(|axis| (None, axis)),
|
|
}
|
|
}
|
|
|
|
/// Whether `rule` is in the shape the reconciler derives (`site-repl-<id>`
|
|
/// naming the deployment its ARN targets). The reconciler rebuilds every such
|
|
/// rule from the current peer set — current peer or not, so a leftover from a
|
|
/// removed peer or a self-pointing rule is rebuilt away — while the merges
|
|
/// keep only the current peers' rules and treat a leftover as operator state
|
|
/// the edit replaces. An operator-authored `site-repl-*` id on an operator
|
|
/// ARN is outside the shape and survives every pass.
|
|
pub(crate) fn is_derived_site_replication_rule(rule: &ReplicationRule) -> bool {
|
|
site_replication_rule_deployment_id(rule).is_some()
|
|
}
|
|
|
|
pub(crate) fn build_site_replication_rule(arn: &str, priority: i32, rule_id: &str) -> ReplicationRule {
|
|
ReplicationRule {
|
|
delete_marker_replication: Some(DeleteMarkerReplication {
|
|
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)),
|
|
}),
|
|
delete_replication: Some(DeleteReplication {
|
|
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
|
|
}),
|
|
destination: Destination {
|
|
bucket: arn.to_string(),
|
|
..Default::default()
|
|
},
|
|
existing_object_replication: Some(ExistingObjectReplication {
|
|
status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED),
|
|
}),
|
|
filter: None,
|
|
id: Some(rule_id.to_string()),
|
|
prefix: None,
|
|
priority: Some(priority),
|
|
source_selection_criteria: Some(SourceSelectionCriteria {
|
|
replica_modifications: Some(ReplicaModifications {
|
|
status: ReplicaModificationsStatus::from_static(ReplicaModificationsStatus::ENABLED),
|
|
}),
|
|
sse_kms_encrypted_objects: None,
|
|
}),
|
|
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn build_site_replication_config(
|
|
bucket: &str,
|
|
state: &SiteReplicationState,
|
|
local_peer: &PeerInfo,
|
|
service_account_secret_key: &str,
|
|
existing: Option<&ReplicationConfiguration>,
|
|
) -> S3Result<Option<ReplicationConfiguration>> {
|
|
// Reuse the ARN already recorded for a peer so the rule keeps pointing at the same
|
|
// bucket target `reconcile_site_replication_bucket_targets` keys off (a MinIO-era
|
|
// `arn:minio:...` target would otherwise be orphaned by a freshly minted ARN).
|
|
let configured_arns = site_replication_target_arns_by_peer(existing);
|
|
let mut rules = Vec::new();
|
|
for peer in state.peers.values() {
|
|
if peer.deployment_id == local_peer.deployment_id || same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) {
|
|
continue;
|
|
}
|
|
|
|
let Some(target) = site_replication_bucket_target_for_peer(
|
|
bucket,
|
|
state,
|
|
peer,
|
|
service_account_secret_key,
|
|
configured_arns.get(&peer.deployment_id).cloned(),
|
|
)?
|
|
else {
|
|
continue;
|
|
};
|
|
rules.push(build_site_replication_rule(
|
|
&target.arn,
|
|
(rules.len() + 1) as i32,
|
|
&format!("site-repl-{}", peer.deployment_id),
|
|
));
|
|
}
|
|
|
|
if rules.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(ReplicationConfiguration {
|
|
role: String::new(),
|
|
rules,
|
|
}))
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn ensure_site_replication_bucket_targets_with_runtime(
|
|
bucket: &str,
|
|
state: &SiteReplicationState,
|
|
local_peer: &PeerInfo,
|
|
config: Option<&ReplicationConfiguration>,
|
|
service_account_secret_key: &str,
|
|
expected_incarnation_id: Uuid,
|
|
) -> S3Result<()> {
|
|
let existing = match metadata_sys::list_bucket_targets(bucket).await {
|
|
Ok(targets) => targets,
|
|
Err(StorageError::ConfigNotFound) => BucketTargets::default(),
|
|
Err(err) => return Err(ApiError::from(err).into()),
|
|
};
|
|
let existing_json = serde_json::to_vec(&existing)
|
|
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize bucket targets failed: {e}")))?;
|
|
|
|
let updated =
|
|
reconcile_site_replication_bucket_targets(existing, bucket, state, local_peer, config, service_account_secret_key)?;
|
|
if updated.targets.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let json_targets = serde_json::to_vec(&updated)
|
|
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize bucket targets failed: {e}")))?;
|
|
// Rewriting identical targets would churn bucket metadata and rebuild every remote S3
|
|
// client — noticeable now that startup reconciles all buckets, not just the one bucket
|
|
// an operation touched.
|
|
if json_targets == existing_json {
|
|
return Ok(());
|
|
}
|
|
metadata_sys::update_if_incarnation(bucket, BUCKET_TARGETS_FILE, json_targets, expected_incarnation_id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn bucket_replication_config_for_target_refresh(bucket: &str) -> S3Result<Option<ReplicationConfiguration>> {
|
|
match metadata_sys::get_replication_config(bucket).await {
|
|
Ok((config, _)) => Ok(Some(config)),
|
|
Err(StorageError::ConfigNotFound) => Ok(None),
|
|
Err(err) => Err(ApiError::from(err).into()),
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn ensure_site_replication_bucket_replication_config_with_runtime(
|
|
bucket: &str,
|
|
state: &SiteReplicationState,
|
|
local_peer: &PeerInfo,
|
|
service_account_secret_key: &str,
|
|
expected_incarnation_id: Uuid,
|
|
) -> S3Result<()> {
|
|
let existing = match metadata_sys::get_replication_config(bucket).await {
|
|
Ok((existing, _)) => Some(existing),
|
|
Err(StorageError::ConfigNotFound) => None,
|
|
Err(err) => return Err(ApiError::from(err).into()),
|
|
};
|
|
|
|
let Some(desired) = build_site_replication_config(bucket, state, local_peer, service_account_secret_key, existing.as_ref())?
|
|
else {
|
|
return Ok(());
|
|
};
|
|
|
|
// Derived rules are state owned by this site: rebuild them from the current peer
|
|
// set on every pass instead of preserving whatever is on disk. A rule left over
|
|
// from a removed peer — or one whose destination ARN names this very deployment,
|
|
// which no bucket target can ever satisfy — must not survive, otherwise objects
|
|
// are queued against an ARN that resolves to nothing.
|
|
let (existing_role, existing_rules) = existing
|
|
.map(|config| (config.role, config.rules))
|
|
.unwrap_or_else(|| (String::new(), Vec::new()));
|
|
let mut rules: Vec<ReplicationRule> = existing_rules
|
|
.iter()
|
|
.filter(|rule| !is_derived_site_replication_rule(rule))
|
|
.cloned()
|
|
.collect();
|
|
rules.extend(desired.rules);
|
|
// Operator priorities are the operator's policy; only the derived rules
|
|
// take free slots, by the same function as the config merges so a merged
|
|
// write and this pass agree byte for byte.
|
|
assign_site_replication_rule_priorities(&mut rules, is_derived_site_replication_rule);
|
|
|
|
// Only a `role` naming a current peer is ours to drop — an operator-authored role is
|
|
// part of the bucket's S3-visible configuration, and repairing a reverse rule must not
|
|
// quietly rewrite it. Same rule as `merge_incoming_replication_config`.
|
|
let role = if is_site_replication_role(&existing_role, &remote_peer_deployment_ids(state, local_peer)) {
|
|
String::new()
|
|
} else {
|
|
existing_role.clone()
|
|
};
|
|
|
|
if rules == existing_rules && role == existing_role {
|
|
return Ok(());
|
|
}
|
|
|
|
let data = serialize(&ReplicationConfiguration { role, rules })
|
|
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize replication failed: {e}")))?;
|
|
metadata_sys::update_if_incarnation(bucket, BUCKET_REPLICATION_CONFIG, data, expected_incarnation_id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn ensure_site_replication_bucket_setup_with_runtime(
|
|
bucket: &str,
|
|
runtime: &SiteReplicationRuntime,
|
|
) -> S3Result<()> {
|
|
let expected_incarnation_id = metadata_sys::capture_bucket_metadata_incarnation(bucket)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
ensure_site_replication_bucket_setup_with_runtime_for_incarnation(bucket, runtime, expected_incarnation_id).await
|
|
}
|
|
|
|
pub(crate) async fn ensure_site_replication_bucket_setup_with_runtime_for_incarnation(
|
|
bucket: &str,
|
|
runtime: &SiteReplicationRuntime,
|
|
expected_incarnation_id: Uuid,
|
|
) -> S3Result<()> {
|
|
let _targets_guard = lock_bucket_targets_metadata(bucket).await;
|
|
let config = bucket_replication_config_for_target_refresh(bucket).await?;
|
|
ensure_site_replication_bucket_targets_with_runtime(
|
|
bucket,
|
|
&runtime.state,
|
|
&runtime.local_peer,
|
|
config.as_ref(),
|
|
&runtime.service_account_secret_key,
|
|
expected_incarnation_id,
|
|
)
|
|
.await?;
|
|
ensure_site_replication_bucket_replication_config_with_runtime(
|
|
bucket,
|
|
&runtime.state,
|
|
&runtime.local_peer,
|
|
&runtime.service_account_secret_key,
|
|
expected_incarnation_id,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn bucket_versioning_xml() -> S3Result<Vec<u8>> {
|
|
let config = VersioningConfiguration {
|
|
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
|
..Default::default()
|
|
};
|
|
serialize(&config).map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize versioning failed: {e}")))
|
|
}
|
|
|
|
pub(crate) async fn ensure_site_replication_bucket_versioning(bucket: &str) -> S3Result<()> {
|
|
let expected_incarnation_id = metadata_sys::capture_bucket_metadata_incarnation(bucket)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
match metadata_sys::get_versioning_config(bucket).await {
|
|
Ok((config, _)) if config.enabled() => return Ok(()),
|
|
Ok(_) | Err(StorageError::ConfigNotFound) => {}
|
|
Err(err) => return Err(ApiError::from(err).into()),
|
|
}
|
|
|
|
metadata_sys::update_if_incarnation(bucket, BUCKET_VERSIONING_CONFIG, bucket_versioning_xml()?, expected_incarnation_id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
Ok(())
|
|
}
|