mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-13 16:46:55 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 195f19217f |
@@ -85,7 +85,7 @@ runs:
|
||||
repo-token: ${{ github.token }}
|
||||
|
||||
- name: Install flatc
|
||||
uses: Nugine/setup-flatc@698800de72a96bfb22cf60431dc21a2ff9a7e07b # v1
|
||||
uses: Nugine/setup-flatc@e7855e994773ce90094a3f1626d4afc9080c23ae # v1
|
||||
with:
|
||||
version: "25.12.19"
|
||||
|
||||
|
||||
+3
-3
@@ -131,9 +131,9 @@ module split is tracked under `docs/architecture/`.
|
||||
why it stays local).
|
||||
- ✅ RESOLVED: `BackpressureConfig` and `DataUsageInfo` each have exactly one
|
||||
definition (`crates/io-core/src/backpressure.rs`,
|
||||
`crates/data-usage/src/data_usage.rs`). The zero-consumer
|
||||
`BackpressureSettings` copy that lingered in io-metrics was removed
|
||||
(rustfs/backlog#1833).
|
||||
`crates/data-usage/src/data_usage.rs`). A zero-consumer
|
||||
`BackpressureSettings` copy lingers in `crates/io-metrics/src/config.rs`;
|
||||
its removal is tracked in rustfs/backlog#1833.
|
||||
|
||||
4. **ecstore does not know about HTTP or S3 protocol details.** It operates on
|
||||
storage-level abstractions (objects, buckets, disks, pools).
|
||||
|
||||
Generated
+1
-11
@@ -9201,6 +9201,7 @@ dependencies = [
|
||||
"sha2 0.11.0",
|
||||
"shadow-rs",
|
||||
"socket2",
|
||||
"starshard",
|
||||
"subtle",
|
||||
"sysinfo",
|
||||
"temp-env",
|
||||
@@ -9736,7 +9737,6 @@ dependencies = [
|
||||
"rustfs-utils",
|
||||
"rustify",
|
||||
"serde",
|
||||
"serde_ignored",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"subtle",
|
||||
@@ -10939,16 +10939,6 @@ dependencies = [
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_ignored"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
|
||||
@@ -182,7 +182,6 @@ quick-xml = "0.41.0"
|
||||
rmp = { version = "0.8.15" }
|
||||
rmp-serde = { version = "1.3.1" }
|
||||
serde = { version = "1.0.229" }
|
||||
serde_ignored = { version = "0.1" }
|
||||
serde_json = { version = "1.0.151" }
|
||||
serde_urlencoded = "0.7.1"
|
||||
|
||||
|
||||
@@ -21,13 +21,6 @@ use crate::{
|
||||
Xxhash3, Xxhash64, Xxhash128,
|
||||
};
|
||||
|
||||
// DELIBERATE DUPLICATION of the x-amz-checksum-* names that also exist as
|
||||
// AMZ_CHECKSUM_* in rustfs-utils' headers module (crates/utils/src/http/
|
||||
// headers.rs): this crate is a zero-internal-dependency leaf, so it cannot
|
||||
// import them, and it additionally owns the RustFS extension names
|
||||
// (sha512/xxhash*) that utils does not carry. Values are pinned by the S3
|
||||
// wire protocol; do not merge without a maintainer decision on the leaf
|
||||
// boundary (backlog#1833).
|
||||
pub const CRC_32_HEADER_NAME: &str = "x-amz-checksum-crc32";
|
||||
pub const CRC_32_C_HEADER_NAME: &str = "x-amz-checksum-crc32c";
|
||||
pub const SHA_1_HEADER_NAME: &str = "x-amz-checksum-sha1";
|
||||
|
||||
@@ -41,14 +41,6 @@ pub const XXHASH_64_NAME: &str = "xxhash64";
|
||||
pub const XXHASH_128_NAME: &str = "xxhash128";
|
||||
pub const MD5_NAME: &str = "md5";
|
||||
|
||||
/// One of three deliberately separate checksum registries (backlog#1833):
|
||||
/// this enum owns the **streaming-hash algorithm registry**, including the
|
||||
/// RustFS extensions (sha512, xxhash3/64/128). The on-disk xl.meta bitset
|
||||
/// lives in `rustfs_rio::ChecksumType` (crates/rio/src/checksum.rs, varint
|
||||
/// bits are append-only), and the MinIO-port client keeps its own
|
||||
/// `ChecksumMode` (crates/ecstore/src/client/checksum.rs). When adding an
|
||||
/// algorithm, extend all three (or record why not) — they do not derive from
|
||||
/// each other.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[non_exhaustive]
|
||||
pub enum ChecksumAlgorithm {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// 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 crate::last_minute::{self};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct ReplicationLatency {
|
||||
// Delays for single and multipart PUT requests
|
||||
upload_histogram: last_minute::LastMinuteHistogram,
|
||||
}
|
||||
|
||||
impl ReplicationLatency {
|
||||
// Merge two ReplicationLatency
|
||||
pub fn merge(&mut self, other: &mut ReplicationLatency) -> &ReplicationLatency {
|
||||
self.upload_histogram.merge(&other.upload_histogram);
|
||||
self
|
||||
}
|
||||
|
||||
// Get upload delay (categorized by object size interval)
|
||||
pub fn get_upload_latency(&mut self) -> HashMap<String, u64> {
|
||||
let mut ret = HashMap::new();
|
||||
let avg = self.upload_histogram.get_avg_data();
|
||||
for (i, v) in avg.iter().enumerate() {
|
||||
let avg_duration = v.avg();
|
||||
ret.insert(self.size_tag_to_string(i), avg_duration.as_millis() as u64);
|
||||
}
|
||||
ret
|
||||
}
|
||||
pub fn update(&mut self, size: i64, during: std::time::Duration) {
|
||||
self.upload_histogram.add(size, during);
|
||||
}
|
||||
|
||||
// Simulate the conversion from size tag to string
|
||||
fn size_tag_to_string(&self, tag: usize) -> String {
|
||||
match tag {
|
||||
0 => String::from("Size < 1 KiB"),
|
||||
1 => String::from("Size < 1 MiB"),
|
||||
2 => String::from("Size < 10 MiB"),
|
||||
3 => String::from("Size < 100 MiB"),
|
||||
4 => String::from("Size < 1 GiB"),
|
||||
_ => String::from("Size > 1 GiB"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #[derive(Debug, Clone, Default)]
|
||||
// pub struct ReplicationLastMinute {
|
||||
// pub last_minute: LastMinuteLatency,
|
||||
// }
|
||||
|
||||
// impl ReplicationLastMinute {
|
||||
// pub fn merge(&mut self, other: ReplicationLastMinute) -> ReplicationLastMinute {
|
||||
// let mut nl = ReplicationLastMinute::default();
|
||||
// nl.last_minute = self.last_minute.merge(&mut other.last_minute);
|
||||
// nl
|
||||
// }
|
||||
|
||||
// pub fn add_size(&mut self, n: i64) {
|
||||
// let t = SystemTime::now()
|
||||
// .duration_since(UNIX_EPOCH)
|
||||
// .expect("Time went backwards")
|
||||
// .as_secs();
|
||||
// self.last_minute.add_all(t - 1, &AccElem { total: t - 1, size: n as u64, n: 1 });
|
||||
// }
|
||||
|
||||
// pub fn get_total(&self) -> AccElem {
|
||||
// self.last_minute.get_total()
|
||||
// }
|
||||
// }
|
||||
|
||||
// impl fmt::Display for ReplicationLastMinute {
|
||||
// fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
// let t = self.last_minute.get_total();
|
||||
// write!(f, "ReplicationLastMinute sz= {}, n= {}, dur= {}", t.size, t.n, t.total)
|
||||
// }
|
||||
// }
|
||||
@@ -572,3 +572,44 @@ mod tests {
|
||||
assert_eq!(total.n, 6);
|
||||
}
|
||||
}
|
||||
|
||||
const SIZE_LAST_ELEM_MARKER: usize = 10; // Assumed marker size is 10, modify according to actual situation
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Default)]
|
||||
pub struct LastMinuteHistogram {
|
||||
histogram: Vec<LastMinuteLatency>,
|
||||
size: u32,
|
||||
}
|
||||
|
||||
impl LastMinuteHistogram {
|
||||
pub fn merge(&mut self, other: &LastMinuteHistogram) {
|
||||
for i in 0..self.histogram.len() {
|
||||
self.histogram[i].merge(&other.histogram[i]);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&mut self, size: i64, t: Duration) {
|
||||
let index = size_to_tag(size);
|
||||
self.histogram[index].add(&t);
|
||||
}
|
||||
|
||||
pub fn get_avg_data(&mut self) -> [AccElem; SIZE_LAST_ELEM_MARKER] {
|
||||
let mut res = [AccElem::default(); SIZE_LAST_ELEM_MARKER];
|
||||
for (i, elem) in self.histogram.iter_mut().enumerate() {
|
||||
res[i] = elem.get_total();
|
||||
}
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
fn size_to_tag(size: i64) -> usize {
|
||||
match size {
|
||||
_ if size < 1024 => 0, // sizeLessThan1KiB
|
||||
_ if size < 1024 * 1024 => 1, // sizeLessThan1MiB
|
||||
_ if size < 10 * 1024 * 1024 => 2, // sizeLessThan10MiB
|
||||
_ if size < 100 * 1024 * 1024 => 3, // sizeLessThan100MiB
|
||||
_ if size < 1024 * 1024 * 1024 => 4, // sizeLessThan1GiB
|
||||
_ => 5, // sizeGreaterThan1GiB
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
pub mod bucket_stats;
|
||||
// pub mod error;
|
||||
pub mod globals;
|
||||
pub mod heal_channel;
|
||||
|
||||
@@ -81,8 +81,7 @@ pub const ENV_TEST_IAM_FAIL_INIT_ATTEMPTS: &str = "RUSTFS_TEST_IAM_FAIL_INIT_ATT
|
||||
pub const ENV_TEST_IAM_RETRY_INTERVAL_MS: &str = "RUSTFS_TEST_IAM_RETRY_INTERVAL_MS";
|
||||
/// Runtime env var controlling the transition worker count.
|
||||
pub const ENV_TRANSITION_WORKERS: &str = "RUSTFS_MAX_TRANSITION_WORKERS";
|
||||
/// Runtime env var controlling the ILM expiry worker count. A set, parsable,
|
||||
/// non-zero value wins; anything else falls back to `min(cpus, 16)`.
|
||||
/// Runtime env var controlling the expiry worker count.
|
||||
pub const ENV_MAX_EXPIRY_WORKERS: &str = "RUSTFS_MAX_EXPIRY_WORKERS";
|
||||
/// Runtime env var controlling the absolute maximum transition workers.
|
||||
pub const ENV_TRANSITION_WORKERS_ABSOLUTE_MAX: &str = "RUSTFS_ABSOLUTE_MAX_WORKERS";
|
||||
|
||||
@@ -1,612 +0,0 @@
|
||||
// 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.
|
||||
|
||||
//! ILM on SSE-KMS buckets while per-key SSE authorization is enforced (backlog#1582).
|
||||
//!
|
||||
//! Per-key KMS authorization (`RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY=true`) scopes the
|
||||
//! SSE-KMS data path to the requesting principal's `kms:GenerateDataKey` /
|
||||
//! `kms:Decrypt` grants. Internal callers — the lifecycle scanner's expiry deletes
|
||||
//! and the tier transition worker's reads — carry no request principal, and
|
||||
//! `authorize_sse_kms_key` (rustfs/src/storage/sse.rs) exempts a `None` principal
|
||||
//! so background maintenance keeps working on encrypted buckets.
|
||||
//!
|
||||
//! These tests pin that exemption end to end. If enforcement ever starts applying
|
||||
//! to the scanner's internal operations, expiry stops happening on SSE-KMS buckets
|
||||
//! and [`ilm_expiration_on_sse_kms_bucket_under_enforcement`] times out; if it
|
||||
//! starts applying to the transition worker or the read-through path,
|
||||
//! [`ilm_transition_on_sse_kms_bucket_under_enforcement_reads_back`] fails at the
|
||||
//! transition wait or the plaintext round-trip.
|
||||
//!
|
||||
//! The replication half of the same acceptance item lives in
|
||||
//! `crates/e2e_test/src/replication_extension_test.rs`
|
||||
//! (`test_bucket_replication_sse_kms_failure_contract`); ILM had no coverage
|
||||
//! before this file.
|
||||
//!
|
||||
//! Deployment constraint pinned by the transition test's setup: the RustFS warm
|
||||
//! backend forwards the object's stored `x-amz-server-side-encryption*` metadata
|
||||
//! as raw headers on the tier data PUT (`build_transition_put_options` +
|
||||
//! `api_put_object.rs` header mapping), so a RustFS tier target must itself have
|
||||
//! KMS enabled and hold the named key or it rejects every transition upload with
|
||||
//! 400 InvalidRequest. That rejection is independent of the enforcement switch;
|
||||
//! the cold server here therefore runs its own Local KMS with the same key id.
|
||||
|
||||
use super::common::{LocalKMSTestEnvironment, create_key_with_specific_id};
|
||||
use crate::common::{RustFSTestEnvironment, admin_request, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketLifecycleConfiguration, ExpirationStatus, LifecycleExpiration, LifecycleRule, LifecycleRuleFilter, RestoreRequest,
|
||||
ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule, Transition,
|
||||
TransitionStorageClass,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serial_test::serial;
|
||||
use std::time::{Duration as StdDuration, Instant};
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
const SSE_KEY: &str = "kms-ilm-sse-key";
|
||||
const PAYLOAD: &[u8] = b"kms ilm sse payload: survives enforcement, expires and transitions on schedule";
|
||||
|
||||
const EXPIRY_BUCKET: &str = "kms-ilm-expiry";
|
||||
const EXPIRE_KEY: &str = "expire/object.bin";
|
||||
const SURVIVOR_KEY: &str = "keep/object.bin";
|
||||
|
||||
const TIER_NAME: &str = "KMSCOLD";
|
||||
const TIER_BUCKET: &str = "kms-ilm-cold-tier";
|
||||
const TIER_PREFIX: &str = "tiered";
|
||||
const TRANSITION_BUCKET: &str = "kms-ilm-transition";
|
||||
const TRANSITION_KEY: &str = "tier/object.bin";
|
||||
|
||||
/// Generous CI safety net; with a 1s scanner cycle and 2s lifecycle days the
|
||||
/// terminal state normally lands within a few seconds.
|
||||
const ILM_DEADLINE: StdDuration = StdDuration::from_secs(90);
|
||||
|
||||
/// Start a Local-KMS server with per-key SSE authorization enforced and the
|
||||
/// lifecycle clock accelerated.
|
||||
///
|
||||
/// KMS wiring matches `kms_authorization_negative_matrix_test.rs` (local backend,
|
||||
/// `--kms-default-key-id`, insecure dev defaults). The lifecycle env matches
|
||||
/// `reliant/lifecycle.rs::fast_lifecycle_env` plus `RUSTFS_ILM_DEBUG_DAY_SECS=2`,
|
||||
/// so a `Days=1` rule is due about two seconds after the write.
|
||||
async fn start_enforcing_ilm_server(env: &mut LocalKMSTestEnvironment) -> TestResult {
|
||||
create_key_with_specific_id(&env.kms_keys_dir, SSE_KEY).await?;
|
||||
|
||||
let key_dir = env.kms_keys_dir.clone();
|
||||
let args = vec![
|
||||
"--kms-enable",
|
||||
"--kms-backend",
|
||||
"local",
|
||||
"--kms-key-dir",
|
||||
key_dir.as_str(),
|
||||
"--kms-default-key-id",
|
||||
SSE_KEY,
|
||||
];
|
||||
|
||||
let envs = [
|
||||
("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true"),
|
||||
("RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY", "false"),
|
||||
("RUSTFS_SCANNER_CYCLE", "1"),
|
||||
("RUSTFS_ILM_PROCESS_TIME", "1"),
|
||||
("RUSTFS_ILM_DEBUG_DAY_SECS", "2"),
|
||||
];
|
||||
|
||||
env.base_env.start_rustfs_server_with_env(args, &envs).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the bucket's default encryption to SSE-KMS under [`SSE_KEY`], so plain
|
||||
/// PUTs (and internal rewrites) are encrypted without per-request SSE headers.
|
||||
async fn set_bucket_default_sse_kms(client: &Client, bucket: &str) -> TestResult {
|
||||
let encryption_config = ServerSideEncryptionConfiguration::builder()
|
||||
.rules(
|
||||
ServerSideEncryptionRule::builder()
|
||||
.apply_server_side_encryption_by_default(
|
||||
ServerSideEncryptionByDefault::builder()
|
||||
.sse_algorithm(ServerSideEncryption::AwsKms)
|
||||
.kms_master_key_id(SSE_KEY)
|
||||
.build()?,
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build()?;
|
||||
client
|
||||
.put_bucket_encryption()
|
||||
.bucket(bucket)
|
||||
.server_side_encryption_configuration(encryption_config)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Assert via `HeadObject` that the stored object is SSE-KMS encrypted under
|
||||
/// [`SSE_KEY`]. Without this, a bucket-default misconfiguration would let the
|
||||
/// tests pass on an unencrypted object and prove nothing about KMS.
|
||||
async fn assert_head_sse_kms(client: &Client, bucket: &str, key: &str) -> TestResult {
|
||||
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||
assert_eq!(
|
||||
head.server_side_encryption(),
|
||||
Some(&ServerSideEncryption::AwsKms),
|
||||
"{bucket}/{key} must be SSE-KMS encrypted via the bucket default"
|
||||
);
|
||||
assert_eq!(
|
||||
head.ssekms_key_id(),
|
||||
Some(SSE_KEY),
|
||||
"{bucket}/{key} must be wrapped under the configured KMS key"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` once `GET bucket/key` fails with `NoSuchKey`, `false` while it
|
||||
/// still succeeds. Any other error is surfaced. (Copied from
|
||||
/// `reliant/lifecycle.rs`; that helper is private to the reliant module.)
|
||||
async fn object_is_gone(client: &Client, bucket: &str, key: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
match client.get_object().bucket(bucket).key(key).send().await {
|
||||
Ok(output) => {
|
||||
output.body.collect().await?;
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(service_error) = e.as_service_error() {
|
||||
if service_error.is_no_such_key() {
|
||||
return Ok(true);
|
||||
}
|
||||
return Err(format!("expected NoSuchKey, got: {e:?}").into());
|
||||
}
|
||||
Err(format!("expected a service error, got: {e:?}").into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll until `GET bucket/key` returns `NoSuchKey`, or fail after `deadline`.
|
||||
async fn wait_for_object_expired(client: &Client, bucket: &str, key: &str, deadline: StdDuration) -> TestResult {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if object_is_gone(client, bucket, key).await? {
|
||||
return Ok(());
|
||||
}
|
||||
if start.elapsed() >= deadline {
|
||||
return Err(format!(
|
||||
"object {bucket}/{key} was not expired by the lifecycle scanner within {}s; \
|
||||
SSE key-policy enforcement may have started blocking the scanner's internal deletes",
|
||||
deadline.as_secs()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
tokio::time::sleep(StdDuration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a prefix-scoped `Days`-based expiration rule.
|
||||
async fn put_expiration_rule(client: &Client, bucket: &str, id: &str, prefix: &str, days: i32) -> TestResult {
|
||||
let rule = LifecycleRule::builder()
|
||||
.id(id)
|
||||
.filter(LifecycleRuleFilter::builder().prefix(prefix).build())
|
||||
.expiration(LifecycleExpiration::builder().days(days).build())
|
||||
.status(ExpirationStatus::Enabled)
|
||||
.build()?;
|
||||
let lifecycle = BucketLifecycleConfiguration::builder().rules(rule).build()?;
|
||||
client
|
||||
.put_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.lifecycle_configuration(lifecycle)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install a prefix-scoped `Days`-based transition rule targeting [`TIER_NAME`].
|
||||
async fn put_transition_rule(client: &Client, bucket: &str, id: &str, prefix: &str, days: i32) -> TestResult {
|
||||
let rule = LifecycleRule::builder()
|
||||
.id(id)
|
||||
.filter(LifecycleRuleFilter::builder().prefix(prefix).build())
|
||||
.transitions(
|
||||
Transition::builder()
|
||||
.days(days)
|
||||
.storage_class(TransitionStorageClass::from(TIER_NAME))
|
||||
.build(),
|
||||
)
|
||||
.status(ExpirationStatus::Enabled)
|
||||
.build()?;
|
||||
let lifecycle = BucketLifecycleConfiguration::builder().rules(rule).build()?;
|
||||
client
|
||||
.put_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.lifecycle_configuration(lifecycle)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a plain Local-KMS server (no enforcement, no lifecycle acceleration)
|
||||
/// holding [`SSE_KEY`], to serve as the cold tier target.
|
||||
///
|
||||
/// The RustFS warm backend forwards the stored SSE-KMS headers on the tier data
|
||||
/// PUT, so the target re-applies managed SSE-KMS under the named key and must
|
||||
/// be able to resolve it; without KMS it answers 400 InvalidRequest and the
|
||||
/// transition can never complete. Enforcement stays off here: the tier writes
|
||||
/// arrive under `cold`'s root credentials, and one enforcing side is enough to
|
||||
/// pin the exemption.
|
||||
async fn start_cold_tier_kms_server(env: &mut LocalKMSTestEnvironment) -> TestResult {
|
||||
create_key_with_specific_id(&env.kms_keys_dir, SSE_KEY).await?;
|
||||
|
||||
let key_dir = env.kms_keys_dir.clone();
|
||||
let args = vec![
|
||||
"--kms-enable",
|
||||
"--kms-backend",
|
||||
"local",
|
||||
"--kms-key-dir",
|
||||
key_dir.as_str(),
|
||||
"--kms-default-key-id",
|
||||
SSE_KEY,
|
||||
];
|
||||
|
||||
env.base_env
|
||||
.start_rustfs_server_with_env(args, &[("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true")])
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The subset of the manual transition run report these tests assert on.
|
||||
///
|
||||
/// Unknown fields are ignored, so this stays compatible with report growth; the
|
||||
/// full shape is pinned by `reliant/tiering.rs`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ManualTransitionRunReport {
|
||||
#[serde(default)]
|
||||
scanned: u64,
|
||||
#[serde(default)]
|
||||
enqueued: u64,
|
||||
#[serde(default)]
|
||||
skipped_already_in_flight: u64,
|
||||
#[serde(default)]
|
||||
skipped_tier: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ManualTransitionRunResponse {
|
||||
state: String,
|
||||
report: ManualTransitionRunReport,
|
||||
}
|
||||
|
||||
/// One synchronous (enqueue-only) manual transition run over `bucket/prefix`,
|
||||
/// via the same admin endpoint `reliant/tiering.rs` drives.
|
||||
async fn manual_transition_run(
|
||||
hot: &RustFSTestEnvironment,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
) -> Result<ManualTransitionRunResponse, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let bucket = urlencoding::encode(bucket);
|
||||
let prefix = urlencoding::encode(prefix);
|
||||
let tier = urlencoding::encode(TIER_NAME);
|
||||
let path =
|
||||
format!("/rustfs/admin/v3/ilm/transition/run?bucket={bucket}&prefix={prefix}&tier={tier}&dryRun=false&maxObjects=10");
|
||||
let (status, body) = admin_request(&hot.url, http::Method::POST, &path, None, &hot.access_key, &hot.secret_key).await?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("manual transition run failed: status={status}, body={body}").into());
|
||||
}
|
||||
Ok(serde_json::from_str(&body)?)
|
||||
}
|
||||
|
||||
/// Drive manual transition runs until one reports the object as processed.
|
||||
///
|
||||
/// The `Days=1` rule becomes due about two seconds after the write
|
||||
/// (`RUSTFS_ILM_DEBUG_DAY_SECS=2`), so early runs may legitimately report the
|
||||
/// object as not yet eligible; the loop keeps running the endpoint until it
|
||||
/// either enqueues the transition, sees it already in flight (the 1s scanner
|
||||
/// backstop got there first), or finds it already on the tier.
|
||||
async fn run_manual_transition_until_processed(
|
||||
hot: &RustFSTestEnvironment,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
deadline: StdDuration,
|
||||
) -> TestResult {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
let run = manual_transition_run(hot, bucket, prefix).await?;
|
||||
assert_eq!(run.report.scanned, 1, "manual transition run must scan the object: {run:#?}");
|
||||
if run.report.enqueued + run.report.skipped_already_in_flight + run.report.skipped_tier >= 1 {
|
||||
info!(state = %run.state, report = ?run.report, "manual transition run processed the SSE-KMS object");
|
||||
return Ok(());
|
||||
}
|
||||
if start.elapsed() >= deadline {
|
||||
return Err(format!(
|
||||
"manual transition runs never processed {bucket}/{prefix} within {}s; last report: {run:#?}",
|
||||
deadline.as_secs()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
tokio::time::sleep(StdDuration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire `hot` -> `cold` as a `TierType::RustFS` remote tier via `AddTier`.
|
||||
///
|
||||
/// No `force`, so the server runs the real connectivity probe against `cold`
|
||||
/// (the tier bucket must already exist there). Mirrors
|
||||
/// `reliant/tiering.rs::add_rustfs_tier`, which is private to that module.
|
||||
async fn add_rustfs_tier(hot: &RustFSTestEnvironment, cold: &RustFSTestEnvironment) -> TestResult {
|
||||
let body = serde_json::json!({
|
||||
"type": "rustfs",
|
||||
"rustfs": {
|
||||
"name": TIER_NAME,
|
||||
"endpoint": cold.url.as_str(),
|
||||
"accessKey": cold.access_key.as_str(),
|
||||
"secretKey": cold.secret_key.as_str(),
|
||||
"bucket": TIER_BUCKET,
|
||||
"prefix": TIER_PREFIX,
|
||||
"region": "us-east-1",
|
||||
"storageClass": ""
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let (status, resp) = admin_request(
|
||||
&hot.url,
|
||||
http::Method::PUT,
|
||||
"/rustfs/admin/v3/tier",
|
||||
Some(body),
|
||||
&hot.access_key,
|
||||
&hot.secret_key,
|
||||
)
|
||||
.await?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("AddTier(RustFS) failed: status={status}, body={resp}").into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll `HEAD` until the object's storage class is the tier name (transition
|
||||
/// complete), or fail after `deadline`. (From `reliant/tiering.rs`.)
|
||||
async fn wait_for_transition(client: &Client, bucket: &str, key: &str, deadline: StdDuration) -> TestResult {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||
if head.storage_class().map(|sc| sc.as_str()) == Some(TIER_NAME) {
|
||||
return Ok(());
|
||||
}
|
||||
if start.elapsed() >= deadline {
|
||||
return Err(format!(
|
||||
"object {bucket}/{key} was not transitioned to {TIER_NAME} within {}s (storage_class={:?}); \
|
||||
SSE key-policy enforcement may have started blocking the transition worker's internal reads",
|
||||
deadline.as_secs(),
|
||||
head.storage_class()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
tokio::time::sleep(StdDuration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll `HEAD` until `x-amz-restore` reports a finished restore
|
||||
/// (`ongoing-request="false"`), or fail after `deadline`.
|
||||
async fn wait_for_restore_complete(client: &Client, bucket: &str, key: &str, deadline: StdDuration) -> TestResult {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||
if head.restore().is_some_and(|r| r.contains("ongoing-request=\"false\"")) {
|
||||
return Ok(());
|
||||
}
|
||||
if start.elapsed() >= deadline {
|
||||
return Err(format!(
|
||||
"object {bucket}/{key} restore did not complete within {}s (restore={:?}); \
|
||||
SSE key-policy enforcement may have started blocking the restore copy-back's internal reads",
|
||||
deadline.as_secs(),
|
||||
head.restore()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
tokio::time::sleep(StdDuration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// ILM expiration keeps working on an SSE-KMS bucket while per-key SSE
|
||||
/// authorization is enforced.
|
||||
///
|
||||
/// The lifecycle scanner deletes expired objects with an internal (no-principal)
|
||||
/// identity that holds no `kms` grant. If enforcement ever starts applying to
|
||||
/// those internal deletes (or to the scanner's metadata reads) on encrypted
|
||||
/// buckets, expiry stops happening and this test times out.
|
||||
///
|
||||
/// A survivor object under a non-matching prefix isolates the rule's prefix
|
||||
/// filter as the cause of the deletion and proves the encrypted bucket stays
|
||||
/// readable end to end after the scanner has run.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn ilm_expiration_on_sse_kms_bucket_under_enforcement() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut env = LocalKMSTestEnvironment::new().await?;
|
||||
start_enforcing_ilm_server(&mut env).await?;
|
||||
env.base_env.create_test_bucket(EXPIRY_BUCKET).await?;
|
||||
|
||||
let client = env.base_env.create_s3_client();
|
||||
set_bucket_default_sse_kms(&client, EXPIRY_BUCKET).await?;
|
||||
|
||||
for key in [EXPIRE_KEY, SURVIVOR_KEY] {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(EXPIRY_BUCKET)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(PAYLOAD))
|
||||
.send()
|
||||
.await?;
|
||||
assert_head_sse_kms(&client, EXPIRY_BUCKET, key).await?;
|
||||
}
|
||||
info!("both objects stored SSE-KMS encrypted under enforcement");
|
||||
|
||||
put_expiration_rule(&client, EXPIRY_BUCKET, "kms-ilm-expire", "expire/", 1).await?;
|
||||
|
||||
// The regression this pins: the scanner's internal delete must stay exempt
|
||||
// from per-key SSE authorization, so the encrypted object actually expires.
|
||||
wait_for_object_expired(&client, EXPIRY_BUCKET, EXPIRE_KEY, ILM_DEADLINE).await?;
|
||||
info!("SSE-KMS object expired by the lifecycle scanner under enforcement");
|
||||
|
||||
// Negative control: same bucket, same encryption, non-matching prefix. It
|
||||
// must survive the scanner and still decrypt for the requesting principal.
|
||||
assert!(
|
||||
!object_is_gone(&client, EXPIRY_BUCKET, SURVIVOR_KEY).await?,
|
||||
"non-matching-prefix object must not be expired by a prefix-scoped rule"
|
||||
);
|
||||
let survivor = client.get_object().bucket(EXPIRY_BUCKET).key(SURVIVOR_KEY).send().await?;
|
||||
assert_eq!(
|
||||
survivor.body.collect().await?.into_bytes().as_ref(),
|
||||
PAYLOAD,
|
||||
"surviving SSE-KMS object must still decrypt after the scanner has run"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ILM transition to a remote tier keeps working on an SSE-KMS bucket while
|
||||
/// per-key SSE authorization is enforced, and the transitioned object reads
|
||||
/// back as plaintext.
|
||||
///
|
||||
/// The transition worker moves the stored (encrypted) bytes to the cold tier
|
||||
/// with an internal (no-principal) identity; the read-through `GET` then
|
||||
/// decrypts the envelope for the requesting principal. If enforcement ever
|
||||
/// starts applying to the worker's internal reads, the transition wait times
|
||||
/// out; if the stored envelope is mishandled across the tier round trip, the
|
||||
/// plaintext comparison fails.
|
||||
///
|
||||
/// The transition is driven through the manual transition-run admin endpoint
|
||||
/// (the mechanism `reliant/tiering.rs` established), so the test does not
|
||||
/// depend on scanner scheduling; the 1s scanner cycle stays on as a backstop.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "pins rustfs/rustfs#6025: GET on a transitioned managed-SSE object silently returns corrupt bytes (fails with enforcement on AND off, so it is not an authorization regression); un-ignore with the fix"]
|
||||
async fn ilm_transition_on_sse_kms_bucket_under_enforcement_reads_back() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
// Cold-tier server: independent credentials, its own Local KMS holding the
|
||||
// same key id (see the module docs for why the tier target needs KMS).
|
||||
// Started first; each server's startup cleanup only matches its own unique
|
||||
// address and temp dir, so the two instances coexist.
|
||||
let mut cold = LocalKMSTestEnvironment::new().await?;
|
||||
cold.base_env.access_key = "kmscoldtieradmin".to_string();
|
||||
cold.base_env.secret_key = "kmscoldtiersecret".to_string();
|
||||
start_cold_tier_kms_server(&mut cold).await?;
|
||||
let cold_client = cold.base_env.create_s3_client();
|
||||
cold_client.create_bucket().bucket(TIER_BUCKET).send().await?;
|
||||
|
||||
// Hot server: Local KMS + enforcement + accelerated lifecycle clock.
|
||||
let mut env = LocalKMSTestEnvironment::new().await?;
|
||||
start_enforcing_ilm_server(&mut env).await?;
|
||||
let hot_client = env.base_env.create_s3_client();
|
||||
|
||||
add_rustfs_tier(&env.base_env, &cold.base_env).await?;
|
||||
|
||||
env.base_env.create_test_bucket(TRANSITION_BUCKET).await?;
|
||||
set_bucket_default_sse_kms(&hot_client, TRANSITION_BUCKET).await?;
|
||||
|
||||
hot_client
|
||||
.put_object()
|
||||
.bucket(TRANSITION_BUCKET)
|
||||
.key(TRANSITION_KEY)
|
||||
.body(ByteStream::from_static(PAYLOAD))
|
||||
.send()
|
||||
.await?;
|
||||
assert_head_sse_kms(&hot_client, TRANSITION_BUCKET, TRANSITION_KEY).await?;
|
||||
info!("object stored SSE-KMS encrypted under enforcement");
|
||||
|
||||
// Days=1 is due ~2s after the write with RUSTFS_ILM_DEBUG_DAY_SECS=2.
|
||||
put_transition_rule(&hot_client, TRANSITION_BUCKET, "kms-ilm-transition", "tier/", 1).await?;
|
||||
|
||||
// Drive the transition deterministically via the manual run endpoint, then
|
||||
// wait for HEAD to report the tier as the object's storage class.
|
||||
run_manual_transition_until_processed(&env.base_env, TRANSITION_BUCKET, "tier/", ILM_DEADLINE).await?;
|
||||
wait_for_transition(&hot_client, TRANSITION_BUCKET, TRANSITION_KEY, ILM_DEADLINE).await?;
|
||||
info!("SSE-KMS object transitioned to the remote tier under enforcement");
|
||||
|
||||
let head = hot_client
|
||||
.head_object()
|
||||
.bucket(TRANSITION_BUCKET)
|
||||
.key(TRANSITION_KEY)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
head.restore().is_none(),
|
||||
"a freshly transitioned object must not advertise x-amz-restore, got {:?}",
|
||||
head.restore()
|
||||
);
|
||||
|
||||
// The remote copy exists on the cold tier. The payload the tier holds is the
|
||||
// hot server's stored ciphertext, wrapped once more under the cold server's
|
||||
// own managed SSE-KMS layer (the forwarded headers re-request encryption).
|
||||
let remote = cold_client.list_objects_v2().bucket(TIER_BUCKET).send().await?;
|
||||
assert!(!remote.contents().is_empty(), "cold-tier bucket must hold the transitioned object's data");
|
||||
|
||||
// Read-through GET under enforcement must succeed (not AccessDenied) and
|
||||
// keep advertising SSE-KMS. Its BODY is deliberately not compared here:
|
||||
// the transitioned read path skips managed-SSE decryption — a product gap
|
||||
// unrelated to enforcement — so a direct GET streams the stored ciphertext
|
||||
// (`new_getobjectreader` in crates/ecstore/src/client/object_api_utils.rs
|
||||
// hardcodes `is_encrypted = false` and never applies the
|
||||
// `ReadTransform::Encrypted` wrapping the hot-read path builds in
|
||||
// crates/ecstore/src/object_api/readers.rs). Plaintext recovery is pinned
|
||||
// through restore semantics below; when the read-through gap is fixed, a
|
||||
// byte assertion can be added here too.
|
||||
let read_through = hot_client
|
||||
.get_object()
|
||||
.bucket(TRANSITION_BUCKET)
|
||||
.key(TRANSITION_KEY)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
read_through.server_side_encryption(),
|
||||
Some(&ServerSideEncryption::AwsKms),
|
||||
"transitioned object must still report SSE-KMS on read-through"
|
||||
);
|
||||
let read_through_body = read_through.body.collect().await?.into_bytes();
|
||||
assert_eq!(
|
||||
read_through_body.len(),
|
||||
PAYLOAD.len(),
|
||||
"read-through GET must stream the object's full logical size under enforcement"
|
||||
);
|
||||
|
||||
// RestoreObject copies the ciphertext back from the tier under the original
|
||||
// envelope metadata; the restored copy is then served by the normal
|
||||
// decrypting read path. The copy-back runs with an internal (no-principal)
|
||||
// identity, so this also pins the exemption on the restore path. Days=300
|
||||
// because RUSTFS_ILM_DEBUG_DAY_SECS=2 accelerates the restored copy's
|
||||
// expiry as well (300 accelerated days == 600s of validity).
|
||||
hot_client
|
||||
.restore_object()
|
||||
.bucket(TRANSITION_BUCKET)
|
||||
.key(TRANSITION_KEY)
|
||||
.restore_request(RestoreRequest::builder().days(300).build())
|
||||
.send()
|
||||
.await?;
|
||||
wait_for_restore_complete(&hot_client, TRANSITION_BUCKET, TRANSITION_KEY, ILM_DEADLINE).await?;
|
||||
info!("SSE-KMS object restored from the remote tier under enforcement");
|
||||
|
||||
// The KMS-relevant half: the restored envelope decrypts back to the exact
|
||||
// plaintext for the requesting principal.
|
||||
let restored = hot_client
|
||||
.get_object()
|
||||
.bucket(TRANSITION_BUCKET)
|
||||
.key(TRANSITION_KEY)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
restored.server_side_encryption(),
|
||||
Some(&ServerSideEncryption::AwsKms),
|
||||
"restored object must still report SSE-KMS"
|
||||
);
|
||||
let body = restored.body.collect().await?.into_bytes();
|
||||
assert_eq!(body.as_ref(), PAYLOAD, "restored SSE-KMS object must round-trip byte-identical plaintext");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -59,6 +59,3 @@ mod configured_roundtrip_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod kms_authorization_negative_matrix_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod kms_ilm_sse_kms_test;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2854,7 +2854,7 @@ pub(crate) mod cmptst_30 {
|
||||
result
|
||||
}
|
||||
|
||||
#[ignore = "timing-sensitive backend-pressure latency probe; run explicitly with --ignored"]
|
||||
#[ignore]
|
||||
#[tokio::test]
|
||||
async fn regression() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
crate::common::init_logging();
|
||||
|
||||
@@ -2401,20 +2401,15 @@ async fn wait_for_site_replication_info<F>(
|
||||
where
|
||||
F: Fn(&SiteReplicationInfo) -> bool,
|
||||
{
|
||||
// 30s to match wait_for_replication_state: the three-node site tests run
|
||||
// several full rustfs processes on one runner, so peer-state propagation
|
||||
// can take well over 10s under CI load.
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
for _ in 0..40 {
|
||||
let info = site_replication_info(env).await?;
|
||||
if predicate(&info) {
|
||||
return Ok(info);
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(format!("site replication info did not reach expected state on {}", env.address).into());
|
||||
}
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
Err(format!("site replication info did not reach expected state on {}", env.address).into())
|
||||
}
|
||||
|
||||
async fn wait_for_site_replication_status<F>(
|
||||
@@ -2425,19 +2420,15 @@ async fn wait_for_site_replication_status<F>(
|
||||
where
|
||||
F: Fn(&SRStatusInfo) -> bool,
|
||||
{
|
||||
// Same 30s ceiling as wait_for_site_replication_info: the status probes
|
||||
// fan out to every peer, so they see the same multi-process CI load.
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
for _ in 0..40 {
|
||||
let status = site_replication_status(env, query).await?;
|
||||
if predicate(&status) {
|
||||
return Ok(status);
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(format!("site replication status did not reach expected state on {}", env.address).into());
|
||||
}
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
Err(format!("site replication status did not reach expected state on {}", env.address).into())
|
||||
}
|
||||
|
||||
async fn wait_for_replication_reset_target<F>(
|
||||
@@ -4244,49 +4235,37 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
"tag rule with disabled delete-marker replication created a marker: {tagged_state:?}"
|
||||
);
|
||||
|
||||
// AWS S3 and MinIO both reject suspending versioning on a bucket that
|
||||
// carries a replication configuration (InvalidBucketState): suspension
|
||||
// would mint null versions that versioned replication can never converge.
|
||||
let suspend_err = source_client
|
||||
.put_bucket_versioning()
|
||||
.bucket(source_bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Suspended)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("suspending versioning on a replication source must be rejected");
|
||||
assert_eq!(
|
||||
suspend_err.as_service_error().and_then(|error| error.code()),
|
||||
Some("InvalidBucketState"),
|
||||
"suspension on a replication source must fail with InvalidBucketState: {suspend_err:?}"
|
||||
);
|
||||
|
||||
// The rejected suspension must leave the versioning + replication state
|
||||
// fully intact: a fresh matched PUT still replicates with a real version.
|
||||
let post_reject_put = source_client
|
||||
set_bucket_versioning(&source_env, source_bucket, BucketVersioningStatus::Suspended).await?;
|
||||
set_bucket_versioning(&target_env_a, target_bucket_a, BucketVersioningStatus::Suspended).await?;
|
||||
let null_put = source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key("prefix/after-rejected-suspend.txt")
|
||||
.body(ByteStream::from_static(b"still replicating"))
|
||||
.key("prefix/null.txt")
|
||||
.body(ByteStream::from_static(b"null version"))
|
||||
.send()
|
||||
.await?;
|
||||
let post_reject_version_id = post_reject_put
|
||||
.version_id()
|
||||
.ok_or("PUT after rejected suspension omitted version ID")?
|
||||
.to_string();
|
||||
wait_for_replication_state(
|
||||
&target_client_a,
|
||||
target_bucket_a,
|
||||
"replication stopped after rejected versioning suspension",
|
||||
|state| {
|
||||
state
|
||||
.iter()
|
||||
.any(|entry| entry.key == "prefix/after-rejected-suspend.txt" && entry.version_id == post_reject_version_id)
|
||||
},
|
||||
)
|
||||
assert!(null_put.version_id().is_none(), "suspended source PUT must create a null version");
|
||||
wait_for_replication_state(&target_client_a, target_bucket_a, "null version did not replicate", |state| {
|
||||
state
|
||||
.iter()
|
||||
.any(|entry| entry.key == "prefix/null.txt" && entry.version_id == "null" && !entry.delete_marker)
|
||||
})
|
||||
.await?;
|
||||
let null_delete = source_client
|
||||
.delete_object()
|
||||
.bucket(source_bucket)
|
||||
.key("prefix/null.txt")
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
null_delete.version_id().is_none(),
|
||||
"suspended source DELETE must create a null delete marker"
|
||||
);
|
||||
wait_for_replication_state(&target_client_a, target_bucket_a, "null delete marker did not replicate", |state| {
|
||||
state
|
||||
.iter()
|
||||
.any(|entry| entry.key == "prefix/null.txt" && entry.version_id == "null" && entry.delete_marker)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -32,11 +32,6 @@ workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Compiles the controlled list-objects namespace-journal chaos injector into a
|
||||
# production binary (it is always available to tests). Off by default so the
|
||||
# RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_* env vars cannot rewrite journal
|
||||
# state in a stock build (backlog#1832).
|
||||
list-chaos = []
|
||||
rio-v2 = ["dep:rustfs-rio-v2"]
|
||||
hotpath = [
|
||||
"hotpath/hotpath",
|
||||
|
||||
@@ -61,11 +61,9 @@ pub mod bucket {
|
||||
delete_manual_transition_scope_admission_if_current, load_manual_transition_job_record,
|
||||
load_manual_transition_job_record_with_etag, load_manual_transition_scope_admission,
|
||||
manual_transition_job_lease_expired, manual_transition_scope_admission_lease_expired,
|
||||
manual_transition_scope_key, persist_manual_transition_job_progress,
|
||||
persist_manual_transition_job_progress_if_owned, renew_manual_transition_job_lease,
|
||||
renew_manual_transition_job_lease_if_owned, request_manual_transition_job_cancel,
|
||||
save_manual_transition_job_record, save_manual_transition_job_record_if_current,
|
||||
save_manual_transition_scope_admission_if_absent, update_manual_transition_job_record,
|
||||
manual_transition_scope_key, persist_manual_transition_job_progress, renew_manual_transition_job_lease,
|
||||
request_manual_transition_job_cancel, save_manual_transition_job_record,
|
||||
save_manual_transition_job_record_if_current, save_manual_transition_scope_admission_if_absent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -346,7 +344,7 @@ pub mod disk {
|
||||
}
|
||||
|
||||
pub mod error {
|
||||
pub use crate::disk::error::{DiskError, Error, FileAccessDeniedWithContext, Result};
|
||||
pub use crate::disk::error::{BitrotErrorType, DiskError, Error, FileAccessDeniedWithContext, Result};
|
||||
}
|
||||
|
||||
pub mod error_reduce {
|
||||
|
||||
@@ -27,10 +27,9 @@ use crate::bucket::lifecycle::manual_transition_job::{
|
||||
ManualTransitionWorkerResult, claim_manual_transition_scope_admission, delete_manual_transition_scope_admission_if_current,
|
||||
load_manual_transition_job_record, load_manual_transition_job_record_with_etag, load_manual_transition_pending_task_records,
|
||||
manual_transition_job_id_from_record_object_name, manual_transition_job_lease_expired,
|
||||
manual_transition_worker_result_task_key, persist_manual_transition_job_progress_if_owned,
|
||||
reconcile_manual_transition_worker_results_if_owned, record_manual_transition_worker_result,
|
||||
record_manual_transition_worker_result_with_reason, renew_manual_transition_job_lease_if_owned,
|
||||
save_manual_transition_job_record_if_current, save_manual_transition_task_if_absent, update_manual_transition_job_record,
|
||||
manual_transition_worker_result_task_key, persist_manual_transition_job_progress, reconcile_manual_transition_worker_results,
|
||||
record_manual_transition_worker_result, record_manual_transition_worker_result_with_reason,
|
||||
renew_manual_transition_job_lease, save_manual_transition_job_record_if_current, save_manual_transition_task_if_absent,
|
||||
};
|
||||
use crate::bucket::lifecycle::replication_sink;
|
||||
use crate::bucket::lifecycle::replication_sink::{
|
||||
@@ -160,6 +159,8 @@ const TIER_FREE_VERSION_RECOVERY_MAX_IDLE_INTERVAL: StdDuration = StdDuration::f
|
||||
const TIER_FREE_VERSION_RECOVERY_JITTER_PERCENT: u64 = 10;
|
||||
const DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS: i64 = 5;
|
||||
const EXPIRY_WORKER_QUEUE_CAPACITY: usize = 1000;
|
||||
/// Maximum expiry workers used as a local fallback when runtime env is unset.
|
||||
const DEFAULT_EXPIRY_WORKERS_CAP: usize = 16;
|
||||
const DEFAULT_MANUAL_TRANSITION_JOB_RECOVERY_LIMIT: usize = 100;
|
||||
|
||||
// Phase 5 (backlog#939): lifecycle expiry/transition state moved into the
|
||||
@@ -205,6 +206,15 @@ fn resolve_transition_queue_send_timeout() -> StdDuration {
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_expiry_worker_count() -> usize {
|
||||
let fallback = std::cmp::min(num_cpus::get(), DEFAULT_EXPIRY_WORKERS_CAP);
|
||||
env::var(ENV_MAX_EXPIRY_WORKERS)
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
fn is_immediate_transition_source(src: &LcEventSrc) -> bool {
|
||||
matches!(
|
||||
src,
|
||||
@@ -2017,25 +2027,8 @@ fn is_slow_down(err: &Error) -> bool {
|
||||
matches!(err, Error::SlowDown)
|
||||
}
|
||||
|
||||
/// Resolves the expiry worker count from the single documented knob,
|
||||
/// `RUSTFS_MAX_EXPIRY_WORKERS`: a set, parsable, non-zero value wins;
|
||||
/// anything else falls back to `min(cpus, 16)`. The historical
|
||||
/// `_RUSTFS_ILM_EXPIRATION_WORKERS` silent override and the
|
||||
/// `RUSTFS_DEFAULT_EXPIRY_WORKERS` zero-fallback were undocumented, unset in
|
||||
/// every known deployment, and are removed (backlog#1832).
|
||||
fn expiry_worker_count() -> usize {
|
||||
let default = std::cmp::min(num_cpus::get(), 16);
|
||||
match env::var(ENV_MAX_EXPIRY_WORKERS) {
|
||||
Ok(value) => match value.parse::<usize>() {
|
||||
Ok(workers) if workers > 0 => workers,
|
||||
_ => default,
|
||||
},
|
||||
Err(_) => default,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn init_background_expiry(api: Arc<ECStore>) {
|
||||
let workers = expiry_worker_count();
|
||||
let workers = resolve_expiry_worker_count();
|
||||
|
||||
ExpiryState::resize_workers(workers, api.clone()).await;
|
||||
let _ = spawn_tier_free_version_recovery_once(api.clone(), &TIER_FREE_VERSION_RECOVERY_STARTED);
|
||||
@@ -2220,18 +2213,7 @@ async fn recover_manual_transition_job(
|
||||
|
||||
let recovery_unknown_snapshot = ManualTransitionQueueSnapshot::default();
|
||||
if record.scan_completed {
|
||||
let reconciled = match reconcile_manual_transition_worker_results_if_owned(
|
||||
api.clone(),
|
||||
job_id,
|
||||
record.lease_id,
|
||||
recovery_unknown_snapshot,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(record) => record,
|
||||
Err(Error::PreconditionFailed) => return Ok(ManualTransitionJobRecoveryOutcome::Skipped),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let reconciled = reconcile_manual_transition_worker_results(api.clone(), job_id, recovery_unknown_snapshot).await?;
|
||||
if reconciled.is_terminal() {
|
||||
release_manual_transition_recovery_admission(api, &reconciled).await;
|
||||
return match reconciled.state {
|
||||
@@ -2284,41 +2266,34 @@ async fn recover_manual_transition_job(
|
||||
replay,
|
||||
ManualTransitionPendingTaskReplay::Queued | ManualTransitionPendingTaskReplay::Deferred
|
||||
) {
|
||||
spawn_manual_transition_recovery_heartbeat(api, job_id, recovery_lease_id);
|
||||
spawn_manual_transition_recovery_heartbeat(api, job_id);
|
||||
return Ok(ManualTransitionJobRecoveryOutcome::Resumed);
|
||||
}
|
||||
|
||||
let mut marked_unknown = false;
|
||||
let record = match update_manual_transition_job_record(api.clone(), job_id, Some(recovery_lease_id), |record| {
|
||||
marked_unknown = record.mark_unknown_if_worker_results_lost(recovery_unknown_snapshot)
|
||||
|| record.mark_unknown_if_recovery_would_skip_pending_page(recovery_unknown_snapshot);
|
||||
marked_unknown
|
||||
})
|
||||
.await
|
||||
let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
||||
if record.mark_unknown_if_worker_results_lost(recovery_unknown_snapshot)
|
||||
|| record.mark_unknown_if_recovery_would_skip_pending_page(recovery_unknown_snapshot)
|
||||
{
|
||||
Ok(record) => record,
|
||||
Err(Error::PreconditionFailed) => return Ok(ManualTransitionJobRecoveryOutcome::Skipped),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if marked_unknown {
|
||||
release_manual_transition_recovery_admission(api, &record).await;
|
||||
return Ok(ManualTransitionJobRecoveryOutcome::Unknown);
|
||||
return match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
|
||||
Ok(()) => {
|
||||
release_manual_transition_recovery_admission(api, &record).await;
|
||||
Ok(ManualTransitionJobRecoveryOutcome::Unknown)
|
||||
}
|
||||
Err(Error::PreconditionFailed) => Ok(ManualTransitionJobRecoveryOutcome::Skipped),
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
}
|
||||
|
||||
let mut options = record.resume_options();
|
||||
options.job_id = Some(job_id);
|
||||
options.cancel_check = Some(manual_transition_recovery_cancel_check(api.clone(), job_id));
|
||||
options.progress_sink = Some(manual_transition_recovery_progress_sink(api.clone(), job_id, recovery_lease_id));
|
||||
options.progress_sink = Some(manual_transition_recovery_progress_sink(api.clone(), job_id));
|
||||
let result = enqueue_transition_for_existing_objects_scoped(api.clone(), &record.bucket, options).await;
|
||||
let final_record = match finalize_recovered_manual_transition_job(api.clone(), job_id, recovery_lease_id, result).await {
|
||||
Ok(record) => record,
|
||||
Err(Error::PreconditionFailed) => return Ok(ManualTransitionJobRecoveryOutcome::Skipped),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let final_record = finalize_recovered_manual_transition_job(api.clone(), job_id, result).await?;
|
||||
if final_record.is_terminal() {
|
||||
release_manual_transition_recovery_admission(api, &final_record).await;
|
||||
} else {
|
||||
spawn_manual_transition_recovery_heartbeat(api, job_id, recovery_lease_id);
|
||||
spawn_manual_transition_recovery_heartbeat(api, job_id);
|
||||
}
|
||||
Ok(ManualTransitionJobRecoveryOutcome::Resumed)
|
||||
}
|
||||
@@ -2402,11 +2377,11 @@ fn manual_transition_recovery_cancel_check(api: Arc<ECStore>, job_id: Uuid) -> M
|
||||
})
|
||||
}
|
||||
|
||||
fn manual_transition_recovery_progress_sink(api: Arc<ECStore>, job_id: Uuid, lease_id: Uuid) -> ManualTransitionProgressSink {
|
||||
fn manual_transition_recovery_progress_sink(api: Arc<ECStore>, job_id: Uuid) -> ManualTransitionProgressSink {
|
||||
Arc::new(move |report| {
|
||||
let api = api.clone();
|
||||
Box::pin(async move {
|
||||
persist_manual_transition_job_progress_if_owned(api, job_id, lease_id, &report, manual_transition_queue_snapshot())
|
||||
persist_manual_transition_job_progress(api, job_id, &report, manual_transition_queue_snapshot())
|
||||
.await
|
||||
.map(|_| ())
|
||||
})
|
||||
@@ -2416,20 +2391,24 @@ fn manual_transition_recovery_progress_sink(api: Arc<ECStore>, job_id: Uuid, lea
|
||||
async fn finalize_recovered_manual_transition_job(
|
||||
api: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
expected_lease_id: Uuid,
|
||||
result: Result<ManualTransitionRunReport, Error>,
|
||||
) -> Result<ManualTransitionJobRecord, Error> {
|
||||
update_manual_transition_job_record(api, job_id, Some(expected_lease_id), |record| {
|
||||
for _ in 0..4 {
|
||||
let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
||||
if record.is_terminal() {
|
||||
return false;
|
||||
return Ok(record);
|
||||
}
|
||||
match &result {
|
||||
Ok(report) => record.complete(report.clone(), manual_transition_queue_snapshot()),
|
||||
Err(err) => record.fail(format!("manual transition recovery failed: {err}")),
|
||||
}
|
||||
true
|
||||
})
|
||||
.await
|
||||
match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
|
||||
Ok(()) => return Ok(record),
|
||||
Err(Error::PreconditionFailed) => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(Error::PreconditionFailed)
|
||||
}
|
||||
|
||||
async fn release_manual_transition_recovery_admission(api: Arc<ECStore>, record: &ManualTransitionJobRecord) {
|
||||
@@ -2448,20 +2427,18 @@ async fn release_manual_transition_recovery_admission(api: Arc<ECStore>, record:
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_manual_transition_recovery_heartbeat(api: Arc<ECStore>, job_id: Uuid, lease_id: Uuid) {
|
||||
fn spawn_manual_transition_recovery_heartbeat(api: Arc<ECStore>, job_id: Uuid) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match renew_manual_transition_job_lease_if_owned(api.clone(), job_id, lease_id, manual_transition_queue_snapshot())
|
||||
.await
|
||||
{
|
||||
match renew_manual_transition_job_lease(api.clone(), job_id, manual_transition_queue_snapshot()).await {
|
||||
Ok(record) if record.is_terminal() => {
|
||||
release_manual_transition_recovery_admission(api, &record).await;
|
||||
return;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(Error::ConfigNotFound | Error::PreconditionFailed) => return,
|
||||
Err(Error::ConfigNotFound) => return,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event = EVENT_LIFECYCLE_WORKER_STATE,
|
||||
@@ -2479,18 +2456,23 @@ fn spawn_manual_transition_recovery_heartbeat(api: Arc<ECStore>, job_id: Uuid, l
|
||||
}
|
||||
|
||||
async fn abandon_manual_transition_recovery_lease(api: Arc<ECStore>, job_id: Uuid, lease_id: Uuid) -> Result<(), Error> {
|
||||
match update_manual_transition_job_record(api, job_id, Some(lease_id), |record| {
|
||||
if record.is_terminal() {
|
||||
return false;
|
||||
for _ in 0..4 {
|
||||
let (mut record, etag) = match load_manual_transition_job_record_with_etag(api.clone(), job_id).await {
|
||||
Ok(record) => record,
|
||||
Err(Error::ConfigNotFound) => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if record.lease_id != lease_id || record.is_terminal() {
|
||||
return Ok(());
|
||||
}
|
||||
record.abandon_recovery_lease(lease_id);
|
||||
true
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) | Err(Error::ConfigNotFound | Error::PreconditionFailed) => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(Error::PreconditionFailed) => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tier_free_version_recovery_enabled() -> bool {
|
||||
@@ -5093,15 +5075,14 @@ pub async fn apply_lifecycle_action(event: &lifecycle::Event, src: &LcEventSrc,
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::expiry_worker_count;
|
||||
use super::{
|
||||
DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS, DEFAULT_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX,
|
||||
DEFAULT_TRANSITION_WORKERS_CAP, EVENT_LIFECYCLE_EVALUATION_FAILED, EVENT_LIFECYCLE_EXPIRED_DETECTED,
|
||||
EVENT_LIFECYCLE_NOT_ENQUEUED, ExpiryState, ExpiryTask, FreeVersionTask, ManualTransitionJobRecoveryOutcome,
|
||||
ManualTransitionQueueSnapshot, ManualTransitionRunOptions, ManualTransitionRunReport, StaleMultipartUploadCandidate,
|
||||
TIER_FREE_VERSION_RECOVERY_BASE_INTERVAL, TIER_FREE_VERSION_RECOVERY_MAX_IDLE_INTERVAL, TRANSITION_COMPLETE,
|
||||
TierFreeVersionRecoverySchedule, TransitionEnqueueOutcome, TransitionState, TransitionedObject, VersionReplicationScan,
|
||||
cleanup_empty_multipart_sha_dirs_on_local_disks, cleanup_stale_multipart_uploads_once_at,
|
||||
DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS, DEFAULT_EXPIRY_WORKERS_CAP, DEFAULT_TRANSITION_QUEUE_CAPACITY,
|
||||
DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX, DEFAULT_TRANSITION_WORKERS_CAP, EVENT_LIFECYCLE_EVALUATION_FAILED,
|
||||
EVENT_LIFECYCLE_EXPIRED_DETECTED, EVENT_LIFECYCLE_NOT_ENQUEUED, ExpiryState, ExpiryTask, FreeVersionTask,
|
||||
ManualTransitionJobRecoveryOutcome, ManualTransitionQueueSnapshot, ManualTransitionRunOptions, ManualTransitionRunReport,
|
||||
StaleMultipartUploadCandidate, TIER_FREE_VERSION_RECOVERY_BASE_INTERVAL, TIER_FREE_VERSION_RECOVERY_MAX_IDLE_INTERVAL,
|
||||
TRANSITION_COMPLETE, TierFreeVersionRecoverySchedule, TransitionEnqueueOutcome, TransitionState, TransitionedObject,
|
||||
VersionReplicationScan, cleanup_empty_multipart_sha_dirs_on_local_disks, cleanup_stale_multipart_uploads_once_at,
|
||||
enqueue_recovered_free_version_with_state, enqueue_transition_for_existing_objects_scoped,
|
||||
enqueue_transition_with_lifecycle, enqueue_transition_with_lifecycle_report, eval_action_from_lifecycle,
|
||||
get_lock_acquire_timeout, jitter_tier_free_version_recovery_delay, lifecycle_action_blocked_by_replication,
|
||||
@@ -5109,8 +5090,8 @@ mod tests {
|
||||
lifecycle_rule_has_date_expiration, manual_transition_duration_elapsed, manual_transition_has_more_after_limit,
|
||||
manual_transition_recovery_progress_sink, manual_transition_version_marker, manual_transition_worker_failure_reason,
|
||||
mark_delete_opts_skip_decommissioned_on_remote_success, merge_stale_multipart_candidate,
|
||||
persist_manual_transition_job_progress_if_owned, persist_manual_transition_page_checkpoint,
|
||||
recover_manual_transition_job, recover_manual_transition_jobs, resolve_tier_free_version_recovery_enabled,
|
||||
persist_manual_transition_job_progress, persist_manual_transition_page_checkpoint, recover_manual_transition_job,
|
||||
recover_manual_transition_jobs, resolve_expiry_worker_count, resolve_tier_free_version_recovery_enabled,
|
||||
resolve_transition_queue_capacity, resolve_transition_queue_send_timeout, resolve_transition_worker_count,
|
||||
resolve_transition_workers_absolute_max, run_tier_free_version_recovery_loop, select_restore_s3_location,
|
||||
set_lifecycle_observability_observer, set_recovered_free_version_enqueue_observer,
|
||||
@@ -5125,19 +5106,18 @@ mod tests {
|
||||
};
|
||||
use crate::bucket::lifecycle::config_boundary;
|
||||
use crate::bucket::lifecycle::manual_transition_job::{
|
||||
ManualTransitionJobCasBarrier, ManualTransitionJobRecord, ManualTransitionJobState, ManualTransitionScopeAdmission,
|
||||
ManualTransitionScopeAdmissionClaim, ManualTransitionTaskRecord, ManualTransitionWorkerFailureReason,
|
||||
ManualTransitionWorkerResult, ManualTransitionWorkerResultRecord, claim_manual_transition_scope_admission,
|
||||
ManualTransitionJobRecord, ManualTransitionJobState, ManualTransitionScopeAdmission, ManualTransitionScopeAdmissionClaim,
|
||||
ManualTransitionTaskRecord, ManualTransitionWorkerFailureReason, ManualTransitionWorkerResult,
|
||||
ManualTransitionWorkerResultRecord, claim_manual_transition_scope_admission,
|
||||
delete_manual_transition_scope_admission_if_current, legacy_manual_transition_scope_key,
|
||||
load_manual_transition_job_record, load_manual_transition_job_record_with_etag, load_manual_transition_scope_admission,
|
||||
load_manual_transition_job_record, load_manual_transition_scope_admission,
|
||||
load_manual_transition_scope_admission_with_etag, load_manual_transition_task_record,
|
||||
manual_transition_scope_record_object_name, manual_transition_worker_result_object_name,
|
||||
manual_transition_worker_result_task_key, reconcile_manual_transition_worker_results,
|
||||
record_manual_transition_worker_result, record_manual_transition_worker_result_with_reason,
|
||||
renew_manual_transition_job_lease_if_owned, request_manual_transition_job_cancel, save_manual_transition_job_record,
|
||||
save_manual_transition_job_record_if_current, save_manual_transition_scope_admission_if_absent,
|
||||
save_manual_transition_scope_admission_if_current, save_manual_transition_task_if_absent,
|
||||
save_manual_transition_worker_result_if_absent,
|
||||
renew_manual_transition_job_lease, request_manual_transition_job_cancel, save_manual_transition_job_record,
|
||||
save_manual_transition_scope_admission_if_absent, save_manual_transition_scope_admission_if_current,
|
||||
save_manual_transition_task_if_absent, save_manual_transition_worker_result_if_absent,
|
||||
};
|
||||
use crate::bucket::lifecycle::replication_sink::{ReplicationStatusType, VersionPurgeStatusType};
|
||||
use crate::bucket::lifecycle::runtime_boundary as runtime_sources;
|
||||
@@ -5177,8 +5157,7 @@ mod tests {
|
||||
#[cfg(feature = "test-util")]
|
||||
use http::HeaderMap;
|
||||
use rustfs_common::metrics::{IlmAction, global_metrics};
|
||||
use rustfs_config::ENV_MAX_EXPIRY_WORKERS;
|
||||
use rustfs_config::ENV_TRANSITION_WORKERS_ABSOLUTE_MAX;
|
||||
use rustfs_config::{ENV_MAX_EXPIRY_WORKERS, ENV_TRANSITION_WORKERS_ABSOLUTE_MAX};
|
||||
use rustfs_data_usage::TierStats;
|
||||
use rustfs_filemeta::{FileInfo, FileMeta};
|
||||
use s3s::dto::{
|
||||
@@ -7176,63 +7155,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: same contract as with_transition_worker_env — only used from
|
||||
// `#[serial]` tests, so no concurrent reader/writer can access the process
|
||||
// environment while `env::set_var`/`env::remove_var` is active.
|
||||
#[allow(unsafe_code)]
|
||||
fn with_expiry_worker_env<F>(value: Option<&str>, test_fn: F)
|
||||
where
|
||||
F: FnOnce(),
|
||||
{
|
||||
let original = env::var_os(ENV_MAX_EXPIRY_WORKERS);
|
||||
|
||||
match value {
|
||||
Some(v) => unsafe {
|
||||
env::set_var(ENV_MAX_EXPIRY_WORKERS, v);
|
||||
},
|
||||
None => unsafe {
|
||||
env::remove_var(ENV_MAX_EXPIRY_WORKERS);
|
||||
},
|
||||
}
|
||||
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(test_fn));
|
||||
|
||||
match original {
|
||||
Some(v) => unsafe {
|
||||
env::set_var(ENV_MAX_EXPIRY_WORKERS, v);
|
||||
},
|
||||
None => unsafe {
|
||||
env::remove_var(ENV_MAX_EXPIRY_WORKERS);
|
||||
},
|
||||
}
|
||||
|
||||
if let Err(e) = result {
|
||||
std::panic::resume_unwind(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// backlog#1832: the single expiry knob must resolve all four env states
|
||||
/// (unset / zero / valid / garbage); the removed `_RUSTFS_ILM_EXPIRATION_WORKERS`
|
||||
/// override and `RUSTFS_DEFAULT_EXPIRY_WORKERS` fallback must stay gone.
|
||||
#[test]
|
||||
#[serial]
|
||||
fn expiry_worker_count_resolves_all_env_states() {
|
||||
let default = std::cmp::min(num_cpus::get(), 16);
|
||||
|
||||
with_expiry_worker_env(None, || {
|
||||
assert_eq!(expiry_worker_count(), default, "unset env must fall back to min(cpus, 16)");
|
||||
});
|
||||
with_expiry_worker_env(Some("0"), || {
|
||||
assert_eq!(expiry_worker_count(), default, "zero must fall back instead of spawning zero workers");
|
||||
});
|
||||
with_expiry_worker_env(Some("4"), || {
|
||||
assert_eq!(expiry_worker_count(), 4, "a valid positive value must win");
|
||||
});
|
||||
with_expiry_worker_env(Some("not-a-number"), || {
|
||||
assert_eq!(expiry_worker_count(), default, "garbage must fall back to the default");
|
||||
});
|
||||
}
|
||||
|
||||
// SAFETY: this helper is only used from `#[serial]` tests and those tests run under a
|
||||
// single-thread runtime (`worker_threads = 1`), so no concurrent reader/writer can access
|
||||
// process environment while `env::set_var`/`env::remove_var` is active.
|
||||
@@ -7487,6 +7409,76 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
// SAFETY: this helper is only used from `#[serial]` tests and those tests run under a
|
||||
// single-thread runtime (`worker_threads = 1`), so no concurrent reader/writer can access
|
||||
// process environment while `env::set_var`/`env::remove_var` is active.
|
||||
#[allow(unsafe_code)]
|
||||
fn with_expiry_worker_env<F>(value: Option<&str>, test_fn: F)
|
||||
where
|
||||
F: FnOnce(),
|
||||
{
|
||||
let original = env::var_os(ENV_MAX_EXPIRY_WORKERS);
|
||||
|
||||
match value {
|
||||
Some(value) => unsafe {
|
||||
env::set_var(ENV_MAX_EXPIRY_WORKERS, value);
|
||||
},
|
||||
None => unsafe {
|
||||
env::remove_var(ENV_MAX_EXPIRY_WORKERS);
|
||||
},
|
||||
}
|
||||
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(test_fn));
|
||||
|
||||
match original {
|
||||
Some(value) => unsafe {
|
||||
env::set_var(ENV_MAX_EXPIRY_WORKERS, value);
|
||||
},
|
||||
None => unsafe {
|
||||
env::remove_var(ENV_MAX_EXPIRY_WORKERS);
|
||||
},
|
||||
}
|
||||
|
||||
if let Err(e) = result {
|
||||
std::panic::resume_unwind(e);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_expiry_worker_count_uses_fallback_when_env_missing() {
|
||||
with_expiry_worker_env(None, || {
|
||||
let fallback = std::cmp::min(num_cpus::get(), DEFAULT_EXPIRY_WORKERS_CAP);
|
||||
assert_eq!(resolve_expiry_worker_count(), fallback);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_expiry_worker_count_honors_positive_env_value() {
|
||||
with_expiry_worker_env(Some("6"), || {
|
||||
assert_eq!(resolve_expiry_worker_count(), 6);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_expiry_worker_count_falls_back_for_zero_value() {
|
||||
with_expiry_worker_env(Some("0"), || {
|
||||
let fallback = std::cmp::min(num_cpus::get(), DEFAULT_EXPIRY_WORKERS_CAP);
|
||||
assert_eq!(resolve_expiry_worker_count(), fallback);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_expiry_worker_count_falls_back_for_invalid_value() {
|
||||
with_expiry_worker_env(Some("not-a-number"), || {
|
||||
let fallback = std::cmp::min(num_cpus::get(), DEFAULT_EXPIRY_WORKERS_CAP);
|
||||
assert_eq!(resolve_expiry_worker_count(), fallback);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_transition_queue_capacity_uses_default_when_env_missing() {
|
||||
@@ -8633,10 +8625,9 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let persisted =
|
||||
persist_manual_transition_job_progress_if_owned(ecstore.clone(), job_id, record.lease_id, &report, queue_snapshot)
|
||||
.await
|
||||
.expect("page checkpoint should persist to the job record");
|
||||
let persisted = persist_manual_transition_job_progress(ecstore.clone(), job_id, &report, queue_snapshot)
|
||||
.await
|
||||
.expect("page checkpoint should persist to the job record");
|
||||
|
||||
assert_eq!(persisted.state, ManualTransitionJobState::Running);
|
||||
assert_eq!(persisted.report.scanned, 1000);
|
||||
@@ -8655,232 +8646,6 @@ mod tests {
|
||||
assert_eq!(admission.updated_at_unix_nanos, loaded.updated_at_unix_nanos);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn manual_transition_progress_retries_heartbeat_cas_without_losing_checkpoint() {
|
||||
let (_paths, ecstore) = setup_test_env().await;
|
||||
let job_id = Uuid::new_v4();
|
||||
let options = ManualTransitionRunOptions {
|
||||
prefix: "logs/".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let record = ManualTransitionJobRecord::new(job_id, "manual-progress-cas-bucket", &options, "owner-a");
|
||||
save_manual_transition_job_record(ecstore.clone(), &record)
|
||||
.await
|
||||
.expect("running job record should save");
|
||||
save_manual_transition_scope_admission_if_absent(ecstore.clone(), &ManualTransitionScopeAdmission::from_job(&record))
|
||||
.await
|
||||
.expect("running scope admission should save");
|
||||
let lease_id = record.lease_id;
|
||||
let barrier = ManualTransitionJobCasBarrier::install(job_id);
|
||||
let progress_store = ecstore.clone();
|
||||
let progress = tokio::spawn(async move {
|
||||
persist_manual_transition_job_progress_if_owned(
|
||||
progress_store,
|
||||
job_id,
|
||||
lease_id,
|
||||
&ManualTransitionRunReport {
|
||||
bucket: "manual-progress-cas-bucket".to_string(),
|
||||
prefix: "logs/".to_string(),
|
||||
scanned: 1000,
|
||||
eligible: 900,
|
||||
enqueued: 800,
|
||||
continuation_token: Some("opaque-page-cursor".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
ManualTransitionQueueSnapshot {
|
||||
queued: 7,
|
||||
active: 3,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
});
|
||||
barrier.wait_until_paused().await;
|
||||
|
||||
let heartbeat = renew_manual_transition_job_lease_if_owned(
|
||||
ecstore.clone(),
|
||||
job_id,
|
||||
lease_id,
|
||||
ManualTransitionQueueSnapshot {
|
||||
queued: 2,
|
||||
active: 1,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("heartbeat should win the first CAS write");
|
||||
barrier.release();
|
||||
let checkpointed = progress
|
||||
.await
|
||||
.expect("progress task should join")
|
||||
.expect("progress should retry its stale ETag");
|
||||
|
||||
assert_eq!(checkpointed.lease_id, heartbeat.lease_id);
|
||||
assert_eq!(checkpointed.report.scanned, 1000);
|
||||
assert_eq!(checkpointed.report.eligible, 900);
|
||||
assert_eq!(checkpointed.report.enqueued, 800);
|
||||
assert_eq!(checkpointed.report.continuation_token.as_deref(), Some("opaque-page-cursor"));
|
||||
assert_eq!(checkpointed.queue_snapshot.queued, 7);
|
||||
assert_eq!(checkpointed.queue_snapshot.active, 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn manual_transition_progress_rejects_stale_recovery_lease() {
|
||||
let (_paths, ecstore) = setup_test_env().await;
|
||||
let job_id = Uuid::new_v4();
|
||||
let record = ManualTransitionJobRecord::new(
|
||||
job_id,
|
||||
"manual-progress-stale-lease-bucket",
|
||||
&ManualTransitionRunOptions::default(),
|
||||
"owner-a",
|
||||
);
|
||||
let stale_lease_id = record.lease_id;
|
||||
save_manual_transition_job_record(ecstore.clone(), &record)
|
||||
.await
|
||||
.expect("running job record should save");
|
||||
|
||||
let (mut recovered, etag) = load_manual_transition_job_record_with_etag(ecstore.clone(), job_id)
|
||||
.await
|
||||
.expect("running job record should load");
|
||||
recovered.lease_id = Uuid::new_v4();
|
||||
recovered.owner_id = "owner-b".to_string();
|
||||
save_manual_transition_job_record_if_current(ecstore.clone(), &recovered, &etag)
|
||||
.await
|
||||
.expect("recovery owner should replace the lease");
|
||||
|
||||
let error = persist_manual_transition_job_progress_if_owned(
|
||||
ecstore.clone(),
|
||||
job_id,
|
||||
stale_lease_id,
|
||||
&ManualTransitionRunReport {
|
||||
scanned: 1000,
|
||||
continuation_token: Some("stale-owner-cursor".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
ManualTransitionQueueSnapshot::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("the stale owner must not update the recovered job");
|
||||
let heartbeat_error = renew_manual_transition_job_lease_if_owned(
|
||||
ecstore.clone(),
|
||||
job_id,
|
||||
stale_lease_id,
|
||||
ManualTransitionQueueSnapshot::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("the stale owner must not renew the recovered job");
|
||||
|
||||
assert_eq!(error, Error::PreconditionFailed);
|
||||
assert_eq!(heartbeat_error, Error::PreconditionFailed);
|
||||
let loaded = load_manual_transition_job_record(ecstore, job_id)
|
||||
.await
|
||||
.expect("recovered job record should load");
|
||||
assert_eq!(loaded.lease_id, recovered.lease_id);
|
||||
assert_eq!(loaded.owner_id, "owner-b");
|
||||
assert_eq!(loaded.report.scanned, 0);
|
||||
assert!(loaded.report.continuation_token.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn manual_transition_reconcile_rejects_lease_takeover_during_cas() {
|
||||
let (_paths, ecstore) = setup_test_env().await;
|
||||
let job_id = Uuid::new_v4();
|
||||
let bucket = format!("manual-reconcile-lease-race-{}", job_id.simple());
|
||||
let mut record = ManualTransitionJobRecord::new(job_id, &bucket, &ManualTransitionRunOptions::default(), "owner-a");
|
||||
record.scan_completed = true;
|
||||
let stale_lease_id = record.lease_id;
|
||||
save_manual_transition_job_record(ecstore.clone(), &record)
|
||||
.await
|
||||
.expect("running job record should save");
|
||||
let task_key = manual_transition_worker_result_task_key(&bucket, "logs/a", None);
|
||||
let task = ManualTransitionTaskRecord::new(job_id, &task_key, &bucket, "logs/a", None, "WARM");
|
||||
assert!(
|
||||
save_manual_transition_task_if_absent(ecstore.clone(), &task)
|
||||
.await
|
||||
.expect("task journal marker should save")
|
||||
);
|
||||
|
||||
let barrier = ManualTransitionJobCasBarrier::install(job_id);
|
||||
let heartbeat_store = ecstore.clone();
|
||||
let heartbeat = tokio::spawn(async move {
|
||||
renew_manual_transition_job_lease_if_owned(
|
||||
heartbeat_store,
|
||||
job_id,
|
||||
stale_lease_id,
|
||||
ManualTransitionQueueSnapshot::default(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
barrier.wait_until_paused().await;
|
||||
|
||||
let (mut recovered, etag) = load_manual_transition_job_record_with_etag(ecstore.clone(), job_id)
|
||||
.await
|
||||
.expect("running job record should load during reconciliation");
|
||||
recovered.lease_id = Uuid::new_v4();
|
||||
recovered.owner_id = "owner-b".to_string();
|
||||
save_manual_transition_job_record_if_current(ecstore.clone(), &recovered, &etag)
|
||||
.await
|
||||
.expect("recovery owner should replace the lease");
|
||||
barrier.release();
|
||||
|
||||
let error = heartbeat
|
||||
.await
|
||||
.expect("heartbeat task should join")
|
||||
.expect_err("stale reconciliation must reject the recovery lease");
|
||||
assert_eq!(error, Error::PreconditionFailed);
|
||||
let loaded = load_manual_transition_job_record(ecstore, job_id)
|
||||
.await
|
||||
.expect("recovered job record should load");
|
||||
assert_eq!(loaded.lease_id, recovered.lease_id);
|
||||
assert_eq!(loaded.owner_id, "owner-b");
|
||||
assert_eq!(loaded.state, ManualTransitionJobState::Running);
|
||||
assert_eq!(loaded.report.enqueued, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn manual_transition_progress_does_not_regress_newer_admission_lease() {
|
||||
let (_paths, ecstore) = setup_test_env().await;
|
||||
let job_id = Uuid::new_v4();
|
||||
let record = ManualTransitionJobRecord::new(
|
||||
job_id,
|
||||
"manual-progress-admission-order-bucket",
|
||||
&ManualTransitionRunOptions::default(),
|
||||
"owner-a",
|
||||
);
|
||||
save_manual_transition_job_record(ecstore.clone(), &record)
|
||||
.await
|
||||
.expect("running job record should save");
|
||||
let mut newer_admission = ManualTransitionScopeAdmission::from_job(&record);
|
||||
newer_admission.lease_expires_at_unix_nanos = newer_admission.lease_expires_at_unix_nanos.saturating_add(60_000_000_000);
|
||||
newer_admission.updated_at_unix_nanos = newer_admission.updated_at_unix_nanos.saturating_add(60_000_000_000);
|
||||
save_manual_transition_scope_admission_if_absent(ecstore.clone(), &newer_admission)
|
||||
.await
|
||||
.expect("newer scope admission should save");
|
||||
|
||||
persist_manual_transition_job_progress_if_owned(
|
||||
ecstore.clone(),
|
||||
job_id,
|
||||
record.lease_id,
|
||||
&ManualTransitionRunReport {
|
||||
scanned: 1000,
|
||||
..Default::default()
|
||||
},
|
||||
ManualTransitionQueueSnapshot::default(),
|
||||
)
|
||||
.await
|
||||
.expect("progress should preserve the newer admission lease");
|
||||
|
||||
let admission = load_manual_transition_scope_admission(ecstore, &record.scope_key)
|
||||
.await
|
||||
.expect("scope admission should load");
|
||||
assert_eq!(admission.lease_expires_at_unix_nanos, newer_admission.lease_expires_at_unix_nanos);
|
||||
assert_eq!(admission.updated_at_unix_nanos, newer_admission.updated_at_unix_nanos);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_transition_page_checkpoint_persists_resume_cursor() {
|
||||
let observed = Arc::new(StdMutex::new(Vec::new()));
|
||||
@@ -8945,7 +8710,7 @@ mod tests {
|
||||
.await
|
||||
.expect("expired scope admission should save");
|
||||
let checkpoint_options = ManualTransitionRunOptions {
|
||||
progress_sink: Some(manual_transition_recovery_progress_sink(ecstore.clone(), job_id, record.lease_id)),
|
||||
progress_sink: Some(manual_transition_recovery_progress_sink(ecstore.clone(), job_id)),
|
||||
..options
|
||||
};
|
||||
let report = ManualTransitionRunReport {
|
||||
@@ -9034,7 +8799,7 @@ mod tests {
|
||||
prefix: prefix.to_string(),
|
||||
tier: Some("WARM".to_string()),
|
||||
dry_run: true,
|
||||
progress_sink: Some(manual_transition_recovery_progress_sink(ecstore.clone(), job_id, record.lease_id)),
|
||||
progress_sink: Some(manual_transition_recovery_progress_sink(ecstore.clone(), job_id)),
|
||||
..Default::default()
|
||||
};
|
||||
let final_report = enqueue_transition_for_existing_objects_scoped(ecstore.clone(), &bucket, production_path_options)
|
||||
@@ -9734,14 +9499,9 @@ mod tests {
|
||||
"new worker result marker must be created"
|
||||
);
|
||||
|
||||
let renewed = renew_manual_transition_job_lease_if_owned(
|
||||
ecstore.clone(),
|
||||
job_id,
|
||||
record.lease_id,
|
||||
ManualTransitionQueueSnapshot::default(),
|
||||
)
|
||||
.await
|
||||
.expect("heartbeat should reconcile marker before unknown fallback");
|
||||
let renewed = renew_manual_transition_job_lease(ecstore.clone(), job_id, ManualTransitionQueueSnapshot::default())
|
||||
.await
|
||||
.expect("heartbeat should reconcile marker before unknown fallback");
|
||||
|
||||
assert_eq!(renewed.state, ManualTransitionJobState::Completed);
|
||||
assert_eq!(renewed.report.transition_completed, 1);
|
||||
@@ -9784,14 +9544,9 @@ mod tests {
|
||||
"new worker result marker must be created"
|
||||
);
|
||||
|
||||
let renewed = renew_manual_transition_job_lease_if_owned(
|
||||
ecstore.clone(),
|
||||
job_id,
|
||||
record.lease_id,
|
||||
ManualTransitionQueueSnapshot::default(),
|
||||
)
|
||||
.await
|
||||
.expect("heartbeat should reconcile task and result journals");
|
||||
let renewed = renew_manual_transition_job_lease(ecstore.clone(), job_id, ManualTransitionQueueSnapshot::default())
|
||||
.await
|
||||
.expect("heartbeat should reconcile task and result journals");
|
||||
|
||||
assert_eq!(renewed.state, ManualTransitionJobState::Completed);
|
||||
assert_eq!(renewed.report.enqueued, 1);
|
||||
@@ -10106,10 +9861,9 @@ mod tests {
|
||||
.await
|
||||
.expect("running scope admission should save");
|
||||
|
||||
let checkpointed = persist_manual_transition_job_progress_if_owned(
|
||||
let checkpointed = persist_manual_transition_job_progress(
|
||||
ecstore.clone(),
|
||||
job_id,
|
||||
record.lease_id,
|
||||
&ManualTransitionRunReport {
|
||||
bucket: bucket.to_string(),
|
||||
prefix: "logs/".to_string(),
|
||||
@@ -10198,7 +9952,7 @@ mod tests {
|
||||
compensation_running: 1,
|
||||
};
|
||||
|
||||
let renewed = renew_manual_transition_job_lease_if_owned(ecstore.clone(), job_id, record.lease_id, queue_snapshot)
|
||||
let renewed = renew_manual_transition_job_lease(ecstore.clone(), job_id, queue_snapshot)
|
||||
.await
|
||||
.expect("running job heartbeat should persist queue pressure status");
|
||||
|
||||
@@ -10249,14 +10003,9 @@ mod tests {
|
||||
.await
|
||||
.expect("running job admission should save");
|
||||
|
||||
let renewed = renew_manual_transition_job_lease_if_owned(
|
||||
ecstore.clone(),
|
||||
job_id,
|
||||
record.lease_id,
|
||||
ManualTransitionQueueSnapshot::default(),
|
||||
)
|
||||
.await
|
||||
.expect("lost worker result should persist unknown state");
|
||||
let renewed = renew_manual_transition_job_lease(ecstore.clone(), job_id, ManualTransitionQueueSnapshot::default())
|
||||
.await
|
||||
.expect("lost worker result should persist unknown state");
|
||||
|
||||
assert_eq!(renewed.state, ManualTransitionJobState::Unknown);
|
||||
assert!(renewed.completed_at_unix_nanos.is_some());
|
||||
@@ -11846,6 +11595,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
#[serial]
|
||||
async fn ecstore_new_succeeds_on_fresh_local_volumes() {
|
||||
let test_base_dir = format!("/tmp/rustfs_ecstore_empty_boot_{}", Uuid::new_v4());
|
||||
|
||||
@@ -86,21 +86,6 @@ where
|
||||
com::save_config_with_opts(api, file, data, opts).await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_config_with_opts_quiet<S>(api: Arc<S>, file: &str, data: Vec<u8>, opts: &ObjectOptions) -> Result<()>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
RangeSpec = HTTPRangeSpec,
|
||||
HeaderMap = HeaderMap,
|
||||
ObjectOptions = ObjectOptions,
|
||||
ObjectInfo = ObjectInfo,
|
||||
GetObjectReader = GetObjectReader,
|
||||
PutObjectReader = PutObjReader,
|
||||
>,
|
||||
{
|
||||
com::save_config_with_opts_quiet(api, file, data, opts).await
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_config<S>(api: Arc<S>, file: &str) -> Result<()>
|
||||
where
|
||||
S: ObjectOperations<
|
||||
|
||||
@@ -45,104 +45,6 @@ const MANUAL_TRANSITION_JOB_LEASE_SECONDS: i128 = 60;
|
||||
const MANUAL_TRANSITION_LEGACY_SCOPE_SCAN_LIMIT: i32 = 1000;
|
||||
const MANUAL_TRANSITION_TASK_SCAN_LIMIT: i32 = 1000;
|
||||
const MANUAL_TRANSITION_WORKER_RESULT_SCAN_LIMIT: i32 = 1000;
|
||||
const MANUAL_TRANSITION_JOB_CAS_RETRIES: usize = 4;
|
||||
|
||||
#[cfg(test)]
|
||||
struct ManualTransitionJobCasBarrierState {
|
||||
job_id: Uuid,
|
||||
paused: std::sync::atomic::AtomicBool,
|
||||
arrived: tokio::sync::Notify,
|
||||
release: tokio::sync::Semaphore,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct ManualTransitionJobCasBarrier {
|
||||
state: Arc<ManualTransitionJobCasBarrierState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static MANUAL_TRANSITION_JOB_CAS_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option<Arc<ManualTransitionJobCasBarrierState>>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
impl ManualTransitionJobCasBarrier {
|
||||
pub(crate) fn install(job_id: Uuid) -> Self {
|
||||
let state = Arc::new(ManualTransitionJobCasBarrierState {
|
||||
job_id,
|
||||
paused: std::sync::atomic::AtomicBool::new(false),
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
release: tokio::sync::Semaphore::new(0),
|
||||
});
|
||||
let mut slot = MANUAL_TRANSITION_JOB_CAS_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("manual transition progress CAS barrier mutex should not poison");
|
||||
assert!(
|
||||
slot.is_none(),
|
||||
"manual transition job CAS barrier must be installed by one test at a time"
|
||||
);
|
||||
*slot = Some(Arc::clone(&state));
|
||||
drop(slot);
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_until_paused(&self) {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), async {
|
||||
loop {
|
||||
let arrived = self.state.arrived.notified();
|
||||
if self.state.paused.load(std::sync::atomic::Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
arrived.await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("manual transition job update should reach the deterministic CAS barrier");
|
||||
}
|
||||
|
||||
pub(crate) fn release(&self) {
|
||||
self.state.release.add_permits(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for ManualTransitionJobCasBarrier {
|
||||
fn drop(&mut self) {
|
||||
self.release();
|
||||
let mut slot = MANUAL_TRANSITION_JOB_CAS_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("manual transition progress CAS barrier mutex should not poison");
|
||||
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn pause_manual_transition_job_before_first_cas(job_id: Uuid) {
|
||||
let barrier = MANUAL_TRANSITION_JOB_CAS_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("manual transition progress CAS barrier mutex should not poison")
|
||||
.as_ref()
|
||||
.filter(|barrier| barrier.job_id == job_id)
|
||||
.cloned();
|
||||
if let Some(barrier) = barrier
|
||||
&& barrier
|
||||
.paused
|
||||
.compare_exchange(false, true, std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
barrier.arrived.notify_one();
|
||||
barrier
|
||||
.release
|
||||
.acquire()
|
||||
.await
|
||||
.expect("manual transition job CAS barrier should remain open")
|
||||
.forget();
|
||||
}
|
||||
}
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
@@ -246,6 +148,7 @@ impl ManualTransitionJobRecord {
|
||||
|
||||
pub fn fail(&mut self, error: impl Into<String>) {
|
||||
self.state = ManualTransitionJobState::Failed;
|
||||
self.report.tier_failure = self.report.tier_failure.saturating_add(1);
|
||||
self.error = Some(error.into());
|
||||
self.mark_updated_terminal();
|
||||
}
|
||||
@@ -1137,7 +1040,7 @@ pub async fn save_manual_transition_job_record_if_current(
|
||||
}
|
||||
let object = manual_transition_job_record_object_name(job.job_id).map_err(manual_transition_job_store_error)?;
|
||||
let data = job.encode().map_err(manual_transition_job_store_error)?;
|
||||
config_boundary::save_config_with_opts_quiet(
|
||||
config_boundary::save_config_with_opts(
|
||||
api,
|
||||
&object,
|
||||
data,
|
||||
@@ -1153,54 +1056,6 @@ pub async fn save_manual_transition_job_record_if_current(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Applies a job-record mutation with optimistic concurrency control.
|
||||
///
|
||||
/// The mutation returns whether the record needs to be persisted. When a lease
|
||||
/// is supplied, ownership is checked again after every conflicting write.
|
||||
pub async fn update_manual_transition_job_record<F>(
|
||||
api: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
expected_lease_id: Option<Uuid>,
|
||||
update: F,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord>
|
||||
where
|
||||
F: FnMut(&mut ManualTransitionJobRecord) -> bool,
|
||||
{
|
||||
update_manual_transition_job_record_from(api, job_id, expected_lease_id, None, update).await
|
||||
}
|
||||
|
||||
async fn update_manual_transition_job_record_from<F>(
|
||||
api: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
expected_lease_id: Option<Uuid>,
|
||||
mut current: Option<(ManualTransitionJobRecord, String)>,
|
||||
mut update: F,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord>
|
||||
where
|
||||
F: FnMut(&mut ManualTransitionJobRecord) -> bool,
|
||||
{
|
||||
for _ in 0..MANUAL_TRANSITION_JOB_CAS_RETRIES {
|
||||
let (mut record, etag) = match current.take() {
|
||||
Some(current) => current,
|
||||
None => load_manual_transition_job_record_with_etag(api.clone(), job_id).await?,
|
||||
};
|
||||
if expected_lease_id.is_some_and(|lease_id| record.lease_id != lease_id) {
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
if !update(&mut record) {
|
||||
return Ok(record);
|
||||
}
|
||||
#[cfg(test)]
|
||||
pause_manual_transition_job_before_first_cas(job_id).await;
|
||||
match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
|
||||
Ok(()) => return Ok(record),
|
||||
Err(Error::PreconditionFailed) => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(Error::PreconditionFailed)
|
||||
}
|
||||
|
||||
pub(crate) async fn save_manual_transition_worker_result_if_absent(
|
||||
api: Arc<ECStore>,
|
||||
record: &ManualTransitionWorkerResultRecord,
|
||||
@@ -1459,113 +1314,99 @@ pub async fn reconcile_manual_transition_worker_results(
|
||||
api: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
queue_snapshot: ManualTransitionQueueSnapshot,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord> {
|
||||
reconcile_manual_transition_worker_results_inner(api, job_id, None, queue_snapshot, false).await
|
||||
}
|
||||
|
||||
pub(crate) async fn reconcile_manual_transition_worker_results_if_owned(
|
||||
api: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
expected_lease_id: Uuid,
|
||||
queue_snapshot: ManualTransitionQueueSnapshot,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord> {
|
||||
reconcile_manual_transition_worker_results_inner(api, job_id, Some(expected_lease_id), queue_snapshot, false).await
|
||||
}
|
||||
|
||||
async fn reconcile_manual_transition_worker_results_inner(
|
||||
api: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
expected_lease_id: Option<Uuid>,
|
||||
queue_snapshot: ManualTransitionQueueSnapshot,
|
||||
mark_missing_results_unknown: bool,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord> {
|
||||
let task_stats = match scan_manual_transition_task_journal(api.clone(), job_id).await? {
|
||||
ManualTransitionTaskJournal::Stats(stats) => stats,
|
||||
ManualTransitionTaskJournal::Corrupt(error) => {
|
||||
return mark_manual_transition_job_unknown_for_task_journal_error(
|
||||
api,
|
||||
job_id,
|
||||
expected_lease_id,
|
||||
error,
|
||||
queue_snapshot,
|
||||
)
|
||||
.await;
|
||||
return mark_manual_transition_job_unknown_for_task_journal_error(api, job_id, error, queue_snapshot).await;
|
||||
}
|
||||
};
|
||||
let stats = match scan_manual_transition_worker_result_journal(api.clone(), job_id).await? {
|
||||
ManualTransitionWorkerResultJournal::Stats(stats) => stats,
|
||||
ManualTransitionWorkerResultJournal::Corrupt(error) => {
|
||||
return mark_manual_transition_job_unknown_for_worker_result_journal_error(
|
||||
api,
|
||||
job_id,
|
||||
expected_lease_id,
|
||||
error,
|
||||
queue_snapshot,
|
||||
)
|
||||
.await;
|
||||
return mark_manual_transition_job_unknown_for_worker_result_journal_error(api, job_id, error, queue_snapshot).await;
|
||||
}
|
||||
};
|
||||
let mut changed = false;
|
||||
let record = update_manual_transition_job_record(api.clone(), job_id, expected_lease_id, |record| {
|
||||
let counts_changed = record.apply_worker_result_counts(
|
||||
for _ in 0..4 {
|
||||
let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
||||
let changed = record.apply_worker_result_counts(
|
||||
stats.stats.completed,
|
||||
stats.stats.failed,
|
||||
&stats.stats.tier_failure_by_reason,
|
||||
task_stats.queued,
|
||||
queue_snapshot,
|
||||
);
|
||||
let became_unknown = mark_missing_results_unknown && record.mark_unknown_if_worker_results_lost(queue_snapshot);
|
||||
changed = counts_changed || became_unknown;
|
||||
changed
|
||||
})
|
||||
.await?;
|
||||
if !changed {
|
||||
return Ok(record);
|
||||
if !changed {
|
||||
return Ok(record);
|
||||
}
|
||||
match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
|
||||
Ok(()) => {
|
||||
if record.is_terminal() {
|
||||
delete_manual_transition_scope_admission_if_current(
|
||||
api.clone(),
|
||||
&record.scope_key,
|
||||
record.job_id,
|
||||
record.lease_id,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
renew_manual_transition_scope_admission_from_job(api, &record).await?;
|
||||
}
|
||||
return Ok(record);
|
||||
}
|
||||
Err(Error::PreconditionFailed) => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
if record.is_terminal() {
|
||||
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?;
|
||||
} else {
|
||||
renew_manual_transition_scope_admission_from_job(api, &record).await?;
|
||||
}
|
||||
Ok(record)
|
||||
Err(Error::PreconditionFailed)
|
||||
}
|
||||
|
||||
async fn mark_manual_transition_job_unknown_for_task_journal_error(
|
||||
api: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
expected_lease_id: Option<Uuid>,
|
||||
error: String,
|
||||
queue_snapshot: ManualTransitionQueueSnapshot,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord> {
|
||||
let mut changed = false;
|
||||
let record = update_manual_transition_job_record(api.clone(), job_id, expected_lease_id, |record| {
|
||||
changed = record.mark_unknown_for_task_journal_error(error.clone(), queue_snapshot);
|
||||
changed
|
||||
})
|
||||
.await?;
|
||||
if changed && record.is_terminal() {
|
||||
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?;
|
||||
for _ in 0..4 {
|
||||
let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
||||
if !record.mark_unknown_for_task_journal_error(error.clone(), queue_snapshot) {
|
||||
return Ok(record);
|
||||
}
|
||||
match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
|
||||
Ok(()) => {
|
||||
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id)
|
||||
.await?;
|
||||
return Ok(record);
|
||||
}
|
||||
Err(Error::PreconditionFailed) => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Ok(record)
|
||||
Err(Error::PreconditionFailed)
|
||||
}
|
||||
|
||||
async fn mark_manual_transition_job_unknown_for_worker_result_journal_error(
|
||||
api: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
expected_lease_id: Option<Uuid>,
|
||||
error: String,
|
||||
queue_snapshot: ManualTransitionQueueSnapshot,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord> {
|
||||
let mut changed = false;
|
||||
let record = update_manual_transition_job_record(api.clone(), job_id, expected_lease_id, |record| {
|
||||
changed = record.mark_unknown_for_worker_result_journal_error(error.clone(), queue_snapshot);
|
||||
changed
|
||||
})
|
||||
.await?;
|
||||
if changed && record.is_terminal() {
|
||||
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?;
|
||||
for _ in 0..4 {
|
||||
let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
||||
if !record.mark_unknown_for_worker_result_journal_error(error.clone(), queue_snapshot) {
|
||||
return Ok(record);
|
||||
}
|
||||
match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
|
||||
Ok(()) => {
|
||||
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id)
|
||||
.await?;
|
||||
return Ok(record);
|
||||
}
|
||||
Err(Error::PreconditionFailed) => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Ok(record)
|
||||
Err(Error::PreconditionFailed)
|
||||
}
|
||||
|
||||
pub async fn save_manual_transition_scope_admission_if_absent(
|
||||
@@ -1762,14 +1603,19 @@ async fn find_active_legacy_manual_transition_scope_conflict(
|
||||
}
|
||||
|
||||
pub async fn request_manual_transition_job_cancel(api: Arc<ECStore>, job_id: Uuid) -> EcstoreResult<ManualTransitionJobRecord> {
|
||||
update_manual_transition_job_record(api, job_id, None, |record| {
|
||||
for _ in 0..4 {
|
||||
let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
||||
if record.is_terminal() || record.cancel_requested {
|
||||
return false;
|
||||
return Ok(record);
|
||||
}
|
||||
record.mark_cancel_requested();
|
||||
true
|
||||
})
|
||||
.await
|
||||
match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
|
||||
Ok(()) => return Ok(record),
|
||||
Err(Error::PreconditionFailed) => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(Error::PreconditionFailed)
|
||||
}
|
||||
|
||||
pub async fn persist_manual_transition_job_progress(
|
||||
@@ -1778,39 +1624,10 @@ pub async fn persist_manual_transition_job_progress(
|
||||
report: &ManualTransitionRunReport,
|
||||
queue_snapshot: ManualTransitionQueueSnapshot,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord> {
|
||||
let current = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
||||
persist_manual_transition_job_progress_inner(api, job_id, current.0.lease_id, Some(current), report, queue_snapshot).await
|
||||
}
|
||||
|
||||
pub async fn persist_manual_transition_job_progress_if_owned(
|
||||
api: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
expected_lease_id: Uuid,
|
||||
report: &ManualTransitionRunReport,
|
||||
queue_snapshot: ManualTransitionQueueSnapshot,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord> {
|
||||
persist_manual_transition_job_progress_inner(api, job_id, expected_lease_id, None, report, queue_snapshot).await
|
||||
}
|
||||
|
||||
async fn persist_manual_transition_job_progress_inner(
|
||||
api: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
expected_lease_id: Uuid,
|
||||
current: Option<(ManualTransitionJobRecord, String)>,
|
||||
report: &ManualTransitionRunReport,
|
||||
queue_snapshot: ManualTransitionQueueSnapshot,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord> {
|
||||
let record = update_manual_transition_job_record_from(api.clone(), job_id, Some(expected_lease_id), current, |record| {
|
||||
if record.state != ManualTransitionJobState::Running {
|
||||
return false;
|
||||
}
|
||||
record.update_running_progress(report.clone(), queue_snapshot);
|
||||
true
|
||||
})
|
||||
.await?;
|
||||
if record.state == ManualTransitionJobState::Running {
|
||||
renew_manual_transition_scope_admission_from_job(api, &record).await?;
|
||||
}
|
||||
let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
||||
record.update_running_progress(report.clone(), queue_snapshot);
|
||||
save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await?;
|
||||
renew_manual_transition_scope_admission_from_job(api, &record).await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
@@ -1844,58 +1661,25 @@ pub async fn renew_manual_transition_job_lease(
|
||||
job_id: Uuid,
|
||||
queue_snapshot: ManualTransitionQueueSnapshot,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord> {
|
||||
let current = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
||||
renew_manual_transition_job_lease_inner(api, job_id, current.0.lease_id, Some(current), queue_snapshot).await
|
||||
}
|
||||
|
||||
pub async fn renew_manual_transition_job_lease_if_owned(
|
||||
api: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
expected_lease_id: Uuid,
|
||||
queue_snapshot: ManualTransitionQueueSnapshot,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord> {
|
||||
renew_manual_transition_job_lease_inner(api, job_id, expected_lease_id, None, queue_snapshot).await
|
||||
}
|
||||
|
||||
async fn renew_manual_transition_job_lease_inner(
|
||||
api: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
expected_lease_id: Uuid,
|
||||
current: Option<(ManualTransitionJobRecord, String)>,
|
||||
queue_snapshot: ManualTransitionQueueSnapshot,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord> {
|
||||
let (current, current_etag) = match current {
|
||||
Some(current) => current,
|
||||
None => load_manual_transition_job_record_with_etag(api.clone(), job_id).await?,
|
||||
};
|
||||
if current.lease_id != expected_lease_id {
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
if current.state != ManualTransitionJobState::Running {
|
||||
return Ok(current);
|
||||
}
|
||||
if current.scan_completed && queue_snapshot.queued == 0 && queue_snapshot.active == 0 {
|
||||
return reconcile_manual_transition_worker_results_inner(api, job_id, Some(expected_lease_id), queue_snapshot, true)
|
||||
.await;
|
||||
}
|
||||
let record = update_manual_transition_job_record_from(
|
||||
api.clone(),
|
||||
job_id,
|
||||
Some(expected_lease_id),
|
||||
Some((current, current_etag)),
|
||||
|record| {
|
||||
if record.state != ManualTransitionJobState::Running {
|
||||
return false;
|
||||
let (mut record, mut etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
||||
if record.state == ManualTransitionJobState::Running {
|
||||
if record.scan_completed && queue_snapshot.queued == 0 && queue_snapshot.active == 0 {
|
||||
record = reconcile_manual_transition_worker_results(api.clone(), job_id, queue_snapshot).await?;
|
||||
if record.is_terminal() || !record.report.worker_transition_pending() {
|
||||
return Ok(record);
|
||||
}
|
||||
(record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
||||
}
|
||||
let became_terminal = record.mark_unknown_if_worker_results_lost(queue_snapshot);
|
||||
if !became_terminal {
|
||||
record.renew_lease(queue_snapshot);
|
||||
true
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if record.is_terminal() {
|
||||
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?;
|
||||
} else if record.state == ManualTransitionJobState::Running {
|
||||
renew_manual_transition_scope_admission_from_job(api, &record).await?;
|
||||
}
|
||||
save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await?;
|
||||
if became_terminal {
|
||||
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?;
|
||||
} else {
|
||||
renew_manual_transition_scope_admission_from_job(api, &record).await?;
|
||||
}
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
@@ -1904,31 +1688,15 @@ async fn renew_manual_transition_scope_admission_from_job(
|
||||
api: Arc<ECStore>,
|
||||
record: &ManualTransitionJobRecord,
|
||||
) -> EcstoreResult<()> {
|
||||
for _ in 0..MANUAL_TRANSITION_JOB_CAS_RETRIES {
|
||||
let (admission, admission_etag) =
|
||||
match load_manual_transition_scope_admission_with_etag(api.clone(), &record.scope_key).await {
|
||||
Ok(admission) => admission,
|
||||
Err(Error::ConfigNotFound) => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if admission.job_id != record.job_id || admission.lease_id != record.lease_id {
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
let mut renewed_admission = ManualTransitionScopeAdmission::from_job(record);
|
||||
renewed_admission.lease_expires_at_unix_nanos = renewed_admission
|
||||
.lease_expires_at_unix_nanos
|
||||
.max(admission.lease_expires_at_unix_nanos);
|
||||
renewed_admission.updated_at_unix_nanos = renewed_admission.updated_at_unix_nanos.max(admission.updated_at_unix_nanos);
|
||||
if renewed_admission == admission {
|
||||
return Ok(());
|
||||
}
|
||||
match save_manual_transition_scope_admission_if_current(api.clone(), &renewed_admission, &admission_etag).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(Error::PreconditionFailed) => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
if let Ok((admission, admission_etag)) =
|
||||
load_manual_transition_scope_admission_with_etag(api.clone(), &record.scope_key).await
|
||||
&& admission.job_id == record.job_id
|
||||
&& admission.lease_id == record.lease_id
|
||||
{
|
||||
let renewed_admission = ManualTransitionScopeAdmission::from_job(record);
|
||||
save_manual_transition_scope_admission_if_current(api, &renewed_admission, &admission_etag).await?;
|
||||
}
|
||||
Err(Error::PreconditionFailed)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_manual_transition_scope_admission_if_current(
|
||||
@@ -2618,14 +2386,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_transition_job_record_control_plane_failure_does_not_count_tier_failure() {
|
||||
fn manual_transition_job_record_failure_counts_tier_failure() {
|
||||
let options = ManualTransitionRunOptions::default();
|
||||
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
||||
|
||||
record.fail("missing tier");
|
||||
|
||||
assert_eq!(record.state, ManualTransitionJobState::Failed);
|
||||
assert_eq!(record.report.tier_failure, 0);
|
||||
assert_eq!(record.report.tier_failure, 1);
|
||||
assert_eq!(record.error.as_deref(), Some("missing tier"));
|
||||
}
|
||||
|
||||
|
||||
@@ -27,24 +27,12 @@ use crate::client::utils::base64_decode;
|
||||
use crate::client::utils::base64_encode;
|
||||
use crate::client::{api_put_object::PutObjectOptions, api_s3_datatypes::ObjectPart};
|
||||
use crate::{disk::DiskAPI, object_api::GetObjectReader};
|
||||
// s3s::header has no CRC64NVME constant yet; the canonical RustFS copy lives
|
||||
// in rustfs-utils' headers module.
|
||||
use rustfs_utils::http::headers::AMZ_CHECKSUM_CRC64NVME;
|
||||
use s3s::header::{
|
||||
X_AMZ_CHECKSUM_ALGORITHM, X_AMZ_CHECKSUM_CRC32, X_AMZ_CHECKSUM_CRC32C, X_AMZ_CHECKSUM_SHA1, X_AMZ_CHECKSUM_SHA256,
|
||||
};
|
||||
|
||||
use enumset::{EnumSet, EnumSetType, enum_set};
|
||||
|
||||
/// One of three deliberately separate checksum registries (backlog#1833):
|
||||
/// this enum is the MinIO-port client's wire vocabulary and stops at the
|
||||
/// standard S3 set (CRC64NVME is its newest member; the RustFS extensions do
|
||||
/// not exist on this client path). The streaming-hash registry lives in
|
||||
/// `rustfs_checksums::ChecksumAlgorithm` (crates/checksums/src/lib.rs) and
|
||||
/// the on-disk xl.meta bitset in `rustfs_rio::ChecksumType`
|
||||
/// (crates/rio/src/checksum.rs, varint bits are append-only). When adding an
|
||||
/// algorithm, extend all three (or record why not) — they do not derive from
|
||||
/// each other.
|
||||
#[derive(Debug, EnumSetType, Default)]
|
||||
#[enumset(repr = "u8")]
|
||||
pub enum ChecksumMode {
|
||||
@@ -69,6 +57,8 @@ lazy_static! {
|
||||
static ref C_ChecksumFullObjectCRC32C: EnumSet<ChecksumMode> =
|
||||
enum_set!(ChecksumMode::ChecksumCRC32C | ChecksumMode::ChecksumFullObject);
|
||||
}
|
||||
const AMZ_CHECKSUM_CRC64NVME: &str = "x-amz-checksum-crc64nvme";
|
||||
|
||||
impl ChecksumMode {
|
||||
//pub const CRC64_NVME_POLYNOMIAL: i64 = 0xad93d23594c93659;
|
||||
|
||||
|
||||
@@ -113,9 +113,6 @@ pub enum DiskError {
|
||||
#[error("bit-rot hash algorithm is invalid")]
|
||||
BitrotHashAlgoInvalid,
|
||||
|
||||
/// Never constructed locally by RustFS (only reachable through wire
|
||||
/// decoding, and no current node sends it). The wire code is kept for
|
||||
/// cross-version compatibility — do not renumber or remove (backlog#1831).
|
||||
#[error("Rename across devices not allowed, please fix your backend configuration")]
|
||||
CrossDeviceLink,
|
||||
|
||||
@@ -146,9 +143,6 @@ pub enum DiskError {
|
||||
#[error("io error {0}")]
|
||||
Io(#[source] io::Error),
|
||||
|
||||
/// Never constructed locally by RustFS (only reachable through wire
|
||||
/// decoding, and no current node sends it). The wire code is kept for
|
||||
/// cross-version compatibility — do not renumber or remove (backlog#1831).
|
||||
#[error("source stalled")]
|
||||
SourceStalled,
|
||||
|
||||
@@ -648,6 +642,19 @@ impl Hash for DiskError {
|
||||
// is currently commented out to avoid complexity. These can be re-enabled
|
||||
// when needed for specific disk quorum checking and error aggregation logic.
|
||||
|
||||
/// Bitrot errors
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BitrotErrorType {
|
||||
#[error("bitrot checksum verification failed")]
|
||||
BitrotChecksumMismatch { expected: String, got: String },
|
||||
}
|
||||
|
||||
impl From<BitrotErrorType> for DiskError {
|
||||
fn from(e: BitrotErrorType) -> Self {
|
||||
DiskError::other(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Context wrapper for file access errors
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub struct FileAccessDeniedWithContext {
|
||||
@@ -862,6 +869,19 @@ mod tests {
|
||||
let _disk_error: DiskError = json_error.into();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitrot_error_type() {
|
||||
let bitrot_error = BitrotErrorType::BitrotChecksumMismatch {
|
||||
expected: "abc123".to_string(),
|
||||
got: "def456".to_string(),
|
||||
};
|
||||
|
||||
assert!(bitrot_error.to_string().contains("bitrot checksum verification failed"));
|
||||
|
||||
let disk_error: DiskError = bitrot_error.into();
|
||||
assert!(matches!(disk_error, DiskError::Io(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_access_denied_with_context() {
|
||||
let path = PathBuf::from("/test/path");
|
||||
|
||||
@@ -309,17 +309,9 @@ const ENV_API_LIST_OBJECTS_INDEX_PROVIDER: &str = "RUSTFS_LIST_OBJECTS_INDEX_PRO
|
||||
const ENV_API_LIST_OBJECTS_INDEX_PROVIDER_PATH: &str = "RUSTFS_LIST_OBJECTS_INDEX_PROVIDER_PATH";
|
||||
const ENV_API_LIST_OBJECTS_INDEX_PROVIDER_GENERATION: &str = "RUSTFS_LIST_OBJECTS_INDEX_PROVIDER_GENERATION";
|
||||
const ENV_API_LIST_OBJECTS_NAMESPACE_JOURNAL_PATH: &str = "RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_PATH";
|
||||
// The chaos machinery below is compiled only for tests and the opt-in
|
||||
// `list-chaos` feature (backlog#1832): a production binary without the
|
||||
// feature carries no chaos symbols, so the two env vars cannot silently
|
||||
// rewrite a bucket's namespace-journal state.
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
const ENV_API_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_ENABLED: &str = "RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_ENABLED";
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
const ENV_API_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_BUCKET: &str = "RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_BUCKET";
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
const ENV_API_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_SEQUENCE: &str = "RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_SEQUENCE";
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
const ENV_API_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_STATUS: &str = "RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_STATUS";
|
||||
const ENV_API_LIST_OBJECTS_METADATA_FAST_ENABLED: &str = "RUSTFS_LIST_OBJECTS_METADATA_FAST_ENABLED";
|
||||
const ENV_API_LIST_OBJECTS_METADATA_FAST_STALENESS_MS: &str = "RUSTFS_LIST_OBJECTS_METADATA_FAST_STALENESS_MS";
|
||||
@@ -560,9 +552,7 @@ static LIST_OBJECTS_MUTATION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
static SCANNER_NAMESPACE_MUTATION_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
static LIST_OBJECTS_BUCKET_MUTATION_SEQUENCE: OnceCell<RwLock<HashMap<String, u64>>> = OnceCell::const_new();
|
||||
static LIST_OBJECTS_NAMESPACE_JOURNAL_DEGRADED_BUCKETS: OnceCell<RwLock<HashSet<String>>> = OnceCell::const_new();
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
static LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_CONFIG: OnceCell<Option<NamespaceMutationJournalChaosConfig>> = OnceCell::const_new();
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
static LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_APPLIED: OnceCell<RwLock<HashSet<String>>> = OnceCell::const_new();
|
||||
|
||||
async fn persistent_key_only_index_cache() -> &'static RwLock<Option<PersistentKeyOnlyIndexCache>> {
|
||||
@@ -589,7 +579,6 @@ async fn list_objects_namespace_journal_degraded_buckets() -> &'static RwLock<Ha
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
async fn list_objects_namespace_journal_chaos_config() -> Option<&'static NamespaceMutationJournalChaosConfig> {
|
||||
LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_CONFIG
|
||||
.get_or_init(|| async { namespace_mutation_journal_chaos_config_from_env() })
|
||||
@@ -597,7 +586,6 @@ async fn list_objects_namespace_journal_chaos_config() -> Option<&'static Namesp
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
async fn list_objects_namespace_journal_chaos_applied() -> &'static RwLock<HashSet<String>> {
|
||||
LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_APPLIED
|
||||
.get_or_init(|| async { RwLock::new(HashSet::new()) })
|
||||
@@ -693,7 +681,6 @@ enum NamespaceMutationJournalStatus {
|
||||
}
|
||||
|
||||
impl NamespaceMutationJournalStatus {
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
fn from_env_value(value: &str) -> Option<Self> {
|
||||
if value.eq_ignore_ascii_case(LIST_OBJECTS_NAMESPACE_JOURNAL_STATUS_HEALTHY) {
|
||||
Some(Self::Healthy)
|
||||
@@ -704,7 +691,6 @@ impl NamespaceMutationJournalStatus {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
fn env_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Healthy => LIST_OBJECTS_NAMESPACE_JOURNAL_STATUS_HEALTHY,
|
||||
@@ -726,7 +712,6 @@ struct NamespaceMutationJournalSnapshot {
|
||||
degraded: bool,
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct NamespaceMutationJournalChaosConfig {
|
||||
bucket: String,
|
||||
@@ -810,35 +795,30 @@ fn list_objects_namespace_journal_root_from_env() -> Option<PathBuf> {
|
||||
.filter(|path| !path.as_os_str().is_empty())
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
fn namespace_mutation_journal_chaos_enabled_from_env() -> bool {
|
||||
std::env::var(ENV_API_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_ENABLED)
|
||||
.ok()
|
||||
.is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("on") || value.eq_ignore_ascii_case("true"))
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
fn namespace_mutation_journal_chaos_bucket_from_env() -> Option<String> {
|
||||
std::env::var(ENV_API_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_BUCKET)
|
||||
.ok()
|
||||
.filter(|bucket| !bucket.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
fn namespace_mutation_journal_chaos_sequence_from_env() -> Option<u64> {
|
||||
std::env::var(ENV_API_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_SEQUENCE)
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
fn namespace_mutation_journal_chaos_status_from_env() -> Option<NamespaceMutationJournalStatus> {
|
||||
std::env::var(ENV_API_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_STATUS)
|
||||
.ok()
|
||||
.and_then(|value| NamespaceMutationJournalStatus::from_env_value(&value))
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
fn namespace_mutation_journal_chaos_config_from_env() -> Option<NamespaceMutationJournalChaosConfig> {
|
||||
if !namespace_mutation_journal_chaos_enabled_from_env() {
|
||||
return None;
|
||||
@@ -866,7 +846,6 @@ fn namespace_mutation_journal_chaos_config_from_env() -> Option<NamespaceMutatio
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
fn namespace_mutation_journal_chaos_applied_key(bucket: &str, status: NamespaceMutationJournalStatus) -> String {
|
||||
let mut key = String::with_capacity(bucket.len() + 1 + status.env_value().len());
|
||||
key.push_str(bucket);
|
||||
@@ -875,13 +854,6 @@ fn namespace_mutation_journal_chaos_applied_key(bucket: &str, status: NamespaceM
|
||||
key
|
||||
}
|
||||
|
||||
/// Production no-op twin of the chaos injector: without `list-chaos` the
|
||||
/// injection point compiles to nothing (backlog#1832).
|
||||
#[cfg(not(any(test, feature = "list-chaos")))]
|
||||
#[inline]
|
||||
async fn maybe_apply_system_namespace_mutation_journal_chaos(_store: &ECStore, _bucket: &str, _default_sequence: u64) {}
|
||||
|
||||
#[cfg(any(test, feature = "list-chaos"))]
|
||||
async fn maybe_apply_system_namespace_mutation_journal_chaos(store: &ECStore, bucket: &str, default_sequence: u64) {
|
||||
let Some(config) = list_objects_namespace_journal_chaos_config().await else {
|
||||
return;
|
||||
|
||||
@@ -1585,7 +1585,7 @@ impl ECStore {
|
||||
) -> Result<GetObjectReader> {
|
||||
check_get_obj_args(bucket, object)?;
|
||||
|
||||
let object = rustfs_utils::path::encode_dir_object_ref(object);
|
||||
let object = encode_dir_object(object);
|
||||
let mut opts = opts.clone();
|
||||
let read_lock_guard = self
|
||||
.acquire_object_read_lock_if_needed("get_object", bucket, &object, &mut opts)
|
||||
@@ -1593,14 +1593,14 @@ impl ECStore {
|
||||
|
||||
let reader = if self.single_pool() {
|
||||
self.pools[0]
|
||||
.get_object_reader(bucket, object.as_ref(), range, h, &opts)
|
||||
.get_object_reader(bucket, object.as_str(), range, h, &opts)
|
||||
.await?
|
||||
} else {
|
||||
let (_, idx) = self
|
||||
.get_latest_accessible_object_info_with_idx(bucket, &object, &opts)
|
||||
.await?;
|
||||
self.pools[idx]
|
||||
.get_object_reader(bucket, object.as_ref(), range, h, &opts)
|
||||
.get_object_reader(bucket, object.as_str(), range, h, &opts)
|
||||
.await?
|
||||
};
|
||||
|
||||
|
||||
+49
-60
@@ -14,23 +14,19 @@
|
||||
|
||||
use crate::IamStorageError;
|
||||
use rustfs_policy::policy::Error as PolicyError;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
// Arc payloads keep Clone variant-preserving for the non-cloneable inner
|
||||
// errors (backlog#1831 PR2). Display is unchanged; the source() chain is
|
||||
// not forwarded (Arc<E> does not implement std::error::Error).
|
||||
#[error("{0}")]
|
||||
PolicyError(Arc<PolicyError>),
|
||||
#[error(transparent)]
|
||||
PolicyError(#[from] PolicyError),
|
||||
|
||||
#[error("{0}")]
|
||||
StringError(String),
|
||||
|
||||
#[error("crypto: {0}")]
|
||||
CryptoError(Arc<rustfs_crypto::Error>),
|
||||
CryptoError(#[from] rustfs_crypto::Error),
|
||||
|
||||
#[error("user '{0}' does not exist")]
|
||||
NoSuchUser(String),
|
||||
@@ -62,6 +58,15 @@ pub enum Error {
|
||||
#[error("not initialized")]
|
||||
IamSysNotInitialized,
|
||||
|
||||
#[error("invalid service type: {0}")]
|
||||
InvalidServiceType(String),
|
||||
|
||||
#[error("malformed credential")]
|
||||
ErrCredMalformed,
|
||||
|
||||
#[error("CredNotInitialized")]
|
||||
CredNotInitialized,
|
||||
|
||||
#[error("invalid access key length")]
|
||||
InvalidAccessKeyLength,
|
||||
|
||||
@@ -74,12 +79,27 @@ pub enum Error {
|
||||
#[error("group name contains reserved characters =,")]
|
||||
GroupNameContainsReservedChars,
|
||||
|
||||
#[error("jwt err {0}")]
|
||||
JWTError(jsonwebtoken::errors::Error),
|
||||
|
||||
#[error("no access key")]
|
||||
NoAccessKey,
|
||||
|
||||
#[error("invalid token")]
|
||||
InvalidToken,
|
||||
|
||||
#[error("invalid access_key")]
|
||||
InvalidAccessKey,
|
||||
|
||||
#[error("access key is already in use")]
|
||||
AccessKeyAlreadyExists,
|
||||
|
||||
#[error("action not allowed")]
|
||||
IAMActionNotAllowed,
|
||||
|
||||
#[error("invalid expiration")]
|
||||
InvalidExpiration,
|
||||
|
||||
#[error("no secret key with access key")]
|
||||
NoSecretKeyWithAccessKey,
|
||||
|
||||
@@ -108,8 +128,9 @@ impl PartialEq for Error {
|
||||
(Error::NoSuchServiceAccount(a), Error::NoSuchServiceAccount(b)) => a == b,
|
||||
(Error::NoSuchTempAccount(a), Error::NoSuchTempAccount(b)) => a == b,
|
||||
(Error::NoSuchGroup(a), Error::NoSuchGroup(b)) => a == b,
|
||||
(Error::InvalidServiceType(a), Error::InvalidServiceType(b)) => a == b,
|
||||
(Error::Io(a), Error::Io(b)) => a.kind() == b.kind() && a.to_string() == b.to_string(),
|
||||
// For complex types like PolicyError and CryptoError, compare string representations
|
||||
// For complex types like PolicyError, CryptoError, JWTError, compare string representations
|
||||
(a, b) => std::mem::discriminant(a) == std::mem::discriminant(b) && a.to_string() == b.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -118,9 +139,9 @@ impl PartialEq for Error {
|
||||
impl Clone for Error {
|
||||
fn clone(&self) -> Self {
|
||||
match self {
|
||||
Error::PolicyError(e) => Error::PolicyError(Arc::clone(e)),
|
||||
Error::PolicyError(e) => Error::StringError(e.to_string()), // Convert to string since PolicyError may not be cloneable
|
||||
Error::StringError(s) => Error::StringError(s.clone()),
|
||||
Error::CryptoError(e) => Error::CryptoError(Arc::clone(e)),
|
||||
Error::CryptoError(e) => Error::StringError(format!("crypto: {e}")), // Convert to string
|
||||
Error::NoSuchUser(s) => Error::NoSuchUser(s.clone()),
|
||||
Error::NoSuchAccount(s) => Error::NoSuchAccount(s.clone()),
|
||||
Error::NoSuchServiceAccount(s) => Error::NoSuchServiceAccount(s.clone()),
|
||||
@@ -131,12 +152,20 @@ impl Clone for Error {
|
||||
Error::GroupNotEmpty => Error::GroupNotEmpty,
|
||||
Error::InvalidArgument => Error::InvalidArgument,
|
||||
Error::IamSysNotInitialized => Error::IamSysNotInitialized,
|
||||
Error::InvalidServiceType(s) => Error::InvalidServiceType(s.clone()),
|
||||
Error::ErrCredMalformed => Error::ErrCredMalformed,
|
||||
Error::CredNotInitialized => Error::CredNotInitialized,
|
||||
Error::InvalidAccessKeyLength => Error::InvalidAccessKeyLength,
|
||||
Error::InvalidSecretKeyLength => Error::InvalidSecretKeyLength,
|
||||
Error::ContainsReservedChars => Error::ContainsReservedChars,
|
||||
Error::GroupNameContainsReservedChars => Error::GroupNameContainsReservedChars,
|
||||
Error::JWTError(e) => Error::StringError(format!("jwt err {e}")), // Convert to string
|
||||
Error::NoAccessKey => Error::NoAccessKey,
|
||||
Error::InvalidToken => Error::InvalidToken,
|
||||
Error::InvalidAccessKey => Error::InvalidAccessKey,
|
||||
Error::AccessKeyAlreadyExists => Error::AccessKeyAlreadyExists,
|
||||
Error::IAMActionNotAllowed => Error::IAMActionNotAllowed,
|
||||
Error::InvalidExpiration => Error::InvalidExpiration,
|
||||
Error::NoSecretKeyWithAccessKey => Error::NoSecretKeyWithAccessKey,
|
||||
Error::NoAccessKeyWithSecretKey => Error::NoAccessKeyWithSecretKey,
|
||||
Error::PolicyTooLarge => Error::PolicyTooLarge,
|
||||
@@ -147,18 +176,6 @@ impl Clone for Error {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PolicyError> for Error {
|
||||
fn from(e: PolicyError) -> Self {
|
||||
Error::PolicyError(Arc::new(e))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rustfs_crypto::Error> for Error {
|
||||
fn from(e: rustfs_crypto::Error) -> Self {
|
||||
Error::CryptoError(Arc::new(e))
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn other<E>(error: E) -> Self
|
||||
where
|
||||
@@ -191,10 +208,16 @@ impl From<rustfs_policy::error::Error> for Error {
|
||||
match e {
|
||||
rustfs_policy::error::Error::PolicyTooLarge => Error::PolicyTooLarge,
|
||||
rustfs_policy::error::Error::InvalidArgument => Error::InvalidArgument,
|
||||
rustfs_policy::error::Error::InvalidServiceType(s) => Error::InvalidServiceType(s),
|
||||
rustfs_policy::error::Error::IAMActionNotAllowed => Error::IAMActionNotAllowed,
|
||||
rustfs_policy::error::Error::InvalidExpiration => Error::InvalidExpiration,
|
||||
rustfs_policy::error::Error::NoAccessKey => Error::NoAccessKey,
|
||||
rustfs_policy::error::Error::InvalidToken => Error::InvalidToken,
|
||||
rustfs_policy::error::Error::InvalidAccessKey => Error::InvalidAccessKey,
|
||||
rustfs_policy::error::Error::NoSecretKeyWithAccessKey => Error::NoSecretKeyWithAccessKey,
|
||||
rustfs_policy::error::Error::NoAccessKeyWithSecretKey => Error::NoAccessKeyWithSecretKey,
|
||||
rustfs_policy::error::Error::Io(e) => Error::Io(e),
|
||||
rustfs_policy::error::Error::JWTError(e) => Error::JWTError(e),
|
||||
rustfs_policy::error::Error::NoSuchUser(s) => Error::NoSuchUser(s),
|
||||
rustfs_policy::error::Error::NoSuchAccount(s) => Error::NoSuchAccount(s),
|
||||
rustfs_policy::error::Error::NoSuchServiceAccount(s) => Error::NoSuchServiceAccount(s),
|
||||
@@ -207,22 +230,13 @@ impl From<rustfs_policy::error::Error> for Error {
|
||||
rustfs_policy::error::Error::InvalidSecretKeyLength => Error::InvalidSecretKeyLength,
|
||||
rustfs_policy::error::Error::ContainsReservedChars => Error::ContainsReservedChars,
|
||||
rustfs_policy::error::Error::GroupNameContainsReservedChars => Error::GroupNameContainsReservedChars,
|
||||
rustfs_policy::error::Error::CredNotInitialized => Error::CredNotInitialized,
|
||||
rustfs_policy::error::Error::IamSysNotInitialized => Error::IamSysNotInitialized,
|
||||
rustfs_policy::error::Error::PolicyError(e) => Error::PolicyError(Arc::new(e)),
|
||||
rustfs_policy::error::Error::PolicyError(e) => Error::PolicyError(e),
|
||||
rustfs_policy::error::Error::StringError(s) => Error::StringError(s),
|
||||
rustfs_policy::error::Error::CryptoError(e) => Error::CryptoError(Arc::new(e)),
|
||||
rustfs_policy::error::Error::CryptoError(e) => Error::CryptoError(e),
|
||||
rustfs_policy::error::Error::ErrCredMalformed => Error::ErrCredMalformed,
|
||||
rustfs_policy::error::Error::IamSysAlreadyInitialized => Error::IamSysAlreadyInitialized,
|
||||
// These policy variants had dead same-name twins on iam::Error (zero
|
||||
// construction and zero match sites, removed in backlog#1831); the
|
||||
// message is preserved through StringError instead.
|
||||
err @ (rustfs_policy::error::Error::InvalidServiceType(_)
|
||||
| rustfs_policy::error::Error::InvalidExpiration
|
||||
| rustfs_policy::error::Error::NoAccessKey
|
||||
| rustfs_policy::error::Error::InvalidToken
|
||||
| rustfs_policy::error::Error::InvalidAccessKey
|
||||
| rustfs_policy::error::Error::JWTError(_)
|
||||
| rustfs_policy::error::Error::CredNotInitialized
|
||||
| rustfs_policy::error::Error::ErrCredMalformed) => Error::StringError(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -401,31 +415,6 @@ mod tests {
|
||||
assert!(converted_io.to_string().contains("access denied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_preserves_variant_identity_and_message() {
|
||||
// backlog#1831 PR2: cloning must never demote a variant to a different
|
||||
// one (the old Clone stringified PolicyError/CryptoError into
|
||||
// StringError). Pin discriminant and rendered message across clone.
|
||||
let errors = vec![
|
||||
Error::PolicyError(Arc::new(PolicyError::NonAction)),
|
||||
Error::CryptoError(Arc::new(rustfs_crypto::Error::ErrInvalidKeyLength)),
|
||||
Error::Io(std::io::Error::other("io payload")),
|
||||
Error::StringError("plain".to_string()),
|
||||
Error::NoSuchUser("u".to_string()),
|
||||
Error::ConfigNotFound,
|
||||
];
|
||||
|
||||
for error in errors {
|
||||
let cloned = error.clone();
|
||||
assert_eq!(
|
||||
std::mem::discriminant(&error),
|
||||
std::mem::discriminant(&cloned),
|
||||
"clone must keep the variant of {error:?}"
|
||||
);
|
||||
assert_eq!(error.to_string(), cloned.to_string(), "clone must keep the rendered message");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_display_format() {
|
||||
let test_cases = vec,
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Removed
|
||||
|
||||
#### rustfs-io-metrics
|
||||
- **Unified configuration** (added in 0.0.5): the zero-consumer `IoConfig`, `CacheSettings`, `IoSchedulerSettings`, `BackpressureSettings`, `TimeoutSettings`, `DeadlockDetectionSettings` types and their `DEFAULT_*` constants were removed (rustfs/rustfs#6008); rustfs-io-core's `IoSchedulerConfig`/`BackpressureConfig` remain the canonical configuration types.
|
||||
|
||||
## [0.0.5] - 2025-01-XX
|
||||
|
||||
### Added
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
- **Metrics Collection**: Unified metrics recording and reporting
|
||||
- **Bandwidth Monitoring**: Real-time bandwidth observation and analysis
|
||||
- **Performance Metrics**: I/O performance metrics collection
|
||||
- **Unified Configuration**: Centralized configuration management
|
||||
- **Exporter Boundary**: Emit via `metrics`, export via `rustfs-obs`, no Prometheus HTTP endpoint
|
||||
|
||||
## Features
|
||||
@@ -202,6 +203,30 @@ path and include:
|
||||
deltas with `operation` and `backend` columns, so the TCP baseline can attribute
|
||||
bytes and request/error counts to `tcp-http` transport operations.
|
||||
|
||||
### Unified Configuration
|
||||
|
||||
Centralized configuration management:
|
||||
|
||||
```rust
|
||||
use rustfs_io_metrics::{
|
||||
IoConfig, CacheSettings, IoSchedulerSettings,
|
||||
BackpressureSettings, TimeoutSettings,
|
||||
};
|
||||
|
||||
let config = IoConfig::new()
|
||||
.with_cache(CacheSettings::new()
|
||||
.with_max_capacity(10_000)
|
||||
.with_ttl(std::time::Duration::from_secs(300)))
|
||||
.with_scheduler(IoSchedulerSettings::new()
|
||||
.with_max_concurrent_reads(64))
|
||||
.with_backpressure(BackpressureSettings::new())
|
||||
.with_timeout(TimeoutSettings::new());
|
||||
|
||||
// Access configuration
|
||||
println!("Cache capacity: {}", config.cache.max_capacity);
|
||||
println!("Max concurrent reads: {}", config.scheduler.max_concurrent_reads);
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
@@ -210,6 +235,7 @@ rustfs-io-metrics/
|
||||
│ ├── lib.rs # Module entry
|
||||
│ ├── cache_config.rs # Cache configuration
|
||||
│ ├── adaptive_ttl.rs # Adaptive TTL
|
||||
│ ├── config.rs # Unified configuration
|
||||
│ ├── io_metrics.rs # I/O metrics
|
||||
│ ├── backpressure_metrics.rs # Backpressure metrics
|
||||
│ ├── deadlock_metrics.rs # Deadlock metrics
|
||||
@@ -252,6 +278,7 @@ Useful source references:
|
||||
|
||||
- [Crate API overview](./src/lib.rs)
|
||||
- [Metrics example](./examples/metrics_example.rs)
|
||||
- [Configuration module](./src/config.rs)
|
||||
- [Adaptive TTL module](./src/adaptive_ttl.rs)
|
||||
|
||||
## Related Modules
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
- **指标收集**:统一的指标记录和上报
|
||||
- **带宽监控**:实时带宽观测和分析
|
||||
- **性能指标**:I/O 性能指标收集
|
||||
- **统一配置**:集中式配置管理
|
||||
- **导出边界**:通过 `metrics` 主动上报,由 `rustfs-obs` 负责 OTEL 导出,不提供 Prometheus HTTP 端点
|
||||
|
||||
## ✨ 核心功能
|
||||
@@ -171,6 +172,30 @@ println!("读取速率: {} bytes/s", snapshot.read_bytes_per_sec);
|
||||
println!("写入速率: {} bytes/s", snapshot.write_bytes_per_sec);
|
||||
```
|
||||
|
||||
### 统一配置 (IoConfig)
|
||||
|
||||
集中式配置管理:
|
||||
|
||||
```rust
|
||||
use rustfs_io_metrics::{
|
||||
IoConfig, CacheSettings, IoSchedulerSettings,
|
||||
BackpressureSettings, TimeoutSettings,
|
||||
};
|
||||
|
||||
let config = IoConfig::new()
|
||||
.with_cache(CacheSettings::new()
|
||||
.with_max_capacity(10_000)
|
||||
.with_ttl(std::time::Duration::from_secs(300)))
|
||||
.with_scheduler(IoSchedulerSettings::new()
|
||||
.with_max_concurrent_reads(64))
|
||||
.with_backpressure(BackpressureSettings::new())
|
||||
.with_timeout(TimeoutSettings::new());
|
||||
|
||||
// 访问配置
|
||||
println!("缓存容量: {}", config.cache.max_capacity);
|
||||
println!("最大并发读: {}", config.scheduler.max_concurrent_reads);
|
||||
```
|
||||
|
||||
## 📊 指标类型
|
||||
|
||||
### I/O 调度指标
|
||||
@@ -208,6 +233,21 @@ println!("写入速率: {} bytes/s", snapshot.write_bytes_per_sec);
|
||||
| `operation_duration_secs` | 操作时长 | Histogram |
|
||||
| `operation_progress` | 操作进度 | Gauge |
|
||||
|
||||
## 🔧 配置
|
||||
|
||||
### 代码配置
|
||||
|
||||
```rust
|
||||
use rustfs_io_metrics::{CacheSettings, IoConfig};
|
||||
|
||||
let settings = CacheSettings::new()
|
||||
.with_max_capacity(5000)
|
||||
.with_ttl(std::time::Duration::from_secs(600))
|
||||
.with_max_memory(200 * 1024 * 1024);
|
||||
|
||||
let config = IoConfig::new().with_cache(settings);
|
||||
```
|
||||
|
||||
## 📁 模块结构
|
||||
|
||||
```
|
||||
@@ -216,6 +256,7 @@ rustfs-io-metrics/
|
||||
│ ├── lib.rs # 模块入口
|
||||
│ ├── cache_config.rs # 缓存配置
|
||||
│ ├── adaptive_ttl.rs # 自适应 TTL
|
||||
│ ├── config.rs # 统一配置
|
||||
│ ├── io_metrics.rs # I/O 指标
|
||||
│ ├── backpressure_metrics.rs # 背压指标
|
||||
│ ├── deadlock_metrics.rs # 死锁指标
|
||||
@@ -256,6 +297,7 @@ cargo doc --package rustfs-io-metrics --no-deps --open
|
||||
|
||||
- [Crate API 概览](./src/lib.rs)
|
||||
- [指标示例](./examples/metrics_example.rs)
|
||||
- [配置模块](./src/config.rs)
|
||||
- [自适应 TTL 模块](./src/adaptive_ttl.rs)
|
||||
|
||||
## 🔗 相关模块
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
|
||||
//! Example demonstrating metrics and configuration usage.
|
||||
|
||||
use rustfs_io_metrics::{AccessTracker, AdaptiveTTL, CacheConfig, record_cache_size};
|
||||
use rustfs_io_metrics::{
|
||||
AccessTracker, AdaptiveTTL, CacheConfig, CacheSettings, IoConfig, IoSchedulerSettings, record_cache_size,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() {
|
||||
@@ -29,7 +31,10 @@ fn main() {
|
||||
// 3. Access tracking example
|
||||
access_tracker_example();
|
||||
|
||||
// 4. Metrics recording example
|
||||
// 4. Unified configuration example
|
||||
unified_config_example();
|
||||
|
||||
// 5. Metrics recording example
|
||||
metrics_recording_example();
|
||||
}
|
||||
|
||||
@@ -104,6 +109,26 @@ fn access_tracker_example() {
|
||||
println!();
|
||||
}
|
||||
|
||||
fn unified_config_example() {
|
||||
println!("--- Unified Configuration ---");
|
||||
|
||||
let config = IoConfig::new()
|
||||
.with_cache(
|
||||
CacheSettings::new()
|
||||
.with_max_capacity(5000)
|
||||
.with_ttl(Duration::from_secs(600)),
|
||||
)
|
||||
.with_scheduler(IoSchedulerSettings::new().with_max_concurrent_reads(64));
|
||||
|
||||
println!(" Cache capacity: {}", config.cache.max_capacity);
|
||||
println!(" Cache TTL: {:?}", config.cache.default_ttl);
|
||||
println!(" Max concurrent reads: {}", config.scheduler.max_concurrent_reads);
|
||||
println!(" Backpressure high watermark: {}", config.backpressure.high_watermark);
|
||||
println!(" Default timeout: {:?}", config.timeout.default_timeout);
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
fn metrics_recording_example() {
|
||||
println!("--- Metrics Recording ---");
|
||||
|
||||
|
||||
@@ -315,44 +315,6 @@ impl Default for AccessTracker {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Replaces the per-helper smoke tests that called the record_* helpers
|
||||
/// and asserted nothing: the calls (same literals) now run against a local
|
||||
/// DebuggingRecorder and every metric name the helpers own must actually
|
||||
/// be emitted (rustfs/backlog#1836 PR3).
|
||||
#[test]
|
||||
fn record_helpers_emit_their_metrics() {
|
||||
let recorder = metrics_util::debugging::DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
record_ttl_adjustment("test-key", 100, 150);
|
||||
record_ttl_adjustment("test-key", 100, 50);
|
||||
record_ttl_expiration();
|
||||
record_early_eviction("cold");
|
||||
record_early_eviction("low_priority");
|
||||
record_access_pattern_change("sequential", "random");
|
||||
record_access_pattern_change("random", "sequential");
|
||||
});
|
||||
|
||||
let emitted: std::collections::HashSet<String> = snapshotter
|
||||
.snapshot()
|
||||
.into_vec()
|
||||
.into_iter()
|
||||
.map(|(composite, _, _, _)| composite.key().name().to_string())
|
||||
.collect();
|
||||
for expected in [
|
||||
"rustfs_cache_ttl_adjustments",
|
||||
"rustfs_cache_ttl_base",
|
||||
"rustfs_cache_ttl_adjusted",
|
||||
"rustfs_cache_ttl_extensions",
|
||||
"rustfs_cache_ttl_reductions",
|
||||
"rustfs_cache_ttl_expirations",
|
||||
"rustfs_cache_evictions_early",
|
||||
"rustfs_cache_access_pattern_changes",
|
||||
] {
|
||||
assert!(emitted.contains(expected), "{expected} must be emitted by its record helper");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adaptive_ttl_stats() {
|
||||
let mut stats = AdaptiveTTLStats::new();
|
||||
@@ -373,6 +335,30 @@ mod tests {
|
||||
assert!((stats.reduction_rate() - 0.3333333333333333).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_ttl_adjustment() {
|
||||
// This test verifies the function compiles and runs
|
||||
record_ttl_adjustment("test-key", 100, 150);
|
||||
record_ttl_adjustment("test-key", 100, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_ttl_expiration() {
|
||||
record_ttl_expiration();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_early_eviction() {
|
||||
record_early_eviction("cold");
|
||||
record_early_eviction("low_priority");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_access_pattern_change() {
|
||||
record_access_pattern_change("sequential", "random");
|
||||
record_access_pattern_change("random", "sequential");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_access_record() {
|
||||
let mut record = AccessRecord::new();
|
||||
|
||||
@@ -53,38 +53,30 @@ pub fn record_backpressure_deactivation() {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Replaces the per-helper smoke tests that called the record_* helpers
|
||||
/// and asserted nothing: the calls (same literals) now run against a local
|
||||
/// DebuggingRecorder and every metric name the helpers own must actually
|
||||
/// be emitted (rustfs/backlog#1836 PR3).
|
||||
#[test]
|
||||
fn record_helpers_emit_their_metrics() {
|
||||
let recorder = metrics_util::debugging::DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
record_backpressure_state_change("normal", "warning");
|
||||
record_backpressure_state_change("warning", "critical");
|
||||
record_backpressure_rejection();
|
||||
record_concurrent_operations(10);
|
||||
record_concurrent_operations(32);
|
||||
record_backpressure_activation();
|
||||
record_backpressure_deactivation();
|
||||
});
|
||||
fn test_record_backpressure_state_change() {
|
||||
record_backpressure_state_change("normal", "warning");
|
||||
record_backpressure_state_change("warning", "critical");
|
||||
}
|
||||
|
||||
let emitted: std::collections::HashSet<String> = snapshotter
|
||||
.snapshot()
|
||||
.into_vec()
|
||||
.into_iter()
|
||||
.map(|(composite, _, _, _)| composite.key().name().to_string())
|
||||
.collect();
|
||||
for expected in [
|
||||
"rustfs_backpressure_state_changes",
|
||||
"rustfs_backpressure_rejections",
|
||||
"rustfs_backpressure_concurrent",
|
||||
"rustfs_backpressure_activations",
|
||||
"rustfs_backpressure_deactivations",
|
||||
] {
|
||||
assert!(emitted.contains(expected), "{expected} must be emitted by its record helper");
|
||||
}
|
||||
#[test]
|
||||
fn test_record_backpressure_rejection() {
|
||||
record_backpressure_rejection();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_concurrent_operations() {
|
||||
record_concurrent_operations(10);
|
||||
record_concurrent_operations(32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_backpressure_activation() {
|
||||
record_backpressure_activation();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_backpressure_deactivation() {
|
||||
record_backpressure_deactivation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
// 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.
|
||||
|
||||
//! Unified configuration interface for I/O operations.
|
||||
//!
|
||||
//! This module provides a centralized configuration interface
|
||||
//! for all I/O-related settings.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
// ============================================================================
|
||||
// Configuration Constants
|
||||
// ============================================================================
|
||||
|
||||
/// Default cache max capacity.
|
||||
pub const DEFAULT_CACHE_MAX_CAPACITY: u64 = 10_000;
|
||||
/// Default cache TTL in seconds.
|
||||
pub const DEFAULT_CACHE_TTL_SECS: u64 = 300;
|
||||
/// Default cache max memory in bytes (100 MB).
|
||||
pub const DEFAULT_CACHE_MAX_MEMORY: u64 = 100 * 1024 * 1024;
|
||||
|
||||
/// Default I/O scheduler max concurrent reads.
|
||||
pub const DEFAULT_MAX_CONCURRENT_READS: usize = 32;
|
||||
/// Default high priority size threshold (64 KB).
|
||||
pub const DEFAULT_HIGH_PRIORITY_SIZE_THRESHOLD: usize = 64 * 1024;
|
||||
/// Default low priority size threshold (4 MB).
|
||||
pub const DEFAULT_LOW_PRIORITY_SIZE_THRESHOLD: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// Default backpressure high watermark.
|
||||
pub const DEFAULT_BACKPRESSURE_HIGH_WATERMARK: f64 = 0.8;
|
||||
/// Default backpressure low watermark.
|
||||
pub const DEFAULT_BACKPRESSURE_LOW_WATERMARK: f64 = 0.5;
|
||||
|
||||
/// Default lock acquire timeout in seconds.
|
||||
pub const DEFAULT_LOCK_ACQUIRE_TIMEOUT_SECS: u64 = 5;
|
||||
/// Default deadlock detection interval in seconds.
|
||||
pub const DEFAULT_DEADLOCK_DETECTION_INTERVAL_SECS: u64 = 1;
|
||||
|
||||
/// Default base buffer size (128 KB).
|
||||
pub const DEFAULT_BASE_BUFFER_SIZE: usize = 128 * 1024;
|
||||
/// Default max buffer size (1 MB).
|
||||
pub const DEFAULT_MAX_BUFFER_SIZE: usize = 1024 * 1024;
|
||||
/// Default min buffer size (4 KB).
|
||||
pub const DEFAULT_MIN_BUFFER_SIZE: usize = 4 * 1024;
|
||||
|
||||
// ============================================================================
|
||||
// Cache Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// Cache configuration settings.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CacheSettings {
|
||||
/// Maximum cache capacity.
|
||||
pub max_capacity: u64,
|
||||
/// Default TTL.
|
||||
pub default_ttl: Duration,
|
||||
/// Maximum memory usage.
|
||||
pub max_memory: u64,
|
||||
/// Whether adaptive TTL is enabled.
|
||||
pub adaptive_ttl_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for CacheSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_capacity: DEFAULT_CACHE_MAX_CAPACITY,
|
||||
default_ttl: Duration::from_secs(DEFAULT_CACHE_TTL_SECS),
|
||||
max_memory: DEFAULT_CACHE_MAX_MEMORY,
|
||||
adaptive_ttl_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CacheSettings {
|
||||
/// Create new cache settings.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Builder: set max capacity.
|
||||
pub fn with_max_capacity(mut self, capacity: u64) -> Self {
|
||||
self.max_capacity = capacity;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: set TTL.
|
||||
pub fn with_ttl(mut self, ttl: Duration) -> Self {
|
||||
self.default_ttl = ttl;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: set max memory.
|
||||
pub fn with_max_memory(mut self, memory: u64) -> Self {
|
||||
self.max_memory = memory;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// I/O Scheduler Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// I/O scheduler configuration settings.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IoSchedulerSettings {
|
||||
/// Maximum concurrent reads.
|
||||
pub max_concurrent_reads: usize,
|
||||
/// High priority size threshold.
|
||||
pub high_priority_threshold: usize,
|
||||
/// Low priority size threshold.
|
||||
pub low_priority_threshold: usize,
|
||||
/// Base buffer size.
|
||||
pub base_buffer_size: usize,
|
||||
/// Max buffer size.
|
||||
pub max_buffer_size: usize,
|
||||
/// Min buffer size.
|
||||
pub min_buffer_size: usize,
|
||||
/// Whether priority scheduling is enabled.
|
||||
pub priority_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for IoSchedulerSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_concurrent_reads: DEFAULT_MAX_CONCURRENT_READS,
|
||||
high_priority_threshold: DEFAULT_HIGH_PRIORITY_SIZE_THRESHOLD,
|
||||
low_priority_threshold: DEFAULT_LOW_PRIORITY_SIZE_THRESHOLD,
|
||||
base_buffer_size: DEFAULT_BASE_BUFFER_SIZE,
|
||||
max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
|
||||
min_buffer_size: DEFAULT_MIN_BUFFER_SIZE,
|
||||
priority_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IoSchedulerSettings {
|
||||
/// Create new settings.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Builder: set max concurrent reads.
|
||||
pub fn with_max_concurrent_reads(mut self, max: usize) -> Self {
|
||||
self.max_concurrent_reads = max;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: set buffer sizes.
|
||||
pub fn with_buffer_sizes(mut self, base: usize, min: usize, max: usize) -> Self {
|
||||
self.base_buffer_size = base;
|
||||
self.min_buffer_size = min;
|
||||
self.max_buffer_size = max;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Backpressure Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// Backpressure configuration settings.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackpressureSettings {
|
||||
/// Whether backpressure is enabled.
|
||||
pub enabled: bool,
|
||||
/// High watermark (percentage).
|
||||
pub high_watermark: f64,
|
||||
/// Low watermark (percentage).
|
||||
pub low_watermark: f64,
|
||||
/// Cooldown duration.
|
||||
pub cooldown: Duration,
|
||||
}
|
||||
|
||||
impl Default for BackpressureSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
high_watermark: DEFAULT_BACKPRESSURE_HIGH_WATERMARK,
|
||||
low_watermark: DEFAULT_BACKPRESSURE_LOW_WATERMARK,
|
||||
cooldown: Duration::from_millis(100),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BackpressureSettings {
|
||||
/// Create new settings.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Get high watermark threshold for a given max value.
|
||||
pub fn high_threshold(&self, max: usize) -> usize {
|
||||
(max as f64 * self.high_watermark) as usize
|
||||
}
|
||||
|
||||
/// Get low watermark threshold for a given max value.
|
||||
pub fn low_threshold(&self, max: usize) -> usize {
|
||||
(max as f64 * self.low_watermark) as usize
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Timeout Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// Timeout configuration settings.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TimeoutSettings {
|
||||
/// Default operation timeout.
|
||||
pub default_timeout: Duration,
|
||||
/// Maximum retries.
|
||||
pub max_retries: usize,
|
||||
/// Retry backoff factor.
|
||||
pub retry_backoff_factor: f64,
|
||||
/// Lock acquire timeout.
|
||||
pub lock_acquire_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for TimeoutSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_timeout: Duration::from_secs(30),
|
||||
max_retries: 3,
|
||||
retry_backoff_factor: 2.0,
|
||||
lock_acquire_timeout: Duration::from_secs(DEFAULT_LOCK_ACQUIRE_TIMEOUT_SECS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TimeoutSettings {
|
||||
/// Create new settings.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Calculate timeout with backoff for a given retry count.
|
||||
pub fn timeout_with_backoff(&self, retry_count: usize) -> Duration {
|
||||
let multiplier = self.retry_backoff_factor.powi(retry_count as i32);
|
||||
Duration::from_secs_f64(self.default_timeout.as_secs_f64() * multiplier)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Deadlock Detection Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// Deadlock detection configuration settings.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeadlockDetectionSettings {
|
||||
/// Whether detection is enabled.
|
||||
pub enabled: bool,
|
||||
/// Detection interval.
|
||||
pub detection_interval: Duration,
|
||||
/// Maximum lock hold time before warning.
|
||||
pub max_hold_time: Duration,
|
||||
}
|
||||
|
||||
impl Default for DeadlockDetectionSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
detection_interval: Duration::from_secs(DEFAULT_DEADLOCK_DETECTION_INTERVAL_SECS),
|
||||
max_hold_time: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeadlockDetectionSettings {
|
||||
/// Create new settings.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Unified Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// Unified configuration for all I/O operations.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct IoConfig {
|
||||
/// Cache settings.
|
||||
pub cache: CacheSettings,
|
||||
/// I/O scheduler settings.
|
||||
pub scheduler: IoSchedulerSettings,
|
||||
/// Backpressure settings.
|
||||
pub backpressure: BackpressureSettings,
|
||||
/// Timeout settings.
|
||||
pub timeout: TimeoutSettings,
|
||||
/// Deadlock detection settings.
|
||||
pub deadlock_detection: DeadlockDetectionSettings,
|
||||
}
|
||||
|
||||
impl IoConfig {
|
||||
/// Create new unified configuration.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Builder: set cache settings.
|
||||
pub fn with_cache(mut self, cache: CacheSettings) -> Self {
|
||||
self.cache = cache;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: set scheduler settings.
|
||||
pub fn with_scheduler(mut self, scheduler: IoSchedulerSettings) -> Self {
|
||||
self.scheduler = scheduler;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: set backpressure settings.
|
||||
pub fn with_backpressure(mut self, backpressure: BackpressureSettings) -> Self {
|
||||
self.backpressure = backpressure;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: set timeout settings.
|
||||
pub fn with_timeout(mut self, timeout: TimeoutSettings) -> Self {
|
||||
self.timeout = timeout;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cache_settings() {
|
||||
let settings = CacheSettings::new()
|
||||
.with_max_capacity(5000)
|
||||
.with_ttl(Duration::from_secs(600));
|
||||
|
||||
assert_eq!(settings.max_capacity, 5000);
|
||||
assert_eq!(settings.default_ttl, Duration::from_secs(600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_scheduler_settings() {
|
||||
let settings =
|
||||
IoSchedulerSettings::new()
|
||||
.with_max_concurrent_reads(64)
|
||||
.with_buffer_sizes(256 * 1024, 8 * 1024, 2 * 1024 * 1024);
|
||||
|
||||
assert_eq!(settings.max_concurrent_reads, 64);
|
||||
assert_eq!(settings.base_buffer_size, 256 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backpressure_settings() {
|
||||
let settings = BackpressureSettings::new();
|
||||
|
||||
assert_eq!(settings.high_threshold(100), 80);
|
||||
assert_eq!(settings.low_threshold(100), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeout_settings() {
|
||||
let settings = TimeoutSettings::new();
|
||||
|
||||
// First retry: 30s * 2 = 60s
|
||||
let timeout1 = settings.timeout_with_backoff(1);
|
||||
assert!(timeout1.as_secs() >= 60);
|
||||
|
||||
// Second retry: 30s * 4 = 120s
|
||||
let timeout2 = settings.timeout_with_backoff(2);
|
||||
assert!(timeout2.as_secs() >= 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unified_config() {
|
||||
let config = IoConfig::new()
|
||||
.with_cache(CacheSettings::new().with_max_capacity(5000))
|
||||
.with_scheduler(IoSchedulerSettings::new().with_max_concurrent_reads(64));
|
||||
|
||||
assert_eq!(config.cache.max_capacity, 5000);
|
||||
assert_eq!(config.scheduler.max_concurrent_reads, 64);
|
||||
}
|
||||
}
|
||||
@@ -72,48 +72,39 @@ pub fn record_wait_edge_removed() {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Replaces the per-helper smoke tests that called the record_* helpers
|
||||
/// and asserted nothing: the calls (same literals) now run against a local
|
||||
/// DebuggingRecorder and every metric name the helpers own must actually
|
||||
/// be emitted (rustfs/backlog#1836 PR3).
|
||||
#[test]
|
||||
fn record_helpers_emit_their_metrics() {
|
||||
let recorder = metrics_util::debugging::DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
record_deadlock_detected(3);
|
||||
record_deadlock_detected(5);
|
||||
record_long_held_lock(1, Duration::from_secs(30));
|
||||
record_long_held_lock(2, Duration::from_secs(60));
|
||||
record_lock_acquisition("mutex");
|
||||
record_lock_acquisition("rwlock");
|
||||
record_lock_release("mutex", Duration::from_millis(10));
|
||||
record_lock_release("rwlock", Duration::from_millis(5));
|
||||
record_lock_contention("mutex");
|
||||
record_lock_contention("rwlock");
|
||||
record_wait_edge_added();
|
||||
record_wait_edge_removed();
|
||||
});
|
||||
fn test_record_deadlock_detected() {
|
||||
record_deadlock_detected(3);
|
||||
record_deadlock_detected(5);
|
||||
}
|
||||
|
||||
let emitted: std::collections::HashSet<String> = snapshotter
|
||||
.snapshot()
|
||||
.into_vec()
|
||||
.into_iter()
|
||||
.map(|(composite, _, _, _)| composite.key().name().to_string())
|
||||
.collect();
|
||||
for expected in [
|
||||
"rustfs_deadlock_detected_total",
|
||||
"rustfs_deadlock_cycle_length",
|
||||
"rustfs_deadlock_long_held",
|
||||
"rustfs_deadlock_hold_time_secs",
|
||||
"rustfs_lock_acquisitions",
|
||||
"rustfs_lock_releases",
|
||||
"rustfs_lock_hold_time_secs",
|
||||
"rustfs_lock_contentions",
|
||||
"rustfs_deadlock_wait_edges_added",
|
||||
"rustfs_deadlock_wait_edges_removed",
|
||||
] {
|
||||
assert!(emitted.contains(expected), "{expected} must be emitted by its record helper");
|
||||
}
|
||||
#[test]
|
||||
fn test_record_long_held_lock() {
|
||||
record_long_held_lock(1, Duration::from_secs(30));
|
||||
record_long_held_lock(2, Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_lock_acquisition() {
|
||||
record_lock_acquisition("mutex");
|
||||
record_lock_acquisition("rwlock");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_lock_release() {
|
||||
record_lock_release("mutex", Duration::from_millis(10));
|
||||
record_lock_release("rwlock", Duration::from_millis(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_lock_contention() {
|
||||
record_lock_contention("mutex");
|
||||
record_lock_contention("rwlock");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_wait_edge() {
|
||||
record_wait_edge_added();
|
||||
record_wait_edge_removed();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,58 +169,46 @@ impl IoSchedulerStats {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Replaces the per-helper smoke tests that called the record_* helpers
|
||||
/// and asserted nothing: the calls (same literals) now run against a local
|
||||
/// DebuggingRecorder and every metric name the helpers own must actually
|
||||
/// be emitted (rustfs/backlog#1836 PR3).
|
||||
#[test]
|
||||
fn record_helpers_emit_their_metrics() {
|
||||
let recorder = metrics_util::debugging::DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
record_io_scheduler_decision(128 * 1024, "low", "sequential");
|
||||
record_io_scheduler_decision(64 * 1024, "high", "random");
|
||||
record_io_priority_decision("high", 1024);
|
||||
record_io_priority_decision("normal", 1024 * 1024);
|
||||
record_io_priority_decision("low", 10 * 1024 * 1024);
|
||||
record_load_level_change("low", "medium");
|
||||
record_load_level_change("medium", "high");
|
||||
record_bandwidth_observation(100 * 1024 * 1024);
|
||||
record_bandwidth_observation(500 * 1024 * 1024);
|
||||
record_buffer_size_adjustment(128 * 1024, 64 * 1024, "concurrency");
|
||||
record_buffer_size_adjustment(128 * 1024, 256 * 1024, "sequential");
|
||||
record_queue_operation("enqueue", "high", 10);
|
||||
record_queue_operation("dequeue", "high", 9);
|
||||
record_starvation_event("low");
|
||||
});
|
||||
fn test_record_io_scheduler_decision() {
|
||||
record_io_scheduler_decision(128 * 1024, "low", "sequential");
|
||||
record_io_scheduler_decision(64 * 1024, "high", "random");
|
||||
}
|
||||
|
||||
let emitted: std::collections::HashSet<String> = snapshotter
|
||||
.snapshot()
|
||||
.into_vec()
|
||||
.into_iter()
|
||||
.map(|(composite, _, _, _)| composite.key().name().to_string())
|
||||
.collect();
|
||||
for expected in [
|
||||
"rustfs_io_scheduler_decisions",
|
||||
"rustfs_io_scheduler_buffer_size",
|
||||
"rustfs_io_scheduler_load",
|
||||
"rustfs_io_scheduler_strategy",
|
||||
"rustfs_io_scheduler_buffer_size_histogram",
|
||||
"rustfs_io_priority_decisions",
|
||||
"rustfs_io_priority_by_level",
|
||||
"rustfs_io_priority_request_size",
|
||||
"rustfs_io_load_changes",
|
||||
"rustfs_io_bandwidth_bps",
|
||||
"rustfs_io_bandwidth_histogram",
|
||||
"rustfs_io_buffer_adjustments",
|
||||
"rustfs_io_buffer_original",
|
||||
"rustfs_io_buffer_adjusted",
|
||||
"rustfs_io_queue_operations",
|
||||
"rustfs_io_queue_size",
|
||||
"rustfs_io_starvation_events",
|
||||
] {
|
||||
assert!(emitted.contains(expected), "{expected} must be emitted by its record helper");
|
||||
}
|
||||
#[test]
|
||||
fn test_record_io_priority_decision() {
|
||||
record_io_priority_decision("high", 1024);
|
||||
record_io_priority_decision("normal", 1024 * 1024);
|
||||
record_io_priority_decision("low", 10 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_load_level_change() {
|
||||
record_load_level_change("low", "medium");
|
||||
record_load_level_change("medium", "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_bandwidth_observation() {
|
||||
record_bandwidth_observation(100 * 1024 * 1024);
|
||||
record_bandwidth_observation(500 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_buffer_size_adjustment() {
|
||||
record_buffer_size_adjustment(128 * 1024, 64 * 1024, "concurrency");
|
||||
record_buffer_size_adjustment(128 * 1024, 256 * 1024, "sequential");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_queue_operation() {
|
||||
record_queue_operation("enqueue", "high", 10);
|
||||
record_queue_operation("dequeue", "high", 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_starvation_event() {
|
||||
record_starvation_event("low");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -173,6 +173,7 @@ pub mod backpressure_metrics;
|
||||
pub mod cache_config;
|
||||
pub mod capacity_metrics;
|
||||
pub mod collector;
|
||||
pub mod config;
|
||||
pub mod deadlock_metrics;
|
||||
pub mod internode_metrics;
|
||||
pub mod io_metrics;
|
||||
@@ -259,6 +260,13 @@ pub use timeout_metrics::{
|
||||
record_operation_progress, record_stalled_operation, record_timeout_event,
|
||||
};
|
||||
|
||||
// Config exports
|
||||
pub use config::{
|
||||
BackpressureSettings, CacheSettings, DEFAULT_BASE_BUFFER_SIZE, DEFAULT_CACHE_MAX_CAPACITY, DEFAULT_CACHE_MAX_MEMORY,
|
||||
DEFAULT_CACHE_TTL_SECS, DEFAULT_MAX_BUFFER_SIZE, DEFAULT_MAX_CONCURRENT_READS, DEFAULT_MIN_BUFFER_SIZE,
|
||||
DeadlockDetectionSettings, IoConfig, IoSchedulerSettings, TimeoutSettings,
|
||||
};
|
||||
|
||||
// Re-exports for convenience
|
||||
pub use collector::MetricsCollector;
|
||||
pub use performance::PerformanceMetrics;
|
||||
|
||||
@@ -163,46 +163,6 @@ impl LockMetricsSummary {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Replaces the per-helper smoke tests that called the record_* helpers
|
||||
/// and asserted nothing: the calls (same literals) now run against a local
|
||||
/// DebuggingRecorder and every metric name the helpers own must actually
|
||||
/// be emitted (rustfs/backlog#1836 PR3).
|
||||
#[test]
|
||||
fn record_helpers_emit_their_metrics() {
|
||||
let recorder = metrics_util::debugging::DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
record_lock_optimization_enabled(true);
|
||||
record_lock_optimization_enabled(false);
|
||||
record_spin_attempt(true);
|
||||
record_spin_attempt(false);
|
||||
record_spin_count_change(100);
|
||||
record_spin_count_change(200);
|
||||
record_lock_hold_time(Duration::from_millis(10));
|
||||
record_lock_hold_time(Duration::from_millis(100));
|
||||
record_early_release();
|
||||
record_contention_event();
|
||||
});
|
||||
|
||||
let emitted: std::collections::HashSet<String> = snapshotter
|
||||
.snapshot()
|
||||
.into_vec()
|
||||
.into_iter()
|
||||
.map(|(composite, _, _, _)| composite.key().name().to_string())
|
||||
.collect();
|
||||
for expected in [
|
||||
"rustfs_lock_optimization_enabled",
|
||||
"rustfs_lock_spin_successes",
|
||||
"rustfs_lock_spin_failures",
|
||||
"rustfs_lock_spin_count",
|
||||
"rustfs_lock_hold_time_secs",
|
||||
"rustfs_lock_early_releases",
|
||||
"rustfs_lock_contentions",
|
||||
] {
|
||||
assert!(emitted.contains(expected), "{expected} must be emitted by its record helper");
|
||||
}
|
||||
}
|
||||
use metrics::{Counter, CounterFn, Gauge, GaugeFn, Histogram, HistogramFn, Key, KeyName, Metadata, SharedString, Unit};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -295,6 +255,40 @@ mod tests {
|
||||
fn record(&self, _value: f64) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_lock_optimization_enabled() {
|
||||
record_lock_optimization_enabled(true);
|
||||
record_lock_optimization_enabled(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_spin_attempt() {
|
||||
record_spin_attempt(true);
|
||||
record_spin_attempt(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_spin_count_change() {
|
||||
record_spin_count_change(100);
|
||||
record_spin_count_change(200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_lock_hold_time() {
|
||||
record_lock_hold_time(Duration::from_millis(10));
|
||||
record_lock_hold_time(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_early_release() {
|
||||
record_early_release();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_contention_event() {
|
||||
record_contention_event();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_object_lock_diag_enabled() {
|
||||
let recorder = SeenMetricsRecorder::default();
|
||||
|
||||
@@ -114,46 +114,39 @@ impl TimeoutMetricsSummary {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Replaces the per-helper smoke tests that called the record_* helpers
|
||||
/// and asserted nothing: the calls (same literals) now run against a local
|
||||
/// DebuggingRecorder and every metric name the helpers own must actually
|
||||
/// be emitted (rustfs/backlog#1836 PR3).
|
||||
#[test]
|
||||
fn record_helpers_emit_their_metrics() {
|
||||
let recorder = metrics_util::debugging::DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
record_timeout_event("get_object");
|
||||
record_timeout_event("put_object");
|
||||
record_operation_duration("get_object", Duration::from_millis(100));
|
||||
record_operation_duration("put_object", Duration::from_millis(500));
|
||||
record_dynamic_timeout(1024 * 1024, Duration::from_secs(10));
|
||||
record_dynamic_timeout(100 * 1024 * 1024, Duration::from_secs(30));
|
||||
record_operation_progress("get_object", 50.0);
|
||||
record_operation_progress("get_object", 100.0);
|
||||
record_stalled_operation("get_object");
|
||||
record_operation_completion("get_object", true);
|
||||
record_operation_completion("get_object", false);
|
||||
});
|
||||
fn test_record_timeout_event() {
|
||||
record_timeout_event("get_object");
|
||||
record_timeout_event("put_object");
|
||||
}
|
||||
|
||||
let emitted: std::collections::HashSet<String> = snapshotter
|
||||
.snapshot()
|
||||
.into_vec()
|
||||
.into_iter()
|
||||
.map(|(composite, _, _, _)| composite.key().name().to_string())
|
||||
.collect();
|
||||
for expected in [
|
||||
"rustfs_io_timeout_events_total",
|
||||
"rustfs_io_operation_duration_seconds",
|
||||
"rustfs_timeout_dynamic_size",
|
||||
"rustfs_timeout_dynamic_secs",
|
||||
"rustfs_timeout_dynamic_size_histogram",
|
||||
"rustfs_operation_progress",
|
||||
"rustfs_operation_stalled",
|
||||
"rustfs_operation_completions",
|
||||
] {
|
||||
assert!(emitted.contains(expected), "{expected} must be emitted by its record helper");
|
||||
}
|
||||
#[test]
|
||||
fn test_record_operation_duration() {
|
||||
record_operation_duration("get_object", Duration::from_millis(100));
|
||||
record_operation_duration("put_object", Duration::from_millis(500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_dynamic_timeout() {
|
||||
record_dynamic_timeout(1024 * 1024, Duration::from_secs(10));
|
||||
record_dynamic_timeout(100 * 1024 * 1024, Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_operation_progress() {
|
||||
record_operation_progress("get_object", 50.0);
|
||||
record_operation_progress("get_object", 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_stalled_operation() {
|
||||
record_stalled_operation("get_object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_operation_completion() {
|
||||
record_operation_completion("get_object", true);
|
||||
record_operation_completion("get_object", false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -35,10 +35,6 @@ tokio = { workspace = true, features = ["fs", "io-util", "macros", "rt-multi-thr
|
||||
uuid = { workspace = true, features = ["serde", "v4", "fast-rng", "macro-diagnostics"] }
|
||||
jiff = { workspace = true, features = ["serde"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
# Observes fields a persisted-format deserialization ignored, per the
|
||||
# repository rule that formats too compatibility-bound for
|
||||
# deny_unknown_fields must at least warn (AGENTS.md).
|
||||
serde_ignored = { workspace = true }
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -657,7 +657,6 @@ impl KmsBackend for AwsKmsBackend {
|
||||
created_by: None,
|
||||
rotation_due: false,
|
||||
rotation_due_reason: None,
|
||||
wrap_budget_reserved: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -57,17 +57,6 @@ impl ScriptedResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// The 404 Vault answers a LIST of an empty path with: something routed the
|
||||
/// request and found nothing under it, so the `errors` array comes back
|
||||
/// empty. [`ScriptedResponse::error`] cannot stand in — it always fills
|
||||
/// `errors`, which is what marks a 404 as an unrouted path instead.
|
||||
pub(crate) fn empty_list_404() -> Self {
|
||||
Self::Http {
|
||||
status: 404,
|
||||
body: serde_json::json!({ "errors": [] }).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the connection after consuming a request without sending an HTTP response.
|
||||
pub(crate) fn close() -> Self {
|
||||
Self::Close
|
||||
|
||||
@@ -271,7 +271,6 @@ impl StaticKmsBackend {
|
||||
created_by: None,
|
||||
rotation_due: false,
|
||||
rotation_due_reason: None,
|
||||
wrap_budget_reserved: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,6 @@ use crate::backends::{
|
||||
use crate::config::{KmsConfig, VaultTransitConfig};
|
||||
use crate::encryption::{DataKeyEnvelope, generate_key_material};
|
||||
use crate::error::{KmsError, Result};
|
||||
use crate::persisted_observability::{BoundedUnknownFieldName, UnknownFieldSummary};
|
||||
use crate::policy::{self, AttemptError, OpClass, RetryPolicy};
|
||||
use crate::types::*;
|
||||
use async_trait::async_trait;
|
||||
@@ -101,23 +100,6 @@ fn is_cas_conflict(error: &ClientError) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a transit LIST failed with the 404 Vault uses for "mounted, but no
|
||||
/// keys yet".
|
||||
///
|
||||
/// Vault answers a LIST on a mounted transit engine that holds no keys with a
|
||||
/// 404 whose `errors` array is empty — the mount routed and answered the
|
||||
/// request, so the engine is reachable. A 404 for a path with no mount behind
|
||||
/// it instead carries a "no handler for route" message, so the empty `errors`
|
||||
/// array is what separates "engine reachable but empty" from "engine missing".
|
||||
///
|
||||
/// An empty non-transit engine (e.g. KV v1) at the configured path answers
|
||||
/// with byte-identical 404s, so this probe cannot detect that misconfiguration
|
||||
/// — no LIST-based probe can. The data path still fails hard on the first real
|
||||
/// transit operation against such a mount.
|
||||
fn is_empty_transit_list(error: &ClientError) -> bool {
|
||||
matches!(error, ClientError::APIError { code: 404, errors } if errors.is_empty())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TransitKeyMetadata {
|
||||
key_usage: KeyUsage,
|
||||
@@ -132,12 +114,7 @@ struct TransitKeyMetadata {
|
||||
}
|
||||
|
||||
/// Serializable version of TransitKeyMetadata for KV v2 persistence.
|
||||
///
|
||||
/// `Deserialize` is hand-written so fields the current build does not know
|
||||
/// are counted and warned about instead of vanishing silently — this record
|
||||
/// is compatibility-bound in both directions (older and newer builds read
|
||||
/// each other's writes), so `deny_unknown_fields` is not an option.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct TransitKeyMetadataPersisted {
|
||||
key_usage: KeyUsage,
|
||||
description: Option<String>,
|
||||
@@ -150,168 +127,6 @@ struct TransitKeyMetadataPersisted {
|
||||
current_version: u32,
|
||||
}
|
||||
|
||||
impl UnknownFieldSummary {
|
||||
fn record_for_transit_key_metadata(&self) {
|
||||
let Some((field, field_name_truncated, field_count)) = self.record("vault-transit-key-metadata") else {
|
||||
return;
|
||||
};
|
||||
|
||||
static RECORDS_WITH_UNKNOWN_FIELDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let observed_records = RECORDS_WITH_UNKNOWN_FIELDS
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
.saturating_add(1);
|
||||
if observed_records.is_power_of_two() {
|
||||
tracing::warn!(
|
||||
field = ?field,
|
||||
field_name_truncated,
|
||||
field_count,
|
||||
observed_records,
|
||||
"Vault Transit key metadata record contains unknown fields"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for TransitKeyMetadataPersisted {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::{self, IgnoredAny, MapAccess, Visitor};
|
||||
use std::fmt;
|
||||
|
||||
enum Field {
|
||||
KeyUsage,
|
||||
Description,
|
||||
Tags,
|
||||
KeyState,
|
||||
CreatedAt,
|
||||
DeletionDate,
|
||||
Origin,
|
||||
CreatedBy,
|
||||
CurrentVersion,
|
||||
Unknown(BoundedUnknownFieldName),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Field {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct FieldVisitor;
|
||||
|
||||
impl Visitor<'_> for FieldVisitor {
|
||||
type Value = Field;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("a Vault Transit key metadata field name")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
Ok(match value {
|
||||
"key_usage" => Field::KeyUsage,
|
||||
"description" => Field::Description,
|
||||
"tags" => Field::Tags,
|
||||
"key_state" => Field::KeyState,
|
||||
"created_at" => Field::CreatedAt,
|
||||
"deletion_date" => Field::DeletionDate,
|
||||
"origin" => Field::Origin,
|
||||
"created_by" => Field::CreatedBy,
|
||||
"current_version" => Field::CurrentVersion,
|
||||
_ => Field::Unknown(BoundedUnknownFieldName::new(value)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_identifier(FieldVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
struct TransitKeyMetadataPersistedVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for TransitKeyMetadataPersistedVisitor {
|
||||
type Value = TransitKeyMetadataPersisted;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("a Vault Transit key metadata record")
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
|
||||
where
|
||||
A: MapAccess<'de>,
|
||||
{
|
||||
macro_rules! read_field {
|
||||
($slot:ident, $name:literal) => {{
|
||||
if $slot.is_some() {
|
||||
return Err(de::Error::duplicate_field($name));
|
||||
}
|
||||
$slot = Some(map.next_value()?);
|
||||
}};
|
||||
}
|
||||
|
||||
let mut key_usage = None;
|
||||
let mut description = None;
|
||||
let mut tags = None;
|
||||
let mut key_state = None;
|
||||
let mut created_at = None;
|
||||
let mut deletion_date = None;
|
||||
let mut origin = None;
|
||||
let mut created_by = None;
|
||||
let mut current_version = None;
|
||||
let mut unknown_fields = UnknownFieldSummary::default();
|
||||
|
||||
while let Some(field) = map.next_key()? {
|
||||
match field {
|
||||
Field::KeyUsage => read_field!(key_usage, "key_usage"),
|
||||
Field::Description => read_field!(description, "description"),
|
||||
Field::Tags => read_field!(tags, "tags"),
|
||||
Field::KeyState => read_field!(key_state, "key_state"),
|
||||
Field::CreatedAt => read_field!(created_at, "created_at"),
|
||||
Field::DeletionDate => read_field!(deletion_date, "deletion_date"),
|
||||
Field::Origin => read_field!(origin, "origin"),
|
||||
Field::CreatedBy => read_field!(created_by, "created_by"),
|
||||
Field::CurrentVersion => read_field!(current_version, "current_version"),
|
||||
Field::Unknown(field) => {
|
||||
let _: IgnoredAny = map.next_value()?;
|
||||
unknown_fields.observe(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let metadata = TransitKeyMetadataPersisted {
|
||||
key_usage: key_usage.ok_or_else(|| de::Error::missing_field("key_usage"))?,
|
||||
description: description.unwrap_or(None),
|
||||
tags: tags.ok_or_else(|| de::Error::missing_field("tags"))?,
|
||||
key_state: key_state.ok_or_else(|| de::Error::missing_field("key_state"))?,
|
||||
created_at: created_at.ok_or_else(|| de::Error::missing_field("created_at"))?,
|
||||
deletion_date: deletion_date.unwrap_or(None),
|
||||
origin: origin.ok_or_else(|| de::Error::missing_field("origin"))?,
|
||||
created_by: created_by.unwrap_or(None),
|
||||
current_version: current_version.ok_or_else(|| de::Error::missing_field("current_version"))?,
|
||||
};
|
||||
unknown_fields.record_for_transit_key_metadata();
|
||||
Ok(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
const FIELDS: &[&str] = &[
|
||||
"key_usage",
|
||||
"description",
|
||||
"tags",
|
||||
"key_state",
|
||||
"created_at",
|
||||
"deletion_date",
|
||||
"origin",
|
||||
"created_by",
|
||||
"current_version",
|
||||
];
|
||||
deserializer.deserialize_struct("TransitKeyMetadataPersisted", FIELDS, TransitKeyMetadataPersistedVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
impl TransitKeyMetadata {
|
||||
fn from_create_request(request: &CreateKeyRequest) -> Self {
|
||||
Self {
|
||||
@@ -891,7 +706,6 @@ impl VaultTransitKmsClient {
|
||||
created_by: metadata.created_by,
|
||||
rotation_due: false,
|
||||
rotation_due_reason: None,
|
||||
wrap_budget_reserved: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1260,17 +1074,12 @@ impl VaultTransitKmsClient {
|
||||
let mut all_keys = self
|
||||
.run("vault_transit_list_keys", OpClass::ReadIdempotent, move || async move {
|
||||
let vault = self.vault().map_err(AttemptError::fatal)?;
|
||||
match key::list(&vault.client, &self.config.mount_path).await {
|
||||
Ok(response) => Ok(response.keys),
|
||||
// An empty transit engine answers LIST with a bare 404;
|
||||
// that is an empty listing, not a backend failure.
|
||||
Err(error) if is_empty_transit_list(&error) => Ok(Vec::new()),
|
||||
Err(e) => Err(AttemptError::from_vaultrs(e, |e| {
|
||||
KmsError::backend_error(format!("Failed to list Vault Transit keys: {e}"))
|
||||
})),
|
||||
}
|
||||
key::list(&vault.client, &self.config.mount_path).await.map_err(|e| {
|
||||
AttemptError::from_vaultrs(e, |e| KmsError::backend_error(format!("Failed to list Vault Transit keys: {e}")))
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
.await?
|
||||
.keys;
|
||||
// Vault's own LIST ordering is not part of its contract, so the sort is
|
||||
// what makes the marker a stable cursor across calls.
|
||||
all_keys.sort_unstable();
|
||||
@@ -1443,17 +1252,12 @@ impl VaultTransitKmsClient {
|
||||
pub(crate) async fn health_check(&self) -> Result<()> {
|
||||
self.run("vault_transit_health_check", OpClass::ReadIdempotent, move || async move {
|
||||
let vault = self.vault().map_err(AttemptError::fatal)?;
|
||||
match key::list(&vault.client, &self.config.mount_path).await {
|
||||
Ok(_) => Ok(()),
|
||||
// A brand-new transit mount holds no keys until something
|
||||
// creates one, and this check gates startup before the service
|
||||
// creates its own probe key — treating "empty" as unhealthy
|
||||
// would keep a first-ever deployment from ever starting.
|
||||
Err(error) if is_empty_transit_list(&error) => Ok(()),
|
||||
Err(e) => Err(AttemptError::from_vaultrs(e, |e| {
|
||||
KmsError::backend_error(format!("Vault Transit health check failed: {e}"))
|
||||
})),
|
||||
}
|
||||
key::list(&vault.client, &self.config.mount_path)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| {
|
||||
AttemptError::from_vaultrs(e, |e| KmsError::backend_error(format!("Vault Transit health check failed: {e}")))
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -2112,107 +1916,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test for the first-boot chicken-and-egg on a fresh transit
|
||||
/// mount (rustfs/backlog#1774).
|
||||
///
|
||||
/// Vault answers a LIST on a mounted-but-empty transit engine with a 404
|
||||
/// carrying an empty `errors` array. The health check gates startup before
|
||||
/// the service creates its probe key, so this 404 must count as healthy —
|
||||
/// failing it means a first-ever deployment on a fresh mount can never
|
||||
/// start until an operator creates some transit key out-of-band.
|
||||
#[tokio::test]
|
||||
async fn health_check_passes_on_an_empty_transit_engine() {
|
||||
let (vault, client) = scripted_client(vec![ScriptedResponse::Http {
|
||||
status: 404,
|
||||
body: serde_json::json!({ "errors": [] }).to_string(),
|
||||
}])
|
||||
.await;
|
||||
|
||||
client
|
||||
.health_check()
|
||||
.await
|
||||
.expect("an empty transit engine is reachable and must pass the health check");
|
||||
|
||||
let requests = vault.requests();
|
||||
assert_eq!(
|
||||
requests,
|
||||
vec!["LIST /v1/transit/keys".to_string()],
|
||||
"the empty-list 404 must be accepted on the first attempt, not retried"
|
||||
);
|
||||
}
|
||||
|
||||
/// A 404 whose body says "no handler for route" means no transit engine is
|
||||
/// mounted at the configured path at all; that must keep failing the
|
||||
/// health check instead of riding the empty-engine allowance.
|
||||
#[tokio::test]
|
||||
async fn health_check_fails_when_the_transit_mount_is_missing() {
|
||||
let (_vault, client) = scripted_client(vec![ScriptedResponse::error(
|
||||
404,
|
||||
"no handler for route \"transit/keys\". route entry not found.",
|
||||
)])
|
||||
.await;
|
||||
|
||||
let error = client
|
||||
.health_check()
|
||||
.await
|
||||
.expect_err("a missing transit mount must fail the health check");
|
||||
assert!(matches!(error, KmsError::BackendError { .. }), "got {error:?}");
|
||||
}
|
||||
|
||||
/// The empty-engine allowance is scoped to 404 alone: any other status
|
||||
/// whose body happens to carry an empty `errors` array (an intermediary
|
||||
/// answering for Vault, for instance) must keep failing the health check.
|
||||
#[tokio::test]
|
||||
async fn health_check_fails_on_a_non_404_error_with_an_empty_errors_body() {
|
||||
let (_vault, client) = scripted_client(vec![ScriptedResponse::Http {
|
||||
status: 403,
|
||||
body: serde_json::json!({ "errors": [] }).to_string(),
|
||||
}])
|
||||
.await;
|
||||
|
||||
let error = client
|
||||
.health_check()
|
||||
.await
|
||||
.expect_err("only a 404 may ride the empty-engine allowance");
|
||||
assert!(matches!(error, KmsError::BackendError { .. }), "got {error:?}");
|
||||
}
|
||||
|
||||
/// The listing's own copy of the discriminator must not widen into "every
|
||||
/// LIST failure is an empty listing" — a missing mount still fails loudly.
|
||||
#[tokio::test]
|
||||
async fn list_fails_when_the_transit_mount_is_missing() {
|
||||
let (_vault, client) = scripted_client(vec![ScriptedResponse::error(
|
||||
404,
|
||||
"no handler for route \"transit/keys\". route entry not found.",
|
||||
)])
|
||||
.await;
|
||||
|
||||
let error = client
|
||||
.list_keys(&ListKeysRequest::default(), None)
|
||||
.await
|
||||
.expect_err("a missing transit mount must fail the listing, not empty it");
|
||||
assert!(matches!(error, KmsError::BackendError { .. }), "got {error:?}");
|
||||
}
|
||||
|
||||
/// The same empty-engine 404 on the listing path is an empty result set,
|
||||
/// not a backend failure.
|
||||
#[tokio::test]
|
||||
async fn list_keys_returns_an_empty_page_on_an_empty_transit_engine() {
|
||||
let (_vault, client) = scripted_client(vec![ScriptedResponse::Http {
|
||||
status: 404,
|
||||
body: serde_json::json!({ "errors": [] }).to_string(),
|
||||
}])
|
||||
.await;
|
||||
|
||||
let response = client
|
||||
.list_keys(&ListKeysRequest::default(), None)
|
||||
.await
|
||||
.expect("an empty transit engine must list as empty, not fail");
|
||||
assert!(response.keys.is_empty(), "got {:?}", response.keys);
|
||||
assert!(!response.truncated, "an empty listing has nothing left to page through");
|
||||
assert_eq!(response.next_marker, None);
|
||||
}
|
||||
|
||||
fn test_vault_transit_config() -> VaultTransitConfig {
|
||||
VaultTransitConfig {
|
||||
address: "http://127.0.0.1:8200".to_string(),
|
||||
@@ -2430,41 +2133,6 @@ mod tests {
|
||||
assert!(metadata.deletion_date.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transit_key_metadata_unknown_fields_remain_readable_and_are_observed() {
|
||||
// A record written by a newer build carries fields this build does not
|
||||
// know. It must stay readable — and the drop must be visible, not
|
||||
// silent (rustfs/backlog#1641). Only the field name may be logged.
|
||||
let persisted: TransitKeyMetadataPersisted = TransitKeyMetadata::synthesized().into();
|
||||
let mut value = serde_json::to_value(&persisted).expect("serialize metadata record");
|
||||
let object = value.as_object_mut().expect("metadata record serializes to an object");
|
||||
object.insert("field_from_the_future".to_string(), serde_json::json!("field value must not be logged"));
|
||||
|
||||
let logs = crate::test_support::CapturedLogs::default();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_ansi(false)
|
||||
.with_max_level(tracing::Level::WARN)
|
||||
.with_writer(logs.clone())
|
||||
.finish();
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let recorder = metrics_util::debugging::DebuggingRecorder::new();
|
||||
let parsed: TransitKeyMetadataPersisted = metrics::with_local_recorder(&recorder, || {
|
||||
tracing::dispatcher::with_default(&dispatch, || {
|
||||
serde_json::from_value(value).expect("unknown fields must remain readable")
|
||||
})
|
||||
});
|
||||
assert_eq!(parsed.key_state, KeyState::Enabled);
|
||||
assert_eq!(crate::test_support::unknown_field_metric(&recorder, "vault-transit-key-metadata"), 1);
|
||||
|
||||
let output = logs.output();
|
||||
assert!(
|
||||
output.contains("Vault Transit key metadata record contains unknown fields"),
|
||||
"got: {output}"
|
||||
);
|
||||
assert!(output.contains("field_from_the_future"));
|
||||
assert!(!output.contains("field value must not be logged"));
|
||||
}
|
||||
|
||||
/// KV2 write acknowledgement (`SecretVersionMetadata`) for `kv2::set`.
|
||||
fn kv2_write_ack() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
|
||||
@@ -868,14 +868,6 @@ impl KmsConfig {
|
||||
// `mount_path` is deprecated and unused by this backend, so an empty value
|
||||
// is deliberately not an error.
|
||||
|
||||
// `kv_mount` is: it is the mount every read, write and listing is
|
||||
// routed through, and an empty one produces a path Vault has no
|
||||
// handler for. Rejecting it here names the setting; letting it
|
||||
// through spends a round-trip to report an unroutable path.
|
||||
if config.kv_mount.is_empty() {
|
||||
return Err(KmsError::configuration_error("Vault KV2 mount cannot be empty"));
|
||||
}
|
||||
|
||||
// Validate TLS configuration if using HTTPS
|
||||
if config.address.starts_with("https://")
|
||||
&& let Some(ref tls) = config.tls
|
||||
@@ -1137,53 +1129,6 @@ pub fn allow_immediate_deletion_from_env() -> bool {
|
||||
get_env_bool(ENV_KMS_ALLOW_IMMEDIATE_DELETION, false)
|
||||
}
|
||||
|
||||
impl crate::persisted_observability::UnknownFieldSummary {
|
||||
fn record_for_kms_config(&self) {
|
||||
let Some((field, field_name_truncated, field_count)) = self.record("kms-config") else {
|
||||
return;
|
||||
};
|
||||
|
||||
static RECORDS_WITH_UNKNOWN_FIELDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let observed_records = RECORDS_WITH_UNKNOWN_FIELDS
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
.saturating_add(1);
|
||||
if observed_records.is_power_of_two() {
|
||||
tracing::warn!(
|
||||
field = ?field,
|
||||
field_name_truncated,
|
||||
field_count,
|
||||
observed_records,
|
||||
"persisted KMS configuration contains unknown fields"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize a persisted KMS configuration, observing ignored fields.
|
||||
///
|
||||
/// The persisted configuration deliberately tolerates unknown fields — a
|
||||
/// rolling upgrade writes fields the previous build does not know, and
|
||||
/// rejecting them would turn every upgrade into a hard stop (see the
|
||||
/// regression test pinning that tolerance). Tolerated must not mean
|
||||
/// invisible: this loader wraps the deserializer with `serde_ignored`, so
|
||||
/// every field the configuration silently dropped is counted and sampled
|
||||
/// into a warning, per the repository rule that formats too
|
||||
/// compatibility-bound for `deny_unknown_fields` must at least log unknown
|
||||
/// fields. Only field paths are recorded, never values — a mistyped field
|
||||
/// name can sit next to a secret.
|
||||
pub fn kms_config_from_persisted_json(data: &[u8]) -> serde_json::Result<KmsConfig> {
|
||||
use crate::persisted_observability::{BoundedUnknownFieldName, UnknownFieldSummary};
|
||||
|
||||
let mut deserializer = serde_json::Deserializer::from_slice(data);
|
||||
let mut unknown_fields = UnknownFieldSummary::default();
|
||||
let config: KmsConfig = serde_ignored::deserialize(&mut deserializer, |path| {
|
||||
unknown_fields.observe(BoundedUnknownFieldName::new(&path.to_string()));
|
||||
})?;
|
||||
deserializer.end()?;
|
||||
unknown_fields.record_for_kms_config();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn vault_tls_config(skip_tls_verify: bool) -> Option<TlsConfig> {
|
||||
skip_tls_verify.then_some(TlsConfig {
|
||||
ca_cert_path: None,
|
||||
@@ -1975,34 +1920,6 @@ mod tests {
|
||||
.expect("well-formed token file auth must validate");
|
||||
}
|
||||
|
||||
/// Every KV2 read, write and listing is routed through `kv_mount`, so an
|
||||
/// empty one names a path no Vault engine answers. The Transit backend
|
||||
/// already rejects its own empty mounts; this closes the same gap on the
|
||||
/// setting whose absence otherwise surfaces as an unroutable-path failure at
|
||||
/// the first Vault call.
|
||||
#[test]
|
||||
fn test_validate_rejects_an_empty_kv2_mount() {
|
||||
let kv2_config = |kv_mount: &str| KmsConfig {
|
||||
backend: KmsBackend::VaultKv2,
|
||||
backend_config: BackendConfig::VaultKv2(Box::new(VaultConfig {
|
||||
address: "https://vault.example.com:8200".to_string(),
|
||||
auth_method: VaultAuthMethod::Token {
|
||||
token: "a-real-token".to_string(),
|
||||
},
|
||||
kv_mount: kv_mount.to_string(),
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let error = kv2_config("")
|
||||
.validate()
|
||||
.expect_err("an empty KV2 mount must be rejected as a configuration error");
|
||||
assert!(error.to_string().contains("mount"), "got {error}");
|
||||
|
||||
kv2_config("secret").validate().expect("a named KV2 mount must validate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approle_config_deserializes_legacy_shape_with_defaults() {
|
||||
// Persisted configurations from before the AppRole implementation only
|
||||
@@ -2062,58 +1979,6 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_config_unknown_fields_remain_readable_and_are_observed() {
|
||||
// Unknown fields in a persisted config are deliberately tolerated (a
|
||||
// rolling upgrade writes fields the previous build does not know), but
|
||||
// tolerated must not mean invisible (rustfs/backlog#1641): the
|
||||
// observing loader counts and warns, naming only the field path —
|
||||
// never the value, which can sit next to a secret. Coverage includes a
|
||||
// field nested inside the backend variant, which the externally tagged
|
||||
// enum exposes to the observer.
|
||||
let mut value = serde_json::to_value(KmsConfig::default()).expect("serialize config");
|
||||
value.as_object_mut().expect("config serializes to an object").insert(
|
||||
"top_level_field_from_the_future".to_string(),
|
||||
serde_json::json!("top-level value must not be logged"),
|
||||
);
|
||||
value
|
||||
.pointer_mut("/backend_config/Local")
|
||||
.expect("default config has a Local backend section")
|
||||
.as_object_mut()
|
||||
.expect("Local backend section is an object")
|
||||
.insert(
|
||||
"nested_field_from_the_future".to_string(),
|
||||
serde_json::json!("nested value must not be logged"),
|
||||
);
|
||||
let data = serde_json::to_vec(&value).expect("encode config");
|
||||
|
||||
let logs = crate::test_support::CapturedLogs::default();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_ansi(false)
|
||||
.with_max_level(tracing::Level::WARN)
|
||||
.with_writer(logs.clone())
|
||||
.finish();
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let recorder = metrics_util::debugging::DebuggingRecorder::new();
|
||||
let config = metrics::with_local_recorder(&recorder, || {
|
||||
tracing::dispatcher::with_default(&dispatch, || {
|
||||
kms_config_from_persisted_json(&data).expect("unknown fields must remain readable")
|
||||
})
|
||||
});
|
||||
assert!(matches!(config.backend_config, BackendConfig::Local(_)));
|
||||
assert_eq!(crate::test_support::unknown_field_metric(&recorder, "kms-config"), 2);
|
||||
|
||||
let output = logs.output();
|
||||
assert!(output.contains("persisted KMS configuration contains unknown fields"), "got: {output}");
|
||||
assert!(!output.contains("must not be logged"));
|
||||
|
||||
// A clean config observes nothing and logs nothing.
|
||||
let clean = serde_json::to_vec(&KmsConfig::default()).expect("encode clean config");
|
||||
let recorder = metrics_util::debugging::DebuggingRecorder::new();
|
||||
metrics::with_local_recorder(&recorder, || kms_config_from_persisted_json(&clean).expect("clean config must parse"));
|
||||
assert_eq!(crate::test_support::unknown_field_metric(&recorder, "kms-config"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rejects_incomplete_approle() {
|
||||
let mut config = KmsConfig::vault_approle(
|
||||
|
||||
@@ -62,12 +62,6 @@ const METRIC_TOMBSTONE_KEYS: &str = "rustfs_kms_deletion_tombstone_keys";
|
||||
/// Gauge: seconds since the least recently rotated usable key was rotated
|
||||
/// (its creation time when it was never rotated); `0` when there are none.
|
||||
const METRIC_OLDEST_ROTATION_AGE_SECONDS: &str = "rustfs_kms_oldest_key_rotation_age_seconds";
|
||||
/// Gauge: the largest persisted wrap-operation reservation across usable keys,
|
||||
/// as of the end of the last sweep that saw the whole key set. Published only
|
||||
/// when the backend counts wraps (the Vault KV2 backend today); an aggregate
|
||||
/// that by design overestimates actual wraps. The value to alert on against
|
||||
/// the AES-256-GCM bound of 2^32 wraps per key material.
|
||||
const METRIC_MAX_KEY_WRAP_OPERATIONS: &str = "rustfs_kms_max_key_wrap_operations";
|
||||
/// Counter: keys the sweep acted on, by `outcome` (`removed`, `blocked`,
|
||||
/// `skipped`, `failed`, `unreadable`).
|
||||
const METRIC_SWEEP_KEYS_TOTAL: &str = "rustfs_kms_deletion_sweep_keys_total";
|
||||
@@ -88,10 +82,6 @@ fn describe_metrics() {
|
||||
METRIC_OLDEST_ROTATION_AGE_SECONDS,
|
||||
"Seconds since the least recently rotated usable KMS key was last rotated, counting from creation for keys that were never rotated"
|
||||
);
|
||||
metrics::describe_gauge!(
|
||||
METRIC_MAX_KEY_WRAP_OPERATIONS,
|
||||
"Largest reserved wrap-operation count across usable KMS keys; overestimates actual wraps and is only reported by backends that count them"
|
||||
);
|
||||
metrics::describe_counter!(METRIC_SWEEP_KEYS_TOTAL, "Total keys acted on by the KMS deletion sweep, by outcome");
|
||||
});
|
||||
}
|
||||
@@ -109,11 +99,6 @@ struct KeyCensus {
|
||||
/// way out are excluded: they will never be rotated again, and would
|
||||
/// otherwise pin the gauge high until the sweep finishes removing them.
|
||||
oldest_rotation_age_seconds: f64,
|
||||
/// Largest reserved wrap count across usable keys, `None` when no key
|
||||
/// reported one — either the backend does not count wraps, or no usable
|
||||
/// key was seen. Excluding departing keys mirrors the rotation age: their
|
||||
/// material will never wrap again, so its consumed nonce budget is moot.
|
||||
max_wrap_operations: Option<u64>,
|
||||
}
|
||||
|
||||
impl KeyCensus {
|
||||
@@ -122,9 +107,6 @@ impl KeyCensus {
|
||||
KeyStatus::PendingDeletion => self.pending_deletion += 1,
|
||||
KeyStatus::Deleted => self.tombstones += 1,
|
||||
KeyStatus::Active | KeyStatus::Disabled => {
|
||||
if let Some(reserved) = key.wrap_budget_reserved {
|
||||
self.max_wrap_operations = Some(self.max_wrap_operations.unwrap_or(0).max(reserved));
|
||||
}
|
||||
// A missing rotation time means either "never rotated" or "the
|
||||
// build that rotated it did not record when". Both fall back to
|
||||
// creation, and the two are not worth separate series: for the
|
||||
@@ -172,11 +154,6 @@ fn record_sweep(report: &SweepReport, census: Option<KeyCensus>) {
|
||||
metrics::gauge!(METRIC_PENDING_DELETION_KEYS).set(census.pending_deletion as f64);
|
||||
metrics::gauge!(METRIC_TOMBSTONE_KEYS).set(census.tombstones as f64);
|
||||
metrics::gauge!(METRIC_OLDEST_ROTATION_AGE_SECONDS).set(census.oldest_rotation_age_seconds);
|
||||
// Only emitted when a usable key reported a count: backends that do not
|
||||
// count wraps must not publish a `0` that reads as "no wraps consumed".
|
||||
if let Some(max_wrap_operations) = census.max_wrap_operations {
|
||||
metrics::gauge!(METRIC_MAX_KEY_WRAP_OPERATIONS).set(max_wrap_operations as f64);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports configuration that still references a KMS key.
|
||||
@@ -795,7 +772,6 @@ mod tests {
|
||||
created_by: None,
|
||||
rotation_due: false,
|
||||
rotation_due_reason: None,
|
||||
wrap_budget_reserved: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -827,63 +803,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The wrap census is the max over usable keys that report a counter.
|
||||
/// Keys without one (backends that do not count wraps) leave it `None`
|
||||
/// rather than dragging in a zero, and departing keys are excluded — their
|
||||
/// material never wraps again, so its consumed nonce budget is moot.
|
||||
#[test]
|
||||
fn census_takes_the_max_wrap_reservation_of_usable_keys_only() {
|
||||
let now = Zoned::now();
|
||||
let mut census = KeyCensus::default();
|
||||
|
||||
census.observe(&key_info("uncounted", KeyStatus::Active, now.clone(), None), &now);
|
||||
assert_eq!(census.max_wrap_operations, None, "a key without a counter must not report zero");
|
||||
|
||||
let mut low = key_info("low", KeyStatus::Active, now.clone(), None);
|
||||
low.wrap_budget_reserved = Some(1_000_000);
|
||||
let mut high = key_info("high", KeyStatus::Disabled, now.clone(), None);
|
||||
high.wrap_budget_reserved = Some(3_000_000);
|
||||
let mut departing = key_info("departing", KeyStatus::PendingDeletion, now.clone(), None);
|
||||
departing.wrap_budget_reserved = Some(9_000_000);
|
||||
census.observe(&low, &now);
|
||||
census.observe(&high, &now);
|
||||
census.observe(&departing, &now);
|
||||
|
||||
assert_eq!(census.max_wrap_operations, Some(3_000_000));
|
||||
}
|
||||
|
||||
/// The wrap gauge is a single aggregate: one value, no labels at all — a
|
||||
/// per-key label would carry key identifiers into the metric stream and
|
||||
/// grow the series count with the key set.
|
||||
#[test]
|
||||
fn wrap_budget_gauge_is_aggregate_and_carries_no_key_label() {
|
||||
let (snapshot, ()) = record_metrics(|| {
|
||||
Box::pin(async {
|
||||
let now = Zoned::now();
|
||||
let mut census = KeyCensus::default();
|
||||
let mut wrapped = key_info("wrapped-key-id", KeyStatus::Active, now.clone(), None);
|
||||
wrapped.wrap_budget_reserved = Some(2_000_000);
|
||||
census.observe(&wrapped, &now);
|
||||
record_sweep(&SweepReport::default(), Some(census));
|
||||
})
|
||||
});
|
||||
|
||||
assert_eq!(gauge_value(&snapshot, METRIC_MAX_KEY_WRAP_OPERATIONS), Some(2_000_000.0));
|
||||
for (composite, ..) in &snapshot {
|
||||
if composite.key().name() == METRIC_MAX_KEY_WRAP_OPERATIONS {
|
||||
assert_eq!(composite.key().labels().count(), 0, "the wrap gauge must stay label-less");
|
||||
}
|
||||
for label in composite.key().labels() {
|
||||
assert!(
|
||||
!label.value().contains("wrapped-key-id"),
|
||||
"metric {} leaked a key identifier through label {}",
|
||||
composite.key().name(),
|
||||
label.key()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_publishes_lifecycle_gauges_without_key_labels() {
|
||||
let (snapshot, key_ids) = record_metrics(|| {
|
||||
@@ -916,11 +835,6 @@ mod tests {
|
||||
);
|
||||
assert_eq!(counter_value(&snapshot, METRIC_SWEEP_KEYS_TOTAL, "skipped"), 1);
|
||||
assert_eq!(counter_value(&snapshot, METRIC_SWEEP_KEYS_TOTAL, "removed"), 0);
|
||||
assert_eq!(
|
||||
gauge_value(&snapshot, METRIC_MAX_KEY_WRAP_OPERATIONS),
|
||||
None,
|
||||
"a backend that does not count wraps must not publish a wrap gauge that reads as zero consumption"
|
||||
);
|
||||
|
||||
for (composite, ..) in &snapshot {
|
||||
for label in composite.key().labels() {
|
||||
|
||||
@@ -1707,7 +1707,6 @@ mod tests {
|
||||
created_by: None,
|
||||
rotation_due: false,
|
||||
rotation_due_reason: None,
|
||||
wrap_budget_reserved: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -259,14 +259,6 @@ pub struct KeyInfo {
|
||||
/// verdict to explain.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rotation_due_reason: Option<RotationDueReason>,
|
||||
/// Wrap operations reserved against the key's current material, reported
|
||||
/// only by backends that count wraps (the Vault KV2 backend today). An
|
||||
/// approximate value that by design overestimates the wraps actually
|
||||
/// performed. In-process transport for the deletion worker's aggregate
|
||||
/// wrap gauge, deliberately kept off the serialized admin surface: per-key
|
||||
/// exposure would need its own contract decision and snapshot pin.
|
||||
#[serde(skip)]
|
||||
pub wrap_budget_reserved: Option<u64>,
|
||||
}
|
||||
|
||||
impl From<MasterKeyInfo> for KeyInfo {
|
||||
@@ -285,7 +277,6 @@ impl From<MasterKeyInfo> for KeyInfo {
|
||||
created_by: master_key.created_by,
|
||||
rotation_due: false,
|
||||
rotation_due_reason: None,
|
||||
wrap_budget_reserved: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,15 +53,6 @@ impl<'a> LazyBuf<'a> {
|
||||
}
|
||||
|
||||
/// copy from golang(path.Clean)
|
||||
///
|
||||
/// DELIBERATE DUPLICATION — do not replace with `rustfs_utils::path::clean`.
|
||||
/// This is a faithful port of Go's slash-only `path.Clean`, which is what S3
|
||||
/// ARN/resource matching requires: policy resource paths are opaque S3 keys,
|
||||
/// and a backslash in a key is object-name data, never a separator. The utils
|
||||
/// version is Windows-aware (`filepath.Clean` semantics: converts backslashes
|
||||
/// to forward slashes), so swapping it in would change policy evaluation on
|
||||
/// Windows — a security-adjacent behavior change. Mirror note sits on the
|
||||
/// utils implementation (backlog#1833).
|
||||
pub fn clean(path: &str) -> String {
|
||||
if path.is_empty() {
|
||||
return ".".into();
|
||||
|
||||
@@ -30,15 +30,6 @@ pub const RUSTFS_MULTIPART_CHECKSUM: &str = "x-rustfs-multipart-checksum";
|
||||
pub const RUSTFS_MULTIPART_CHECKSUM_TYPE: &str = "x-rustfs-multipart-checksum-type";
|
||||
|
||||
/// Checksum type enumeration with flags
|
||||
///
|
||||
/// One of three deliberately separate checksum registries (backlog#1833):
|
||||
/// this bitset owns the **on-disk xl.meta encoding** — the raw `u32` is
|
||||
/// varint-serialized into xl.meta (see `append_to`), so bits are append-only
|
||||
/// and must never be renumbered. `rustfs_checksums::ChecksumAlgorithm`
|
||||
/// (crates/checksums/src/lib.rs) owns the streaming-hash algorithm registry,
|
||||
/// and the MinIO-port client keeps its own `ChecksumMode`
|
||||
/// (crates/ecstore/src/client/checksum.rs). When adding an algorithm, extend
|
||||
/// all three (or record why not) — they do not derive from each other.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct ChecksumType(pub u32);
|
||||
|
||||
|
||||
@@ -292,7 +292,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a live RustFS store with a pre-seeded test object (bucket 'dandan')"]
|
||||
#[ignore]
|
||||
async fn test_simple_sql() {
|
||||
let sql = "select * from S3Object";
|
||||
let input = SelectObjectContentInput {
|
||||
@@ -354,7 +354,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a live RustFS store with a pre-seeded test object (bucket 'dandan')"]
|
||||
#[ignore]
|
||||
async fn test_func_sql() {
|
||||
let sql = "SELECT * FROM S3Object s";
|
||||
let input = SelectObjectContentInput {
|
||||
|
||||
@@ -760,7 +760,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a live NATS JetStream server (docker run nats:2 -js; RUSTFS_TEST_NATS_URL overrides)"]
|
||||
#[ignore]
|
||||
async fn tls_change_rebuilds_the_context_and_drains_the_old_acker() {
|
||||
// A TLS fingerprint change on the publish path rebuilds the cached context from the new client and drains the old acker.
|
||||
let subject = format!("rustfs.tlsrebuild.{}", Uuid::new_v4().simple());
|
||||
@@ -839,7 +839,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a live NATS JetStream server (docker run nats:2 -js; RUSTFS_TEST_NATS_URL overrides)"]
|
||||
#[ignore]
|
||||
async fn tls_change_after_a_failed_reconnect_still_rebuilds_the_context() {
|
||||
// A rotation detected while the broker is unreachable does not orphan the cached context: a failed reconnect followed by a successful one ends bound to the rebuilt context.
|
||||
let subject = format!("rustfs.tlsfail.{}", Uuid::new_v4().simple());
|
||||
@@ -917,7 +917,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a live NATS JetStream server (docker run nats:2 -js; RUSTFS_TEST_NATS_URL overrides)"]
|
||||
#[ignore]
|
||||
async fn publish_gate_rejects_an_unsafe_stream_and_heals_after_the_stream_is_fixed() {
|
||||
// The gate rejects every publish while the stream's duplicate window is below the retry lifetime, and starts publishing once the operator widens it, without a restart.
|
||||
let subject = format!("rustfs.gate.{}", Uuid::new_v4().simple());
|
||||
@@ -975,7 +975,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a live NATS JetStream server (docker run nats:2 -js; RUSTFS_TEST_NATS_URL overrides)"]
|
||||
#[ignore]
|
||||
async fn a_remapped_subject_is_rejected_by_the_ack_stream_check_and_the_entry_stays_queued() {
|
||||
// After a subject remap the takeover stream acknowledges, so the ack-stream check rejects it with the mismatch detail, keeps the entry queued, and resets the verdict for re-validation.
|
||||
let subject = format!("rustfs.remap.{}", Uuid::new_v4().simple());
|
||||
|
||||
@@ -107,7 +107,7 @@ async fn drop_table(dsn: &str, table: &str) {
|
||||
.await;
|
||||
}
|
||||
|
||||
#[ignore = "requires a live MySQL 8.0+/TiDB instance (see module docs for the container command)"]
|
||||
#[ignore]
|
||||
#[tokio::test]
|
||||
async fn direct_write_and_read() {
|
||||
let dsn = test_dsn();
|
||||
@@ -131,7 +131,7 @@ async fn direct_write_and_read() {
|
||||
drop_table(&dsn, &table).await;
|
||||
}
|
||||
|
||||
#[ignore = "requires a live MySQL 8.0+/TiDB instance (see module docs for the container command)"]
|
||||
#[ignore]
|
||||
#[tokio::test]
|
||||
async fn delete_appends_row_does_not_remove_old() {
|
||||
let dsn = test_dsn();
|
||||
@@ -155,7 +155,7 @@ async fn delete_appends_row_does_not_remove_old() {
|
||||
drop_table(&dsn, &table).await;
|
||||
}
|
||||
|
||||
#[ignore = "requires a live MySQL 8.0+/TiDB instance (see module docs for the container command)"]
|
||||
#[ignore]
|
||||
#[tokio::test]
|
||||
async fn queue_store_saves_entry_and_replays() {
|
||||
let dsn = test_dsn();
|
||||
@@ -195,7 +195,7 @@ async fn queue_store_saves_entry_and_replays() {
|
||||
drop_table(&dsn, &table).await;
|
||||
}
|
||||
|
||||
#[ignore = "requires a live MySQL 8.0+/TiDB instance (see module docs for the container command)"]
|
||||
#[ignore]
|
||||
#[tokio::test]
|
||||
async fn duplicate_replay_produces_duplicate_rows() {
|
||||
let dsn = test_dsn();
|
||||
@@ -236,7 +236,7 @@ async fn duplicate_replay_produces_duplicate_rows() {
|
||||
drop_table(&dsn, &table).await;
|
||||
}
|
||||
|
||||
#[ignore = "requires a live MySQL 8.0+/TiDB instance (see module docs for the container command)"]
|
||||
#[ignore]
|
||||
#[tokio::test]
|
||||
async fn incompatible_schema_init_fails() {
|
||||
let dsn = test_dsn();
|
||||
@@ -267,7 +267,7 @@ async fn incompatible_schema_init_fails() {
|
||||
drop_table(&dsn, &table).await;
|
||||
}
|
||||
|
||||
#[ignore = "requires a live MySQL 8.0+/TiDB instance (see module docs for the container command)"]
|
||||
#[ignore]
|
||||
#[tokio::test]
|
||||
async fn check_mysql_server_available_succeeds_against_existing_table() {
|
||||
let dsn = test_dsn();
|
||||
|
||||
@@ -181,7 +181,7 @@ fn jetstream_args(subject: &str, stream_name: &str, queue_dir: &str) -> NATSArgs
|
||||
///
|
||||
/// Ignored by default because it needs a running NATS server with JetStream.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a live NATS JetStream server (see module docs; RUSTFS_TEST_NATS_URL overrides)"]
|
||||
#[ignore]
|
||||
async fn end_to_end_publish_is_acked_on_the_stream() {
|
||||
use rustfs_targets::EventName;
|
||||
use rustfs_targets::Target;
|
||||
|
||||
@@ -82,7 +82,7 @@ async fn remove_stream(stream_name: &str) {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a live NATS JetStream server (see module docs; RUSTFS_TEST_NATS_URL overrides)"]
|
||||
#[ignore]
|
||||
async fn missing_stream_fails_the_health_check() {
|
||||
let stream_name = format!("RUSTFS_TEST_{}", Uuid::new_v4().simple());
|
||||
let args = jetstream_args("rustfs.events", &stream_name);
|
||||
@@ -94,7 +94,7 @@ async fn missing_stream_fails_the_health_check() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a live NATS JetStream server (see module docs; RUSTFS_TEST_NATS_URL overrides)"]
|
||||
#[ignore]
|
||||
async fn valid_stream_passes_the_health_check() {
|
||||
let stream_name = format!("RUSTFS_TEST_{}", Uuid::new_v4().simple());
|
||||
let subject = "rustfs.events";
|
||||
@@ -106,7 +106,7 @@ async fn valid_stream_passes_the_health_check() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a live NATS JetStream server (see module docs; RUSTFS_TEST_NATS_URL overrides)"]
|
||||
#[ignore]
|
||||
async fn stream_not_capturing_the_subject_fails_the_health_check() {
|
||||
let stream_name = format!("RUSTFS_TEST_{}", Uuid::new_v4().simple());
|
||||
// The stream binds a different subject than the target publishes to.
|
||||
@@ -118,7 +118,7 @@ async fn stream_not_capturing_the_subject_fails_the_health_check() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a live NATS JetStream server (see module docs; RUSTFS_TEST_NATS_URL overrides)"]
|
||||
#[ignore]
|
||||
async fn too_small_duplicate_window_fails_the_health_check() {
|
||||
let stream_name = format!("RUSTFS_TEST_{}", Uuid::new_v4().simple());
|
||||
let subject = "rustfs.events";
|
||||
|
||||
@@ -156,11 +156,7 @@ pub const REQUEST_ID_HEADER: &str = "x-request-id";
|
||||
pub const AMZ_REQUEST_ID: &str = "x-amz-request-id";
|
||||
pub const AMZ_REQUEST_HOST_ID: &str = "x-amz-id-2";
|
||||
|
||||
// Content Checksums. The standard five x-amz-checksum-* names also exist in
|
||||
// the zero-internal-dependency rustfs-checksums leaf crate
|
||||
// (crates/checksums/src/http.rs, which additionally owns the RustFS
|
||||
// extension names); values are pinned by the S3 wire protocol — keep both
|
||||
// sides in sync (backlog#1833).
|
||||
// Content Checksums
|
||||
pub const AMZ_CHECKSUM_ALGO: &str = "x-amz-checksum-algorithm";
|
||||
pub const AMZ_CHECKSUM_CRC32: &str = "x-amz-checksum-crc32";
|
||||
pub const AMZ_CHECKSUM_CRC32C: &str = "x-amz-checksum-crc32c";
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -45,17 +44,12 @@ pub fn has_suffix(s: &str, suffix: &str) -> bool {
|
||||
/// If the object name ends with a slash, it is considered a directory object.
|
||||
/// The trailing slash is removed and `GLOBAL_DIR_SUFFIX` is appended.
|
||||
/// If it does not end with a slash, the name is returned as is.
|
||||
pub fn encode_dir_object_ref(object: &str) -> Cow<'_, str> {
|
||||
if has_suffix(object, SLASH_SEPARATOR) {
|
||||
Cow::Owned(format!("{}{}", object.trim_end_matches(SLASH_SEPARATOR), GLOBAL_DIR_SUFFIX))
|
||||
} else {
|
||||
Cow::Borrowed(object)
|
||||
}
|
||||
}
|
||||
|
||||
/// Owned compatibility wrapper for callers that retain or mutate the encoded name.
|
||||
pub fn encode_dir_object(object: &str) -> String {
|
||||
encode_dir_object_ref(object).into_owned()
|
||||
if has_suffix(object, SLASH_SEPARATOR) {
|
||||
format!("{}{}", object.trim_end_matches(SLASH_SEPARATOR), GLOBAL_DIR_SUFFIX)
|
||||
} else {
|
||||
object.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the given object name represents a directory object.
|
||||
@@ -444,12 +438,6 @@ impl LazyBuf {
|
||||
/// The returned path ends in a slash only if it represents a root directory, such as `/` on Unix or `C:/` on Windows.
|
||||
///
|
||||
/// If the result of this process is an empty string, `clean` returns the string `.`.
|
||||
///
|
||||
/// Note: `crates/policy/src/policy/utils/path.rs` deliberately keeps its own
|
||||
/// slash-only Go `path.Clean` port instead of using this function — S3
|
||||
/// ARN/resource matching must not treat backslashes as separators, and this
|
||||
/// Windows-aware version would change policy evaluation semantics on Windows.
|
||||
/// Do not consolidate the two (backlog#1833).
|
||||
pub fn clean(path: &str) -> String {
|
||||
if path.is_empty() {
|
||||
return ".".to_string();
|
||||
@@ -614,17 +602,6 @@ mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn encode_dir_object_ref_borrows_objects_and_encodes_directories() {
|
||||
let object = "prefix/object";
|
||||
let encoded = encode_dir_object_ref(object);
|
||||
assert!(matches!(encoded, Cow::Borrowed(value) if value == object));
|
||||
|
||||
let encoded = encode_dir_object_ref("prefix/directory/");
|
||||
assert!(matches!(encoded, Cow::Owned(ref value) if value == "prefix/directory__XLDIR__"));
|
||||
assert_eq!(encode_dir_object("prefix/directory/"), encoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trim_etag() {
|
||||
// Test with quoted ETag
|
||||
|
||||
@@ -101,56 +101,6 @@ impl Stream for RetryTimer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives `operation` with capped, jittered exponential backoff, returning the
|
||||
/// first success or the last error once `max_attempts` attempts are exhausted.
|
||||
///
|
||||
/// The sleep before retry `n` (1-based) is `min(base_delay * 2^(n-1), max_delay)`,
|
||||
/// reduced by up to half through a cheap clock-derived jitter so concurrent
|
||||
/// retriers decorrelate — the same backoff shape as [`RetryTimer`] without
|
||||
/// needing a caller-supplied random seed or the Stream API. `max_attempts` is
|
||||
/// clamped to at least 1.
|
||||
pub async fn retry_with_backoff<F, Fut, T, E>(
|
||||
mut operation: F,
|
||||
max_attempts: usize,
|
||||
base_delay: Duration,
|
||||
max_delay: Duration,
|
||||
) -> Result<T, E>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<T, E>>,
|
||||
{
|
||||
let max_attempts = max_attempts.max(1);
|
||||
let mut last_err = None;
|
||||
|
||||
for attempt in 0..max_attempts {
|
||||
match operation().await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(err) => {
|
||||
last_err = Some(err);
|
||||
if attempt + 1 < max_attempts {
|
||||
// Cap the shift so the multiplier cannot overflow; the cap
|
||||
// below bounds the result anyway.
|
||||
let exp = base_delay.saturating_mul(1u32 << attempt.min(16));
|
||||
let mut sleep_duration = exp.min(max_delay);
|
||||
// Up to 50% reduction, derived from the clock's sub-second
|
||||
// nanoseconds — cheap decorrelation without a rand dependency.
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0);
|
||||
let reduction_percent = u64::from(nanos % 50);
|
||||
let sleep_ms = sleep_duration.as_millis() as u64;
|
||||
let jittered_ms = sleep_ms.saturating_sub(sleep_ms * reduction_percent / 100).max(1);
|
||||
sleep_duration = Duration::from_millis(jittered_ms);
|
||||
tokio::time::sleep(sleep_duration).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err.expect("max_attempts is clamped to at least 1, so at least one attempt ran"))
|
||||
}
|
||||
|
||||
static RETRYABLE_S3CODES: LazyLock<Vec<String>> = LazyLock::new(|| {
|
||||
vec![
|
||||
"RequestError".to_string(),
|
||||
@@ -291,87 +241,6 @@ mod tests {
|
||||
assert!(!is_s3code_in_message_retryable(""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_with_backoff_returns_first_success_without_retrying() {
|
||||
let mut calls = 0;
|
||||
let result: Result<i32, std::io::Error> = retry_with_backoff(
|
||||
|| {
|
||||
calls += 1;
|
||||
async { Ok(42) }
|
||||
},
|
||||
3,
|
||||
Duration::from_millis(1),
|
||||
Duration::from_millis(2),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result.expect("first attempt succeeds"), 42);
|
||||
assert_eq!(calls, 1, "a success must not trigger further attempts");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_with_backoff_retries_until_success() {
|
||||
let mut calls = 0;
|
||||
let result: Result<i32, std::io::Error> = retry_with_backoff(
|
||||
|| {
|
||||
calls += 1;
|
||||
let attempt = calls;
|
||||
async move {
|
||||
if attempt < 3 {
|
||||
Err(std::io::Error::other("transient"))
|
||||
} else {
|
||||
Ok(7)
|
||||
}
|
||||
}
|
||||
},
|
||||
5,
|
||||
Duration::from_millis(1),
|
||||
Duration::from_millis(2),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result.expect("third attempt succeeds"), 7);
|
||||
assert_eq!(calls, 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_with_backoff_exhausts_attempts_and_returns_last_error() {
|
||||
let mut calls = 0;
|
||||
let result: Result<(), std::io::Error> = retry_with_backoff(
|
||||
|| {
|
||||
calls += 1;
|
||||
let attempt = calls;
|
||||
async move { Err(std::io::Error::other(format!("attempt {attempt}"))) }
|
||||
},
|
||||
3,
|
||||
Duration::from_millis(1),
|
||||
Duration::from_millis(2),
|
||||
)
|
||||
.await;
|
||||
|
||||
let err = result.expect_err("all attempts fail");
|
||||
assert_eq!(err.to_string(), "attempt 3", "the LAST error must be returned");
|
||||
assert_eq!(calls, 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_with_backoff_clamps_zero_attempts_to_one() {
|
||||
let mut calls = 0;
|
||||
let result: Result<(), std::io::Error> = retry_with_backoff(
|
||||
|| {
|
||||
calls += 1;
|
||||
async { Err(std::io::Error::other("always")) }
|
||||
},
|
||||
0,
|
||||
Duration::from_millis(1),
|
||||
Duration::from_millis(2),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(calls, 1, "zero attempts clamps to a single attempt instead of panicking");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_s3code_in_message_retryable_is_case_sensitive() {
|
||||
// Pin the contract: a backend that down-cases its error
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# GET Path Experimental Performance Switches
|
||||
|
||||
This document records two experimental environment switches on the object GET
|
||||
path. Both default to **off**, are read once at startup, and exist to support
|
||||
staged performance work — they are not general tuning knobs. Until this
|
||||
document existed they were referenced only by performance harness scripts,
|
||||
which made them look like orphans during dead-code sweeps; they are kept
|
||||
deliberately (rustfs/backlog#1832).
|
||||
|
||||
## RUSTFS_GET_SEEK_BUFFER_ENABLE
|
||||
|
||||
- Type: boolean (`true`/`false`), default `false`.
|
||||
- Read once at startup in `rustfs/src/app/object_usecase.rs`.
|
||||
- When enabled, small GET responses may be served through an in-memory seek
|
||||
buffer, providing seek support without re-reading the object. The seek-buffer
|
||||
code path is unit-test gated; whether the path stays or graduates to default
|
||||
is a post-1.0 maintainer decision — do not remove either the switch or the
|
||||
gated path as dead code.
|
||||
|
||||
## RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE
|
||||
|
||||
- Type: boolean (`true`/`false`), default `false`.
|
||||
- Read once at startup in `rustfs/src/app/object_usecase.rs`.
|
||||
- When enabled, GET responses attribute output-handoff stage timing in the GET
|
||||
stage metrics, at a small per-request bookkeeping cost. Used by the A/B
|
||||
performance runbooks (`scripts/run_get_codec_streaming_smoke.sh`,
|
||||
`scripts/test_get_1mib_abba_stage_metrics.sh`) to compare handoff cost
|
||||
between configurations.
|
||||
|
||||
## Operational guidance
|
||||
|
||||
Leave both switches unset in production. Enable them only when following a
|
||||
performance runbook that asks for them, and unset them afterwards — both are
|
||||
startup-latched, so changing a value requires a process restart to take
|
||||
effect.
|
||||
@@ -176,7 +176,6 @@ These are properties of the upgraded code, so a single node left behind removes
|
||||
|
||||
- **Check-and-set lifecycle writes.** Upgraded builds write every KV2 lifecycle mutation — create, enable, disable, tag metadata, schedule deletion, cancel deletion — as a versioned read followed by a check-and-set write, retrying on conflict by re-reading and re-validating the state gate (rustfs/rustfs#5518). Transit metadata writes got the same treatment (rustfs/rustfs#5520). Builds older than those write blind. A blind write from an old node can overwrite a check-and-set commit from an upgraded node without any conflict being reported, which is precisely the lost update the change was made to eliminate.
|
||||
- **`baseline_version` survives a write-back.** The KV2 key record does not deny unknown fields, so an old build reads a new record without error — and drops `baseline_version` when it writes that record back for any reason. A key that loses its baseline resolves pre-versioning envelopes to the current version again, which after a rotation means the wrong master key material. Any lifecycle operation issued to an old node is enough to trigger this.
|
||||
- **`wrap_budget_reserved` keeps overestimating.** The KV2 key record's approximate wrap counter (`wrap_budget_reserved`, behind the `rustfs_kms_max_key_wrap_operations` gauge) is dropped the same way when an old build rewrites the record, regressing the count toward zero — the one way this deliberately overestimate-only counter can understate the wraps actually performed. Nothing breaks: the counter is advisory, and the next block reservation from an upgraded node re-establishes a floor. Just do not trust a *low* gauge reading taken during or shortly after a mixed-version window.
|
||||
- **Version-record awareness.** Rotation stores each historical version under `{prefix}/{key_id}/versions/{N}` as a create-only record (check-and-set of 0), so two nodes racing the same version number produce exactly one creator; the loser adopts the persisted, never-current material or fails without touching the current pointer. Old builds have no concept of that sub-path: they never read or write it, and their key listing reports the KV2 directory entry (`my-key/`) as though it were a key, because the directory filter only exists in upgraded builds.
|
||||
|
||||
### Windows in which nodes can legitimately disagree
|
||||
|
||||
@@ -54,18 +54,15 @@ Published by the background deletion worker (`crates/kms/src/deletion_worker.rs`
|
||||
| `rustfs_kms_pending_deletion_keys` | gauge | — | Keys scheduled for deletion whose deadline has not passed |
|
||||
| `rustfs_kms_deletion_tombstone_keys` | gauge | — | Keys left tombstoned by an interrupted removal, still awaiting the sweep |
|
||||
| `rustfs_kms_oldest_key_rotation_age_seconds` | gauge | — | Seconds since the least recently rotated usable key was rotated, counting from creation for keys with no recorded rotation; `0` when there are none |
|
||||
| `rustfs_kms_max_key_wrap_operations` | gauge | — | Largest reserved wrap-operation count across usable keys; published only by backends that count wraps (Vault KV2 today) |
|
||||
| `rustfs_kms_deletion_sweep_keys_total` | counter | `outcome` | Keys the sweep acted on, by outcome: `removed`, `blocked`, `skipped`, `failed`, `unreadable` |
|
||||
|
||||
`outcome` is `removed`, `blocked` (live configuration — the default key, or a reference reported by the injected checker — still points at the key, so the sweep refuses to remove it), `skipped` (pending but not yet due, or the state changed between inspection and removal), `failed` (the removal attempt failed and is retried next sweep), or `unreadable` (the backend listed a key record this build cannot describe — a record written by a newer build, or damaged material). Every series is emitted at zero from the first sweep on, so a `rate()` over it is defined immediately.
|
||||
|
||||
A non-zero `unreadable` rate does not stop the sweep — the expired keys it *can* read are still destroyed — but it does suppress the lifecycle gauges for that round, because a census taken over a partially readable key set would quietly undercount. Sustained `unreadable` therefore shows up as gauges that stop advancing; investigate the named key ids from the sweep's log line before trusting a rotation-age or pending-deletion reading again.
|
||||
A non-zero `unreadable` rate does not stop the sweep — the expired keys it *can* read are still destroyed — but it does suppress the three lifecycle gauges for that round, because a census taken over a partially readable key set would quietly undercount. Sustained `unreadable` therefore shows up as gauges that stop advancing; investigate the named key ids from the sweep's log line before trusting a rotation-age or pending-deletion reading again.
|
||||
|
||||
Total damage looks different, and it is worth knowing which you are seeing. When *no* key in a complete listing is readable, the backend fails the listing outright rather than returning an empty page (see the key listing contract in the admin contract page), so the sweep never gets a page to count: it reports `outcome="failed"` with the listing error in its `warn!` line and names no key ids. So `failed` climbing while `unreadable` stays at zero and the gauges freeze means the whole key set is unreadable on this node — a mixed-version node, or a credential that cannot open any record — not that individual removals are failing.
|
||||
|
||||
The gauges are republished only by a sweep that saw the whole key set; a sweep that could not finish listing leaves the previous, complete values standing rather than understating them. Keys already on their way out are excluded from the rotation-age and wrap gauges, so neither stays pinned high by a key that will never be rotated — or wrap — again.
|
||||
|
||||
`rustfs_kms_max_key_wrap_operations` exists because AES-256-GCM caps one key at 2^32 encryptions under random nonces (NIST SP 800-38D), and the KV2 backend wraps every DEK locally with the key's current material — so wraps track encrypted-object writes and the bound is real. The value is a reservation-based approximation that by design *overestimates*: nodes reserve wrap budget from the key record in blocks of one million and count individual wraps in memory only, so a crash discards unused budget, never a counted wrap. Alert on it approaching 2^32 and rotate the key — rotation installs fresh material and resets the counter. Two ways it can understate, both bounded and logged: a node whose reservation writes keep failing continues wrapping under a warn (`Vault KMS wrap budget reservation failed`), and an old build rewriting the key record during a mixed-version window drops the field (see the [mixed-version notes](kms-backend-security.md#mixed-version-clusters-during-a-rolling-upgrade)). Backends that do not wrap locally with rotatable material publish nothing here: Transit and AWS wrap inside the KMS, and Local/Static cannot rotate, so a counter would be an alarm with no remediation.
|
||||
The three gauges are republished only by a sweep that saw the whole key set; a sweep that could not finish listing leaves the previous, complete values standing rather than understating them. Keys already on their way out are excluded from the rotation-age gauge, so it does not stay pinned high by a key that will never be rotated again.
|
||||
|
||||
The rotation age comes from whatever the backend reports as the last rotation, and backends only report a rotation they recorded themselves. Today only the Vault KV2 backend persists that timestamp — it is stamped in the same check-and-set write that commits the rotation (`crates/kms/src/backends/vault.rs`), so it exists if and only if the rotation did. Vault Transit and AWS KMS record no rotation timestamp at all: their key listings always report the rotation time as absent, so on those backends every key ages from creation permanently, the gauge measures key age rather than rotation age, and rotating does not reset it. A KV2 key rotated before the timestamp existed likewise ages from creation until its next rotation stamps the record. In every case the gauge overstates rather than invents — it can report an already-rotated key as overdue, never a stale key as fresh — so an alert on it fires early rather than late. Backends that cannot rotate at all (Local, Static) age every key from creation by construction.
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
| list_objects_v2_metadata_extension_test | 1 | |
|
||||
| list_objects_v2_pagination_test | 12 | ✅ |
|
||||
| mc_mirror_small_bucket_test | 1 | |
|
||||
| multipart_auth_test | 85 | |
|
||||
| multipart_auth_test | 103 | |
|
||||
| multipart_storage_class_test | 3 | ✅ |
|
||||
| namespace_lock_quorum_test | 2 | |
|
||||
| negative_sigv4_test | 6 | ✅ |
|
||||
|
||||
@@ -142,11 +142,6 @@ data:
|
||||
RUSTFS_KMS_DEFAULT_KEY_ID: {{ .default_key | quote }}
|
||||
{{- if eq .vault_backend "vault-transit" }}
|
||||
RUSTFS_KMS_VAULT_MOUNT_PATH: {{ .vault_mount_path | quote }}
|
||||
{{- else if .vault_mount_path }}
|
||||
{{- /* The KV2 backend never calls the Transit engine: its mount is the KV2
|
||||
one, under a different variable. Emitted only when set, so an unset
|
||||
value keeps falling back to the "secret" default. */}}
|
||||
RUSTFS_KMS_VAULT_KV_MOUNT: {{ .vault_mount_path | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -230,7 +230,7 @@ config:
|
||||
vault_backend: "" # Only support vault kv2 and vault transit.
|
||||
vault_address: ""
|
||||
vault_token: "" # Rendered into a dedicated Secret, never into the config ConfigMap.
|
||||
vault_mount_path: "" # Transit engine mount for vault-transit; KV2 engine mount for vault. Unset means "secret" for KV2, which only a dev-mode Vault has by default.
|
||||
vault_mount_path: ""
|
||||
default_key: ""
|
||||
|
||||
|
||||
|
||||
@@ -243,6 +243,7 @@ rustfs-object-data-cache = { workspace = true, features = ["cache"] }
|
||||
rustfs-concurrency = { workspace = true }
|
||||
rustfs-scanner = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
starshard = { workspace = true, features = ["rayon", "async", "serde"] }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-trait = { workspace = true }
|
||||
|
||||
@@ -13,14 +13,13 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::auth::get_condition_values;
|
||||
use crate::server::RemoteAddr;
|
||||
use http::HeaderMap;
|
||||
use http::Uri;
|
||||
use rustfs_credentials::Credentials;
|
||||
use rustfs_iam::store::Store;
|
||||
use rustfs_iam::sys::IamSys;
|
||||
use rustfs_policy::policy::{Args, action::Action};
|
||||
use s3s::{Body, S3Request, S3Result, s3_error};
|
||||
use s3s::{S3Result, s3_error};
|
||||
use std::sync::Arc;
|
||||
use tracing::debug;
|
||||
|
||||
@@ -293,25 +292,6 @@ pub async fn authenticate_request(
|
||||
result
|
||||
}
|
||||
|
||||
/// Full admin gate over an `S3Request`: extract the request credentials,
|
||||
/// authenticate them ([`authenticate_request`]), then authorize the caller for
|
||||
/// `actions` ([`validate_admin_request`], allowing on the first permitted
|
||||
/// action). Returns the authenticated credentials for handlers that need the
|
||||
/// caller identity. `deny_only` stays `false`: every caller performs a full
|
||||
/// allow check.
|
||||
pub async fn authorize_admin_request(req: &S3Request<Body>, actions: Vec<Action>) -> S3Result<Credentials> {
|
||||
let Some(input_cred) = req.credentials.as_ref() else {
|
||||
return Err(s3_error!(InvalidRequest, "get cred failed"));
|
||||
};
|
||||
|
||||
let (cred, owner) = authenticate_request(&req.headers, &req.uri, input_cred).await?;
|
||||
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
validate_admin_request(&req.headers, &cred, owner, false, actions, remote_addr).await?;
|
||||
|
||||
Ok(cred)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Unit coverage for the central admin authorization gate (rustfs/backlog#1151 sec-4).
|
||||
@@ -590,30 +570,6 @@ mod tests {
|
||||
assert_access_denied(res);
|
||||
}
|
||||
|
||||
/// The shared admin gate rejects a request carrying no credentials before
|
||||
/// authentication or IAM is consulted, with the exact error the folded
|
||||
/// per-handler wrappers produced (rustfs/backlog#1829).
|
||||
#[tokio::test]
|
||||
async fn authorize_admin_request_without_credentials_is_rejected() {
|
||||
let req = S3Request {
|
||||
input: Body::from(String::new()),
|
||||
method: http::Method::GET,
|
||||
uri: Uri::from_static("/rustfs/admin/v3/list-jobs"),
|
||||
headers: HeaderMap::new(),
|
||||
extensions: http::Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
};
|
||||
|
||||
let err = authorize_admin_request(&req, vec![admin_action()])
|
||||
.await
|
||||
.expect_err("a request without credentials must be rejected");
|
||||
assert_eq!(err.code(), &s3s::S3ErrorCode::InvalidRequest);
|
||||
assert_eq!(err.message(), Some("get cred failed"));
|
||||
}
|
||||
|
||||
/// KMS scoping rides the object slot with an empty bucket, matching the
|
||||
/// contract the policy crate evaluates KMS statements against.
|
||||
#[test]
|
||||
|
||||
@@ -35,10 +35,11 @@
|
||||
//! When RustFS grows a real batch-job engine, these handlers should be rewired to
|
||||
//! it; the request parsing and response shapes here are intended to stay stable.
|
||||
|
||||
use crate::admin::auth::authorize_admin_request;
|
||||
use crate::admin::auth::validate_admin_request;
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::admin::utils::read_compatible_admin_body;
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::server::{ADMIN_PREFIX, RemoteAddr};
|
||||
use http::{HeaderMap, HeaderValue, Uri};
|
||||
use hyper::{Method, StatusCode};
|
||||
use matchit::Params;
|
||||
@@ -69,7 +70,17 @@ fn extract_query_params(uri: &Uri) -> HashMap<String, String> {
|
||||
}
|
||||
|
||||
async fn validate_batch_job_admin_request(req: &S3Request<Body>, action: AdminAction) -> S3Result<Credentials> {
|
||||
authorize_admin_request(req, vec![Action::AdminAction(action)]).await
|
||||
let Some(input_cred) = req.credentials.as_ref() else {
|
||||
return Err(s3_error!(InvalidRequest, "get cred failed"));
|
||||
};
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
||||
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
validate_admin_request(&req.headers, &cred, owner, false, vec![Action::AdminAction(action)], remote_addr).await?;
|
||||
|
||||
Ok(cred)
|
||||
}
|
||||
|
||||
fn json_response<T: Serialize>(status: StatusCode, value: &T) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
|
||||
@@ -928,7 +928,7 @@ mod tests {
|
||||
ClusterListingDiagnosticsSnapshot, ClusterReadOnlySnapshot, ClusterRuntimeReadinessState, ClusterRuntimeStatusSnapshot,
|
||||
ClusterUsageFreshnessSnapshot,
|
||||
};
|
||||
use crate::shared_types::{DependencyReadiness, ReadinessDegradedReason};
|
||||
use crate::server::{DependencyReadiness, ReadinessDegradedReason};
|
||||
use rustfs_concurrency::{AdmissionState, WorkloadAdmissionRegistrySnapshot, WorkloadAdmissionSnapshot, WorkloadClass};
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::admin::auth::authorize_admin_request;
|
||||
use crate::admin::auth::validate_admin_request;
|
||||
use crate::admin::handlers::supervise_admin_mutation;
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::admin::runtime_sources::{
|
||||
@@ -31,8 +31,9 @@ use crate::admin::storage_api::config::{
|
||||
};
|
||||
use crate::admin::storage_api::contract::list::ListOperations as _;
|
||||
use crate::admin::utils::{encode_compatible_admin_payload, is_compat_admin_request, read_compatible_admin_body};
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::error::ApiError;
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use crate::server::{ADMIN_PREFIX, RemoteAddr};
|
||||
use http::{HeaderMap, HeaderValue, Uri};
|
||||
use hyper::{Method, StatusCode};
|
||||
use matchit::Params;
|
||||
@@ -677,12 +678,28 @@ fn extract_query_params(uri: &Uri) -> HashMap<String, String> {
|
||||
}
|
||||
|
||||
async fn validate_config_admin_request(req: &S3Request<Body>) -> S3Result<Credentials> {
|
||||
// Pre-check keeps this endpoint's historical missing-credentials message;
|
||||
// the shared gate reports "get cred failed".
|
||||
if req.credentials.is_none() {
|
||||
let Some(input_cred) = req.credentials.as_ref() else {
|
||||
return Err(s3_error!(InvalidRequest, "missing credentials"));
|
||||
}
|
||||
authorize_admin_request(req, vec![Action::AdminAction(AdminAction::ConfigUpdateAdminAction)]).await
|
||||
};
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
||||
|
||||
let remote_addr = req
|
||||
.extensions
|
||||
.get::<Option<RemoteAddr>>()
|
||||
.and_then(|opt| opt.map(|addr| addr.0));
|
||||
validate_admin_request(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::AdminAction(AdminAction::ConfigUpdateAdminAction)],
|
||||
remote_addr,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(cred)
|
||||
}
|
||||
|
||||
fn header_value(content_type: &str) -> S3Result<HeaderValue> {
|
||||
@@ -2285,30 +2302,6 @@ mod tests {
|
||||
use serial_test::serial;
|
||||
use temp_env::with_vars;
|
||||
|
||||
/// The config-admin gate historically reports "missing credentials" (not the
|
||||
/// shared gate's "get cred failed"); the pre-check in
|
||||
/// `validate_config_admin_request` must keep that message byte-identical.
|
||||
#[tokio::test]
|
||||
async fn config_admin_request_without_credentials_keeps_historical_message() {
|
||||
let req = S3Request {
|
||||
input: Body::from(String::new()),
|
||||
method: Method::GET,
|
||||
uri: Uri::from_static("/rustfs/admin/v3/config"),
|
||||
headers: HeaderMap::new(),
|
||||
extensions: http::Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
};
|
||||
|
||||
let err = validate_config_admin_request(&req)
|
||||
.await
|
||||
.expect_err("a request without credentials must be rejected");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
||||
assert_eq!(err.message(), Some("missing credentials"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_preflight_covers_each_runtime_worker_family() {
|
||||
assert_eq!(config_preflight_subsystems(Some(SCANNER_SUB_SYS)), [SCANNER_SUB_SYS]);
|
||||
|
||||
@@ -195,14 +195,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_health_response_readiness_returns_503_when_deps_not_ready() {
|
||||
let readiness_report = crate::shared_types::DependencyReadinessReport {
|
||||
readiness: crate::shared_types::DependencyReadiness {
|
||||
let readiness_report = crate::server::DependencyReadinessReport {
|
||||
readiness: crate::server::DependencyReadiness {
|
||||
storage_ready: false,
|
||||
iam_ready: true,
|
||||
lock_quorum_ready: true,
|
||||
peer_health_ready: true,
|
||||
},
|
||||
degraded_reasons: vec![crate::shared_types::ReadinessDegradedReason::StorageQuorumUnavailable],
|
||||
degraded_reasons: vec![crate::server::ReadinessDegradedReason::StorageQuorumUnavailable],
|
||||
};
|
||||
let parts = build_health_response_parts(
|
||||
Method::GET,
|
||||
@@ -217,8 +217,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_health_response_readiness_returns_200_when_deps_ready() {
|
||||
let readiness_report = crate::shared_types::DependencyReadinessReport {
|
||||
readiness: crate::shared_types::DependencyReadiness {
|
||||
let readiness_report = crate::server::DependencyReadinessReport {
|
||||
readiness: crate::server::DependencyReadiness {
|
||||
storage_ready: true,
|
||||
iam_ready: true,
|
||||
lock_quorum_ready: true,
|
||||
@@ -239,14 +239,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_health_response_liveness_returns_200_when_deps_not_ready() {
|
||||
let readiness_report = crate::shared_types::DependencyReadinessReport {
|
||||
readiness: crate::shared_types::DependencyReadiness {
|
||||
let readiness_report = crate::server::DependencyReadinessReport {
|
||||
readiness: crate::server::DependencyReadiness {
|
||||
storage_ready: false,
|
||||
iam_ready: false,
|
||||
lock_quorum_ready: false,
|
||||
peer_health_ready: true,
|
||||
},
|
||||
degraded_reasons: vec![crate::shared_types::ReadinessDegradedReason::StorageAndIamUnavailable],
|
||||
degraded_reasons: vec![crate::server::ReadinessDegradedReason::StorageAndIamUnavailable],
|
||||
};
|
||||
let parts = build_health_response_parts(
|
||||
Method::GET,
|
||||
@@ -266,14 +266,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_health_response_head_returns_empty_body() {
|
||||
let readiness_report = crate::shared_types::DependencyReadinessReport {
|
||||
readiness: crate::shared_types::DependencyReadiness {
|
||||
let readiness_report = crate::server::DependencyReadinessReport {
|
||||
readiness: crate::server::DependencyReadiness {
|
||||
storage_ready: false,
|
||||
iam_ready: false,
|
||||
lock_quorum_ready: false,
|
||||
peer_health_ready: true,
|
||||
},
|
||||
degraded_reasons: vec![crate::shared_types::ReadinessDegradedReason::StorageAndIamUnavailable],
|
||||
degraded_reasons: vec![crate::server::ReadinessDegradedReason::StorageAndIamUnavailable],
|
||||
};
|
||||
let parts = build_health_response_parts(
|
||||
Method::HEAD,
|
||||
@@ -297,7 +297,7 @@ mod tests {
|
||||
storage_ready: true,
|
||||
iam_ready: false,
|
||||
lock_quorum_ready: true,
|
||||
degraded_reasons: &[crate::shared_types::ReadinessDegradedReason::IamNotReady],
|
||||
degraded_reasons: &[crate::server::ReadinessDegradedReason::IamNotReady],
|
||||
service: "rustfs-endpoint",
|
||||
uptime: Some(123),
|
||||
kms_ready: None,
|
||||
@@ -322,7 +322,7 @@ mod tests {
|
||||
storage_ready: false,
|
||||
iam_ready: false,
|
||||
lock_quorum_ready: false,
|
||||
degraded_reasons: &[crate::shared_types::ReadinessDegradedReason::StorageAndIamUnavailable],
|
||||
degraded_reasons: &[crate::server::ReadinessDegradedReason::StorageAndIamUnavailable],
|
||||
service: "rustfs-endpoint",
|
||||
uptime: None,
|
||||
kms_ready: None,
|
||||
@@ -334,8 +334,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_health_response_parts_head_has_no_payload() {
|
||||
let report = crate::shared_types::DependencyReadinessReport {
|
||||
readiness: crate::shared_types::DependencyReadiness {
|
||||
let report = crate::server::DependencyReadinessReport {
|
||||
readiness: crate::server::DependencyReadiness {
|
||||
storage_ready: true,
|
||||
iam_ready: true,
|
||||
lock_quorum_ready: true,
|
||||
@@ -353,14 +353,14 @@ mod tests {
|
||||
#[serial]
|
||||
fn test_build_health_response_parts_get_includes_payload() {
|
||||
with_var(rustfs_config::ENV_HEALTH_MINIMAL_RESPONSE_ENABLE, Some("false"), || {
|
||||
let report = crate::shared_types::DependencyReadinessReport {
|
||||
readiness: crate::shared_types::DependencyReadiness {
|
||||
let report = crate::server::DependencyReadinessReport {
|
||||
readiness: crate::server::DependencyReadiness {
|
||||
storage_ready: false,
|
||||
iam_ready: true,
|
||||
lock_quorum_ready: true,
|
||||
peer_health_ready: true,
|
||||
},
|
||||
degraded_reasons: vec![crate::shared_types::ReadinessDegradedReason::StorageQuorumUnavailable],
|
||||
degraded_reasons: vec![crate::server::ReadinessDegradedReason::StorageQuorumUnavailable],
|
||||
};
|
||||
let parts =
|
||||
build_health_response_parts(Method::GET, HealthProbe::Readiness, Some(&report), "rustfs-endpoint", None, None);
|
||||
@@ -376,8 +376,8 @@ mod tests {
|
||||
#[serial]
|
||||
fn test_build_health_response_parts_readiness_marks_kms_not_ready() {
|
||||
with_var(rustfs_config::ENV_HEALTH_MINIMAL_RESPONSE_ENABLE, Some("false"), || {
|
||||
let report = crate::shared_types::DependencyReadinessReport {
|
||||
readiness: crate::shared_types::DependencyReadiness {
|
||||
let report = crate::server::DependencyReadinessReport {
|
||||
readiness: crate::server::DependencyReadiness {
|
||||
storage_ready: true,
|
||||
iam_ready: true,
|
||||
lock_quorum_ready: true,
|
||||
|
||||
@@ -24,10 +24,10 @@ use crate::admin::storage_api::lifecycle::{
|
||||
claim_manual_transition_scope_admission, delete_manual_transition_scope_admission_if_current,
|
||||
delete_transition_candidate_for_operator, enqueue_transition_for_existing_objects_scoped,
|
||||
finalize_missing_transition_transaction_for_operator, inspect_transition_transaction_for_operator,
|
||||
load_manual_transition_job_record, load_manual_transition_scope_admission, manual_transition_job_lease_expired,
|
||||
manual_transition_queue_snapshot, manual_transition_scope_admission_lease_expired,
|
||||
persist_manual_transition_job_progress_if_owned, renew_manual_transition_job_lease_if_owned,
|
||||
request_manual_transition_job_cancel, save_manual_transition_job_record, update_manual_transition_job_record,
|
||||
load_manual_transition_job_record, load_manual_transition_job_record_with_etag, load_manual_transition_scope_admission,
|
||||
manual_transition_job_lease_expired, manual_transition_queue_snapshot, manual_transition_scope_admission_lease_expired,
|
||||
persist_manual_transition_job_progress, renew_manual_transition_job_lease, request_manual_transition_job_cancel,
|
||||
save_manual_transition_job_record, save_manual_transition_job_record_if_current,
|
||||
};
|
||||
use crate::admin::storage_api::runtime::ECStore;
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
@@ -645,15 +645,31 @@ fn json_response<T: Serialize>(response: &T, status: StatusCode) -> S3Result<S3R
|
||||
Ok(S3Response::with_headers((status, Body::from(body)), headers))
|
||||
}
|
||||
|
||||
async fn update_manual_transition_job_record_if_owned(
|
||||
async fn update_manual_transition_job_record_cas(
|
||||
store: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
expected_lease_id: Uuid,
|
||||
mut update: impl FnMut(&mut ManualTransitionJobRecord) -> bool,
|
||||
mut update: impl FnMut(&mut ManualTransitionJobRecord),
|
||||
) -> S3Result<ManualTransitionJobRecord> {
|
||||
update_manual_transition_job_record(store, job_id, Some(expected_lease_id), |record| update(record))
|
||||
.await
|
||||
.map_err(|err| map_manual_transition_job_load_error(err, job_id))
|
||||
for _ in 0..4 {
|
||||
let (mut record, etag) = load_manual_transition_job_record_with_etag(store.clone(), job_id)
|
||||
.await
|
||||
.map_err(|err| map_manual_transition_job_load_error(err, job_id))?;
|
||||
update(&mut record);
|
||||
match save_manual_transition_job_record_if_current(store.clone(), &record, &etag).await {
|
||||
Ok(()) => return Ok(record),
|
||||
Err(StorageError::PreconditionFailed) => continue,
|
||||
Err(err) => {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InternalError,
|
||||
format!("manual transition job store failed: {err}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(s3_error!(
|
||||
OperationAborted,
|
||||
"manual transition job record changed concurrently; retry the request"
|
||||
))
|
||||
}
|
||||
|
||||
fn manual_transition_durable_cancel_check(store: Arc<ECStore>, job_id: Uuid) -> ManualTransitionCancelCheck {
|
||||
@@ -691,11 +707,11 @@ fn manual_transition_durable_cancel_check(store: Arc<ECStore>, job_id: Uuid) ->
|
||||
})
|
||||
}
|
||||
|
||||
fn manual_transition_progress_sink(store: Arc<ECStore>, job_id: Uuid, lease_id: Uuid) -> ManualTransitionProgressSink {
|
||||
fn manual_transition_progress_sink(store: Arc<ECStore>, job_id: Uuid) -> ManualTransitionProgressSink {
|
||||
Arc::new(move |report| {
|
||||
let store = store.clone();
|
||||
Box::pin(async move {
|
||||
persist_manual_transition_job_progress_if_owned(store, job_id, lease_id, &report, manual_transition_queue_snapshot())
|
||||
persist_manual_transition_job_progress(store, job_id, &report, manual_transition_queue_snapshot())
|
||||
.await
|
||||
.map(|_| ())
|
||||
})
|
||||
@@ -725,13 +741,9 @@ fn release_manual_transition_admission(store: Arc<ECStore>, record: &ManualTrans
|
||||
async fn finalize_manual_transition_job(
|
||||
store: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
lease_id: Uuid,
|
||||
result: Result<ManualTransitionRunReport, StorageError>,
|
||||
) -> Option<ManualTransitionJobRecord> {
|
||||
let updated = update_manual_transition_job_record_if_owned(store.clone(), job_id, lease_id, |record| {
|
||||
if record.is_terminal() {
|
||||
return false;
|
||||
}
|
||||
let updated = update_manual_transition_job_record_cas(store.clone(), job_id, |record| {
|
||||
let cancel_requested = record.cancel_requested;
|
||||
match &result {
|
||||
Ok(report) => {
|
||||
@@ -751,12 +763,10 @@ async fn finalize_manual_transition_job(
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.await;
|
||||
match updated {
|
||||
Ok(record) => Some(record),
|
||||
Err(err) if err.code() == &S3ErrorCode::OperationAborted => None,
|
||||
Err(err) => {
|
||||
error!(
|
||||
event = EVENT_ADMIN_ILM_TRANSITION_STATE,
|
||||
@@ -776,7 +786,6 @@ async fn finalize_manual_transition_job(
|
||||
fn spawn_manual_transition_job_heartbeat(
|
||||
store: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
lease_id: Uuid,
|
||||
scan_cancel_token: CancellationToken,
|
||||
shutdown_token: CancellationToken,
|
||||
) {
|
||||
@@ -786,7 +795,7 @@ fn spawn_manual_transition_job_heartbeat(
|
||||
tokio::select! {
|
||||
_ = shutdown_token.cancelled() => return,
|
||||
_ = interval.tick() => {
|
||||
match renew_manual_transition_job_lease_if_owned(store.clone(), job_id, lease_id, manual_transition_queue_snapshot()).await {
|
||||
match renew_manual_transition_job_lease(store.clone(), job_id, manual_transition_queue_snapshot()).await {
|
||||
Ok(record) if record.is_terminal() => {
|
||||
remove_active_manual_transition_job(job_id);
|
||||
scan_cancel_token.cancel();
|
||||
@@ -794,11 +803,6 @@ fn spawn_manual_transition_job_heartbeat(
|
||||
}
|
||||
Ok(record) if record.cancel_requested => scan_cancel_token.cancel(),
|
||||
Ok(_) => {}
|
||||
Err(StorageError::PreconditionFailed) => {
|
||||
remove_active_manual_transition_job(job_id);
|
||||
scan_cancel_token.cancel();
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event = EVENT_ADMIN_ILM_TRANSITION_STATE,
|
||||
@@ -836,23 +840,15 @@ async fn start_manual_transition_job(
|
||||
match claim_manual_transition_scope_admission(store.clone(), &ManualTransitionScopeAdmission::from_job(&record)).await {
|
||||
Ok(ManualTransitionScopeAdmissionClaim::Claimed) => {}
|
||||
Ok(ManualTransitionScopeAdmissionClaim::Conflict(active)) => {
|
||||
let _ = update_manual_transition_job_record_if_owned(store.clone(), job_id, record.lease_id, |record| {
|
||||
if record.is_terminal() {
|
||||
return false;
|
||||
}
|
||||
let _ = update_manual_transition_job_record_cas(store.clone(), job_id, |record| {
|
||||
record.fail("manual transition admission conflict");
|
||||
true
|
||||
})
|
||||
.await;
|
||||
return Ok(StartManualTransitionJobResult::Conflict(manual_transition_job_conflict_response(*active)));
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = update_manual_transition_job_record_if_owned(store.clone(), job_id, record.lease_id, |record| {
|
||||
if record.is_terminal() {
|
||||
return false;
|
||||
}
|
||||
let _ = update_manual_transition_job_record_cas(store.clone(), job_id, |record| {
|
||||
record.fail(format!("manual transition admission failed: {err}"));
|
||||
true
|
||||
})
|
||||
.await;
|
||||
return Err(S3Error::with_message(
|
||||
@@ -866,22 +862,21 @@ async fn start_manual_transition_job(
|
||||
let heartbeat_shutdown_token = CancellationToken::new();
|
||||
insert_active_manual_transition_job(job_id, scan_cancel_token.clone());
|
||||
let mut run_options = options;
|
||||
let lease_id = record.lease_id;
|
||||
run_options.job_id = Some(job_id);
|
||||
run_options.cancel_token = Some(scan_cancel_token.clone());
|
||||
run_options.cancel_check = Some(manual_transition_durable_cancel_check(store.clone(), job_id));
|
||||
run_options.progress_sink = Some(manual_transition_progress_sink(store.clone(), job_id, lease_id));
|
||||
run_options.progress_sink = Some(manual_transition_progress_sink(store.clone(), job_id));
|
||||
let run_store = store.clone();
|
||||
let job_scan_cancel_token = scan_cancel_token.clone();
|
||||
let job_heartbeat_shutdown_token = heartbeat_shutdown_token.clone();
|
||||
spawn_manual_transition_job_heartbeat(store, job_id, lease_id, scan_cancel_token, heartbeat_shutdown_token);
|
||||
spawn_manual_transition_job_heartbeat(store, job_id, scan_cancel_token, heartbeat_shutdown_token);
|
||||
tokio::spawn(async move {
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if std::env::var_os(E2E_MANUAL_TRANSITION_CANCEL_BARRIER_ENV).is_some() {
|
||||
job_scan_cancel_token.cancelled().await;
|
||||
}
|
||||
let result = enqueue_transition_for_existing_objects_scoped(run_store.clone(), &bucket, run_options).await;
|
||||
if let Some(final_record) = finalize_manual_transition_job(run_store.clone(), job_id, lease_id, result).await
|
||||
if let Some(final_record) = finalize_manual_transition_job(run_store.clone(), job_id, result).await
|
||||
&& final_record.is_terminal()
|
||||
{
|
||||
release_manual_transition_admission(run_store, &final_record);
|
||||
@@ -993,12 +988,9 @@ impl Operation for ManualTransitionJobStatusHandler {
|
||||
&& !manual_transition_scope_admission_lease_expired(&admission)
|
||||
});
|
||||
if !local_active && !leased_elsewhere && manual_transition_job_lease_expired(&record) {
|
||||
record = update_manual_transition_job_record_if_owned(store.clone(), job_id, record.lease_id, |record| {
|
||||
record = update_manual_transition_job_record_cas(store.clone(), job_id, |record| {
|
||||
if record.state == ManualTransitionJobState::Running && manual_transition_job_lease_expired(record) {
|
||||
record.mark_unknown_if_unowned();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -195,9 +195,7 @@ async fn save_kms_config(config: &KmsConfig) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn decode_persisted_kms_config(data: &[u8]) -> serde_json::Result<(KmsConfig, bool)> {
|
||||
// The observing loader warns about fields this build ignores, per the
|
||||
// repository unknown-field rule for compatibility-bound formats.
|
||||
let mut config: KmsConfig = rustfs_kms::config::kms_config_from_persisted_json(data)?;
|
||||
let mut config: KmsConfig = serde_json::from_slice(data)?;
|
||||
// The immediate-deletion gate is per-server operator state, never stored,
|
||||
// so a config loaded from cluster storage still has to pick it up here.
|
||||
config.allow_immediate_deletion = rustfs_kms::config::allow_immediate_deletion_from_env();
|
||||
|
||||
@@ -976,9 +976,6 @@ mod tests {
|
||||
// default, and only a populated one fixes the wire names.
|
||||
rotation_due: true,
|
||||
rotation_due_reason: Some(RotationDueReason::Age),
|
||||
// `#[serde(skip)]`: populated on purpose so the snapshot proves the
|
||||
// wrap counter stays off the admin wire even when a backend set it.
|
||||
wrap_budget_reserved: Some(1_000_000),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::admin::auth::authorize_admin_request;
|
||||
use crate::admin::auth::validate_admin_request;
|
||||
use crate::admin::handlers::site_replication::site_replication_peer_deployment_id_for_endpoint;
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::admin::runtime_sources::{
|
||||
@@ -35,8 +35,9 @@ use crate::admin::storage_api::contract::list::ListOperations as _;
|
||||
use crate::admin::storage_api::error::StorageError;
|
||||
use crate::admin::storage_api::runtime::PeerRestClient;
|
||||
use crate::admin::utils::read_compatible_admin_body;
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::error::ApiError;
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use crate::server::{ADMIN_PREFIX, RemoteAddr};
|
||||
use crate::storage::storage_api::lock_bucket_targets_metadata;
|
||||
use http::{HeaderMap, HeaderValue, Uri};
|
||||
use hyper::{Method, StatusCode};
|
||||
@@ -453,7 +454,17 @@ pub fn register_replication_route(r: &mut S3Router<AdminOperation>) -> std::io::
|
||||
}
|
||||
|
||||
async fn validate_replication_admin_request(req: &S3Request<Body>, action: AdminAction) -> S3Result<Credentials> {
|
||||
authorize_admin_request(req, vec![Action::AdminAction(action)]).await
|
||||
let Some(input_cred) = req.credentials.as_ref() else {
|
||||
return Err(s3_error!(InvalidRequest, "get cred failed"));
|
||||
};
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
||||
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
validate_admin_request(&req.headers, &cred, owner, false, vec![Action::AdminAction(action)], remote_addr).await?;
|
||||
|
||||
Ok(cred)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::admin::auth::authorize_admin_request;
|
||||
use crate::admin::auth::validate_admin_request;
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::admin::runtime_sources::{
|
||||
current_deployment_id, current_endpoints_handle, current_federated_identity_service, current_iam_handle,
|
||||
@@ -41,10 +41,10 @@ use crate::admin::storage_api::contract::bucket::{
|
||||
use crate::admin::storage_api::error::Error as StorageError;
|
||||
use crate::admin::storage_api::runtime::ECStore;
|
||||
use crate::admin::utils::{encode_compatible_admin_payload, read_compatible_admin_body};
|
||||
use crate::auth::constant_time_eq;
|
||||
use crate::auth::{check_key_valid, constant_time_eq, get_session_token};
|
||||
use crate::config::get_config_snapshot;
|
||||
use crate::error::ApiError;
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use crate::server::{ADMIN_PREFIX, RemoteAddr};
|
||||
use crate::storage::storage_api::{
|
||||
delete_config_no_lock, lock_bucket_targets_metadata, read_config_no_lock, save_config_no_lock, with_config_object_read_lock,
|
||||
with_config_object_write_lock,
|
||||
@@ -916,7 +916,17 @@ async fn validate_site_replication_admin_request(
|
||||
req: &S3Request<Body>,
|
||||
action: AdminAction,
|
||||
) -> S3Result<rustfs_credentials::Credentials> {
|
||||
authorize_admin_request(req, vec![Action::AdminAction(action)]).await
|
||||
let Some(input_cred) = req.credentials.as_ref() else {
|
||||
return Err(s3_error!(InvalidRequest, "get cred failed"));
|
||||
};
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
||||
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
validate_admin_request(&req.headers, &cred, owner, false, vec![Action::AdminAction(action)], remote_addr).await?;
|
||||
|
||||
Ok(cred)
|
||||
}
|
||||
|
||||
fn reject_site_replicator_on_public_admin(cred: &rustfs_credentials::Credentials) -> S3Result<()> {
|
||||
@@ -5922,18 +5932,15 @@ fn peer_edit_fence(queries: &HashMap<String, String>) -> Option<(String, u64)> {
|
||||
Some((origin.clone(), generation))
|
||||
}
|
||||
|
||||
/// True when a strictly newer edit from the same origin site already landed
|
||||
/// here. The process mutex on the sending node cannot order deliveries issued
|
||||
/// by two nodes of that site, so ordering is decided here, on the generation
|
||||
/// the sender allocated under the distributed lock. Equal generations are NOT
|
||||
/// stale: one edit legitimately fans out several deliveries under a single
|
||||
/// generation (the ILM-expiry edit sends every peer's record), and a replay of
|
||||
/// an applied delivery re-applies the same edit idempotently.
|
||||
/// True when a newer edit from the same origin site already landed here. The
|
||||
/// process mutex on the sending node cannot order deliveries issued by two
|
||||
/// nodes of that site, so ordering is decided here, on the generation the
|
||||
/// sender allocated under the distributed lock.
|
||||
fn peer_edit_delivery_is_stale(state: &SiteReplicationState, origin: &str, generation: u64) -> bool {
|
||||
state
|
||||
.applied_edit_generations
|
||||
.get(origin)
|
||||
.is_some_and(|applied| *applied > generation)
|
||||
.is_some_and(|applied| *applied >= generation)
|
||||
}
|
||||
|
||||
fn record_applied_peer_edit_generation(state: &mut SiteReplicationState, origin: &str, generation: u64) {
|
||||
@@ -12776,11 +12783,8 @@ mod tests {
|
||||
|
||||
// The delivery that lost the race carries the older generation.
|
||||
assert!(peer_edit_delivery_is_stale(&state, "origin-site", 6));
|
||||
// The generation already applied is NOT stale: one edit fans out one
|
||||
// delivery per peer record under a single generation (the ILM-expiry
|
||||
// edit), so an equal-generation delivery is the same edit's next body
|
||||
// (or an idempotent replay) and must apply.
|
||||
assert!(!peer_edit_delivery_is_stale(&state, "origin-site", 7));
|
||||
// A replay of the generation already applied is stale too.
|
||||
assert!(peer_edit_delivery_is_stale(&state, "origin-site", 7));
|
||||
// The next edit from that origin still applies...
|
||||
assert!(!peer_edit_delivery_is_stale(&state, "origin-site", 8));
|
||||
// ...and another origin site is ordered independently.
|
||||
@@ -12794,65 +12798,6 @@ mod tests {
|
||||
assert!(peer_edit_fence(&HashMap::new()).is_none());
|
||||
}
|
||||
|
||||
/// One edit fans out one delivery per peer record under a single
|
||||
/// generation (the ILM-expiry edit sends every peer's record). The
|
||||
/// receiver's fenced sequence — staleness check, apply, raise the
|
||||
/// high-water mark — must therefore accept every body of that fan-out,
|
||||
/// not just the first, while a strictly older delivery stays rejected.
|
||||
#[test]
|
||||
fn peer_edit_fence_admits_every_body_of_one_edits_fan_out() {
|
||||
let local = PeerInfo {
|
||||
deployment_id: "site-a".to_string(),
|
||||
..peer("site-a", "https://site-a.example.com")
|
||||
};
|
||||
let mut state = SiteReplicationState {
|
||||
peers: BTreeMap::from([
|
||||
("site-a".to_string(), local.clone()),
|
||||
(
|
||||
"site-b".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "site-b".to_string(),
|
||||
..peer("site-b", "https://site-b.example.com")
|
||||
},
|
||||
),
|
||||
(
|
||||
"site-c".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "site-c".to_string(),
|
||||
..peer("site-c", "https://site-c.example.com")
|
||||
},
|
||||
),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
let origin = "origin-site";
|
||||
let generation = 2;
|
||||
|
||||
let bodies: Vec<PeerInfo> = state
|
||||
.peers
|
||||
.values()
|
||||
.map(|peer| PeerInfo {
|
||||
replicate_ilm_expiry: true,
|
||||
..peer.clone()
|
||||
})
|
||||
.collect();
|
||||
for body in bodies {
|
||||
assert!(
|
||||
!peer_edit_delivery_is_stale(&state, origin, generation),
|
||||
"a same-generation fan-out body must not be fenced out"
|
||||
);
|
||||
state = apply_internal_peer_edit(state, &local, body, None).expect("fan-out body applies");
|
||||
record_applied_peer_edit_generation(&mut state, origin, generation);
|
||||
}
|
||||
|
||||
assert!(
|
||||
state.peers.values().all(|peer| peer.replicate_ilm_expiry),
|
||||
"every peer record from the fan-out must be applied: {:?}",
|
||||
state.peers
|
||||
);
|
||||
assert!(peer_edit_delivery_is_stale(&state, origin, generation - 1));
|
||||
}
|
||||
|
||||
/// P1-15 review follow-up: a site that leaves the mesh drops below two
|
||||
/// peers, which clears its state object and restarts its generation
|
||||
/// counter at zero. A mark left over from its previous membership would
|
||||
|
||||
@@ -38,10 +38,10 @@ use rustfs_utils::egress::OutboundPolicy;
|
||||
use s3s::{Body, S3Response, S3Result, header::CONTENT_TYPE, s3_error};
|
||||
use serde::Serialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::ErrorKind;
|
||||
use std::io::{Error, ErrorKind};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::time::{Duration, timeout};
|
||||
use tokio::time::{Duration, sleep, timeout};
|
||||
use url::Url;
|
||||
|
||||
pub(crate) type EndpointKey = (String, String);
|
||||
@@ -535,11 +535,10 @@ pub(crate) async fn validate_queue_dir(queue_dir: &str) -> S3Result<()> {
|
||||
if !Path::new(queue_dir).is_absolute() {
|
||||
return Err(s3_error!(InvalidArgument, "queue_dir must be an absolute path"));
|
||||
}
|
||||
rustfs_utils::retry::retry_with_backoff(
|
||||
retry_with_backoff(
|
||||
|| async { tokio::fs::metadata(queue_dir).await.map(|_| ()) },
|
||||
3,
|
||||
Duration::from_millis(100),
|
||||
rustfs_utils::retry::DEFAULT_RETRY_CAP,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| match e.kind() {
|
||||
@@ -666,6 +665,31 @@ fn collect_endpoint_snapshot(specs: &[AdminTargetSpec], route_prefix: &str, conf
|
||||
})
|
||||
}
|
||||
|
||||
async fn retry_with_backoff<F, Fut, T>(mut operation: F, max_attempts: usize, base_delay: Duration) -> Result<T, Error>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<T, Error>>,
|
||||
{
|
||||
let mut attempts = 0;
|
||||
let mut delay = base_delay;
|
||||
let mut last_err = None;
|
||||
|
||||
while attempts < max_attempts {
|
||||
match operation().await {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) => {
|
||||
last_err = Some(e);
|
||||
attempts += 1;
|
||||
if attempts < max_attempts {
|
||||
sleep(delay).await;
|
||||
delay = delay.saturating_mul(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| Error::other("retry_with_backoff: unknown error")))
|
||||
}
|
||||
|
||||
async fn validate_webhook_request(kv_map: &HashMap<String, String>) -> S3Result<()> {
|
||||
let endpoint = kv_map
|
||||
.get("endpoint")
|
||||
|
||||
@@ -197,10 +197,10 @@ pub(crate) mod lifecycle {
|
||||
pub(crate) use super::ecstore_bucket::lifecycle::manual_transition_job::{
|
||||
ManualTransitionJobRecord, ManualTransitionJobState, ManualTransitionScopeAdmission, ManualTransitionScopeAdmissionClaim,
|
||||
claim_manual_transition_scope_admission, delete_manual_transition_scope_admission_if_current,
|
||||
load_manual_transition_job_record, load_manual_transition_scope_admission, manual_transition_job_lease_expired,
|
||||
manual_transition_scope_admission_lease_expired, persist_manual_transition_job_progress_if_owned,
|
||||
renew_manual_transition_job_lease_if_owned, request_manual_transition_job_cancel, save_manual_transition_job_record,
|
||||
update_manual_transition_job_record,
|
||||
load_manual_transition_job_record, load_manual_transition_job_record_with_etag, load_manual_transition_scope_admission,
|
||||
manual_transition_job_lease_expired, manual_transition_scope_admission_lease_expired,
|
||||
persist_manual_transition_job_progress, renew_manual_transition_job_lease, request_manual_transition_job_cancel,
|
||||
save_manual_transition_job_record, save_manual_transition_job_record_if_current,
|
||||
};
|
||||
pub(crate) type ManualTransitionCancelCheck =
|
||||
super::ecstore_bucket::lifecycle::bucket_lifecycle_ops::ManualTransitionCancelCheck;
|
||||
|
||||
@@ -32,8 +32,7 @@ use crate::cluster_snapshot::{
|
||||
collect_cluster_read_only_snapshot,
|
||||
};
|
||||
use crate::error::ApiError;
|
||||
use crate::server::collect_dependency_readiness_report as collect_runtime_dependency_readiness_report;
|
||||
use crate::shared_types::DependencyReadiness;
|
||||
use crate::server::{DependencyReadiness, collect_dependency_readiness_report as collect_runtime_dependency_readiness_report};
|
||||
use rustfs_data_usage::DataUsageInfo;
|
||||
use rustfs_madmin::{InfoMessage, StorageInfo};
|
||||
use s3s::S3ErrorCode;
|
||||
|
||||
@@ -74,7 +74,7 @@ use crate::app::runtime_sources::{
|
||||
};
|
||||
use crate::auth::get_condition_values_with_client_info;
|
||||
use crate::error::ApiError;
|
||||
use crate::shared_types::RemoteAddr;
|
||||
use crate::server::RemoteAddr;
|
||||
use crate::storage::storage_api::lock_bucket_targets_metadata;
|
||||
use http::StatusCode;
|
||||
use metrics::counter;
|
||||
@@ -738,22 +738,6 @@ async fn validate_bucket_versioning_update(bucket: &str, config: &VersioningConf
|
||||
Err(StorageError::ConfigNotFound) => {}
|
||||
Err(err) => return Err(ApiError::from(err).into()),
|
||||
}
|
||||
// AWS S3 and MinIO both refuse to suspend versioning while a replication
|
||||
// configuration exists: suspension would start minting null versions that
|
||||
// the replication engine (versioned by contract) can never converge.
|
||||
if config.suspended() {
|
||||
match metadata_sys::get_replication_config(bucket).await {
|
||||
Ok(_) => {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidBucketState,
|
||||
"A replication configuration is present on this bucket, bucket wide versioning cannot be suspended."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Err(StorageError::ConfigNotFound) => {}
|
||||
Err(err) => return Err(ApiError::from(err).into()),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2332,6 +2332,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
async fn execute_list_multipart_uploads_returns_internal_error_when_store_uninitialized() {
|
||||
let input = ListMultipartUploadsInput::builder()
|
||||
.bucket("bucket".to_string())
|
||||
@@ -2374,6 +2375,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
async fn execute_list_parts_returns_internal_error_when_store_uninitialized() {
|
||||
let input = ListPartsInput::builder()
|
||||
.bucket("bucket".to_string())
|
||||
@@ -2420,6 +2422,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
async fn execute_upload_part_copy_returns_internal_error_when_store_uninitialized() {
|
||||
let input = UploadPartCopyInput::builder()
|
||||
.bucket("bucket".to_string())
|
||||
|
||||
@@ -113,7 +113,7 @@ use crate::app::runtime_sources::{
|
||||
use crate::config::RustFSBufferConfig;
|
||||
use crate::delete_tail_activity::{DeleteTailActivityGuard, DeleteTailStage};
|
||||
use crate::error::ApiError;
|
||||
use crate::shared_types::convert_ecstore_object_info;
|
||||
use crate::server::convert_ecstore_object_info;
|
||||
use crate::table_catalog;
|
||||
use bytes::{Bytes, BytesMut};
|
||||
use futures::{Stream, StreamExt, TryStreamExt};
|
||||
@@ -162,9 +162,8 @@ use s3s::dto::{
|
||||
GetObjectInput, GetObjectOutput, HeadObjectInput, HeadObjectOutput, MetadataDirective, ObjectAttributes, ObjectLockLegalHold,
|
||||
ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetention, ObjectLockRetentionMode, ObjectPart, PutObjectInput,
|
||||
PutObjectOutput, Range, RequestCharged, RestoreObjectInput, RestoreObjectOutput, RestoreStatus, SSECustomerAlgorithm,
|
||||
SSECustomerKeyMD5, SSEKMSKeyId, SelectObjectContentInput, SelectObjectContentOutput, ServerSideEncryption,
|
||||
ServerSideEncryptionByDefault, StorageClass, StreamingBlob, TaggingDirective, TaggingHeader, Timestamp, TimestampFormat,
|
||||
WebsiteRedirectLocation,
|
||||
SSECustomerKeyMD5, SSEKMSKeyId, SelectObjectContentInput, SelectObjectContentOutput, ServerSideEncryption, StorageClass,
|
||||
StreamingBlob, TaggingDirective, TaggingHeader, Timestamp, TimestampFormat, WebsiteRedirectLocation,
|
||||
};
|
||||
use s3s::header::{X_AMZ_RESTORE, X_AMZ_RESTORE_OUTPUT_PATH};
|
||||
use s3s::stream::{ByteStream, DynByteStream, RemainingLength};
|
||||
@@ -2045,18 +2044,6 @@ struct GetObjectResumeContext {
|
||||
identity: GetObjectResumeIdentity,
|
||||
}
|
||||
|
||||
fn get_object_store_headers(request_headers: &HeaderMap) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
for name in [SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER] {
|
||||
if let Some(value) = request_headers.get(name) {
|
||||
let mut value = value.clone();
|
||||
value.set_sensitive(true);
|
||||
headers.insert(name, value);
|
||||
}
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
impl GetObjectResumeContext {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
@@ -2074,9 +2061,17 @@ impl GetObjectResumeContext {
|
||||
{
|
||||
opts.version_id = Some(version_id.to_string());
|
||||
}
|
||||
// Store spans record their header argument at debug level. Retain only
|
||||
// the SSE-C inputs needed to reopen the reader and keep them redacted.
|
||||
let ssec_headers = get_object_store_headers(request_headers);
|
||||
let mut ssec_headers = HeaderMap::new();
|
||||
for name in [SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER] {
|
||||
if let Some(value) = request_headers.get(name) {
|
||||
// The store's instrumented spans record the header argument at
|
||||
// debug level; mark the replayed values sensitive so the SSE-C
|
||||
// key is redacted there on every resume attempt.
|
||||
let mut value = value.clone();
|
||||
value.set_sensitive(true);
|
||||
ssec_headers.insert(name, value);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
store,
|
||||
bucket: bucket.to_string(),
|
||||
@@ -2569,25 +2564,6 @@ fn has_put_sse_request_headers(headers: &HeaderMap) -> bool {
|
||||
|| headers.get(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID).is_some()
|
||||
}
|
||||
|
||||
/// Managed SSE resolved from a bucket default encryption rule on the copy path.
|
||||
///
|
||||
/// Unknown algorithms fall back to AES256, the same total mapping as the PUT and
|
||||
/// extract paths and the storage-layer resolver (`prepare_sse_configuration`), which
|
||||
/// `sse_encryption` re-runs when it mints the destination DEK. Resolving `None` here
|
||||
/// instead lets a same-name copy under a malformed bucket default pass the
|
||||
/// `copy_changes_encryption` guard and take the metadata-only shortcut while the
|
||||
/// storage layer still encrypts: fresh DEK metadata is committed beside the untouched
|
||||
/// plaintext blocks and the object becomes unreadable. Reachable only via corrupt or
|
||||
/// hand-edited bucket metadata — PutBucketEncryption rejects unknown algorithms
|
||||
/// (backlog#1826).
|
||||
fn bucket_default_write_sse(sse: &ServerSideEncryptionByDefault) -> ServerSideEncryption {
|
||||
match sse.sse_algorithm.as_str() {
|
||||
"AES256" => ServerSideEncryption::from_static(ServerSideEncryption::AES256),
|
||||
"aws:kms" => ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS),
|
||||
_ => ServerSideEncryption::from_static(ServerSideEncryption::AES256),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_use_small_eager_put_path(
|
||||
size: i64,
|
||||
headers: &HeaderMap,
|
||||
@@ -4479,7 +4455,6 @@ impl DefaultObjectUsecase {
|
||||
) -> S3Result<GetObjectPreparedRead> {
|
||||
let read_start = std::time::Instant::now();
|
||||
let read_stage_start = rustfs_io_metrics::get_stage_metrics_enabled().then_some(read_start);
|
||||
let store_headers = get_object_store_headers(&req.headers);
|
||||
let cache_adapter = self.object_data_cache();
|
||||
if cache_adapter.is_disabled() || !cache_adapter.materialize_fill_enabled() {
|
||||
let io_planning = Self::acquire_get_object_io_planning(
|
||||
@@ -4494,7 +4469,7 @@ impl DefaultObjectUsecase {
|
||||
.await?;
|
||||
let reader = track_object_read_setup(
|
||||
object_traffic_health.as_deref(),
|
||||
store.get_object_reader(bucket, key, rs.clone(), store_headers.clone(), opts),
|
||||
store.get_object_reader(bucket, key, rs.clone(), req.headers.clone(), opts),
|
||||
)
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?;
|
||||
@@ -4621,7 +4596,7 @@ impl DefaultObjectUsecase {
|
||||
drop(metadata_admission.take());
|
||||
let outcome = coordinate_cold_fill(&coordinator, cache_key, waiter_deadline, Some(proposed_producer_deadline), {
|
||||
let adapter = &cache_adapter;
|
||||
let headers = &store_headers;
|
||||
let headers = &req.headers;
|
||||
let store = &store;
|
||||
let range = &rs;
|
||||
let object_traffic_health = &object_traffic_health;
|
||||
@@ -4785,7 +4760,7 @@ impl DefaultObjectUsecase {
|
||||
.ok_or_else(|| s3_error!(InternalError, "prepared metadata admission is unavailable"))?;
|
||||
let reader = track_object_read_setup(
|
||||
object_traffic_health.as_deref(),
|
||||
prepared.with_headers(store_headers.clone()).into_reader(),
|
||||
prepared.with_headers(req.headers.clone()).into_reader(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?;
|
||||
@@ -4810,14 +4785,14 @@ impl DefaultObjectUsecase {
|
||||
.map_err(map_get_object_reader_error)?;
|
||||
track_object_read_setup(
|
||||
object_traffic_health.as_deref(),
|
||||
prepared.with_headers(store_headers.clone()).into_reader(),
|
||||
prepared.with_headers(req.headers.clone()).into_reader(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?
|
||||
} else {
|
||||
track_object_read_setup(
|
||||
object_traffic_health.as_deref(),
|
||||
store.get_object_reader(bucket, key, rs.clone(), store_headers, opts),
|
||||
store.get_object_reader(bucket, key, rs.clone(), req.headers.clone(), opts),
|
||||
)
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?
|
||||
@@ -7201,7 +7176,11 @@ impl DefaultObjectUsecase {
|
||||
config.rules.first().and_then(|rule| {
|
||||
rule.apply_server_side_encryption_by_default
|
||||
.as_ref()
|
||||
.map(bucket_default_write_sse)
|
||||
.and_then(|sse| match sse.sse_algorithm.as_str() {
|
||||
"AES256" => Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)),
|
||||
"aws:kms" => Some(ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS)),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
@@ -9591,8 +9570,7 @@ mod tests {
|
||||
DefaultRetention, Delete, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication,
|
||||
DeleteReplicationStatus, Destination, ExistingObjectReplication, ExistingObjectReplicationStatus, ObjectIdentifier,
|
||||
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRule, ReplicaModifications, ReplicaModificationsStatus,
|
||||
ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus, RestoreRequest, ServerSideEncryptionConfiguration,
|
||||
ServerSideEncryptionRule, SourceSelectionCriteria,
|
||||
ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus, RestoreRequest, SourceSelectionCriteria,
|
||||
};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
@@ -9787,46 +9765,6 @@ mod tests {
|
||||
assert!(lookup_opts.no_lock);
|
||||
}
|
||||
|
||||
// A malformed bucket-default algorithm reaches this resolution only through
|
||||
// corrupt or hand-edited bucket metadata (PutBucketEncryption validates the
|
||||
// value), so the invariant is pinned here rather than end-to-end: the copy
|
||||
// path must resolve managed AES256 exactly like PUT/extract. With an
|
||||
// unencrypted same-name source and no SSE-C, the resolved default alone
|
||||
// keeps `copy_changes_encryption` true, so the metadata-only shortcut stays
|
||||
// off while `sse_encryption` mints a fresh DEK (backlog#1826).
|
||||
#[test]
|
||||
fn copy_bucket_default_unknown_sse_algorithm_falls_back_to_aes256() {
|
||||
let config = ServerSideEncryptionConfiguration {
|
||||
rules: vec![ServerSideEncryptionRule {
|
||||
apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault {
|
||||
sse_algorithm: ServerSideEncryption::from(String::from("garbage")),
|
||||
kms_master_key_id: None,
|
||||
}),
|
||||
bucket_key_enabled: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let effective_sse = config
|
||||
.rules
|
||||
.first()
|
||||
.and_then(|rule| rule.apply_server_side_encryption_by_default.as_ref())
|
||||
.map(bucket_default_write_sse);
|
||||
|
||||
assert_eq!(effective_sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AES256));
|
||||
|
||||
// Valid algorithms map to themselves, byte-identical to the PUT path.
|
||||
for (configured, expected) in [
|
||||
(ServerSideEncryption::AES256, ServerSideEncryption::AES256),
|
||||
(ServerSideEncryption::AWS_KMS, ServerSideEncryption::AWS_KMS),
|
||||
] {
|
||||
let sse = ServerSideEncryptionByDefault {
|
||||
sse_algorithm: ServerSideEncryption::from_static(configured),
|
||||
kms_master_key_id: None,
|
||||
};
|
||||
assert_eq!(bucket_default_write_sse(&sse).as_str(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_request_user_metadata_cannot_suppress_bucket_default_retention() {
|
||||
let mut metadata =
|
||||
@@ -13784,11 +13722,6 @@ mod tests {
|
||||
request_headers.insert(SSEC_KEY_MD5_HEADER, HeaderValue::from_static("bWQ1"));
|
||||
request_headers.insert(http::header::AUTHORIZATION, HeaderValue::from_static("AWS4-HMAC-SHA256 Credential=test"));
|
||||
request_headers.insert("x-amz-security-token", HeaderValue::from_static("session-token"));
|
||||
let store_headers = get_object_store_headers(&request_headers);
|
||||
assert_eq!(store_headers.len(), 3, "only store-consumed SSE-C headers are forwarded");
|
||||
assert!(store_headers.values().all(HeaderValue::is_sensitive));
|
||||
assert!(store_headers.get(http::header::AUTHORIZATION).is_none());
|
||||
assert!(store_headers.get("x-amz-security-token").is_none());
|
||||
let plain_info = ObjectInfo {
|
||||
size: 11,
|
||||
..Default::default()
|
||||
@@ -16668,6 +16601,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
async fn execute_get_object_attributes_returns_internal_error_when_store_uninitialized() {
|
||||
let input = GetObjectAttributesInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
@@ -16810,6 +16744,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
async fn execute_restore_object_returns_internal_error_when_store_uninitialized() {
|
||||
let restore_request = RestoreRequest {
|
||||
days: Some(1),
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::runtime_capabilities::runtime_observability_snapshot;
|
||||
use crate::server::snapshot_dependency_readiness_report;
|
||||
use crate::shared_types::{DependencyReadiness, DependencyReadinessReport, ReadinessDegradedReason};
|
||||
use crate::server::{
|
||||
DependencyReadiness, DependencyReadinessReport, ReadinessDegradedReason, snapshot_dependency_readiness_report,
|
||||
};
|
||||
use crate::storage_api::cluster::EndpointServerPools;
|
||||
use crate::storage_api::cluster::contract::observability::ObservabilitySnapshot;
|
||||
use crate::storage_api::cluster::contract::topology::TopologySnapshot;
|
||||
|
||||
@@ -94,7 +94,6 @@ pub mod protocols;
|
||||
pub mod runtime_capabilities;
|
||||
pub(crate) mod runtime_sources;
|
||||
pub mod server;
|
||||
pub mod shared_types;
|
||||
pub(crate) mod site_replication_reconcile;
|
||||
pub(crate) mod startup_audit;
|
||||
pub(crate) mod startup_auth;
|
||||
|
||||
@@ -18,10 +18,13 @@ use super::{
|
||||
};
|
||||
use crate::init::reconcile_persisted_bucket_notification_configurations;
|
||||
use crate::storage_api::server::event::{
|
||||
EventArgs as EcstoreEventArgs, read_existing_server_config_no_lock, register_event_dispatch_hook,
|
||||
EventArgs as EcstoreEventArgs, StorageObjectInfo, read_existing_server_config_no_lock, register_event_dispatch_hook,
|
||||
with_server_config_read_lock,
|
||||
};
|
||||
use rustfs_notify::{EventArgs as NotifyEventArgs, NotificationError, NotificationRuntimeState, NotificationSystem};
|
||||
use jiff::Timestamp;
|
||||
use rustfs_notify::{
|
||||
EventArgs as NotifyEventArgs, NotificationError, NotificationRuntimeState, NotificationSystem, NotifyObjectInfo,
|
||||
};
|
||||
use rustfs_s3_types::EventName;
|
||||
use std::future::Future;
|
||||
use std::net::SocketAddr;
|
||||
@@ -78,7 +81,30 @@ pub fn is_notify_module_enabled() -> bool {
|
||||
NOTIFY_MODULE_ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub(crate) use crate::shared_types::convert_ecstore_object_info;
|
||||
pub(crate) fn convert_ecstore_object_info(object: StorageObjectInfo) -> NotifyObjectInfo {
|
||||
NotifyObjectInfo {
|
||||
bucket: object.bucket,
|
||||
name: object.name,
|
||||
size: object.size,
|
||||
etag: object.etag,
|
||||
content_type: object.content_type,
|
||||
user_defined: object
|
||||
.user_defined
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect(),
|
||||
version_id: object.version_id.map(|version_id| version_id.to_string()),
|
||||
mod_time: object.mod_time.and_then(offset_date_time_to_timestamp),
|
||||
restore_expires: object.restore_expires.and_then(offset_date_time_to_timestamp),
|
||||
storage_class: object.storage_class,
|
||||
transitioned_tier: (!object.transitioned_object.tier.is_empty()).then_some(object.transitioned_object.tier),
|
||||
}
|
||||
}
|
||||
|
||||
fn offset_date_time_to_timestamp(value: time::OffsetDateTime) -> Option<Timestamp> {
|
||||
let nanosecond = value.nanosecond().try_into().ok()?;
|
||||
Timestamp::new(value.unix_timestamp(), nanosecond).ok()
|
||||
}
|
||||
|
||||
fn convert_ecstore_event_args(args: EcstoreEventArgs) -> Option<NotifyEventArgs> {
|
||||
let version_id = args.object.version_id.map(|v| v.to_string()).unwrap_or_default();
|
||||
|
||||
@@ -45,6 +45,7 @@ pub use service_state::ShutdownSignal;
|
||||
pub use service_state::wait_for_shutdown;
|
||||
|
||||
// Items only used within the library crate (admin handlers, server/http.rs, etc.).
|
||||
pub(crate) use event::convert_ecstore_object_info;
|
||||
pub(crate) use event::{
|
||||
is_event_notifier_reconciled, mark_event_notifier_reconciled, mark_event_notifier_unreconciled,
|
||||
reconcile_event_notifier_from_store, start_persisted_event_notifier_reconciler,
|
||||
@@ -73,6 +74,8 @@ pub(crate) use prefix::{
|
||||
PROFILE_MEMORY_PATH, RPC_PREFIX, RUSTFS_ADMIN_PREFIX, TABLE_CATALOG_COMPAT_PREFIX, TABLE_CATALOG_PREFIX, TONIC_PREFIX,
|
||||
VERSION, has_path_prefix, is_admin_path, is_table_catalog_path,
|
||||
};
|
||||
pub(crate) use readiness::DependencyReadiness;
|
||||
pub(crate) use readiness::DependencyReadinessReport;
|
||||
pub(crate) use readiness::ReadinessDegradedReason;
|
||||
pub(crate) use readiness::ReadinessGateLayer;
|
||||
pub(crate) use readiness::collect_dependency_readiness_report;
|
||||
@@ -81,7 +84,8 @@ pub use readiness::publish_ready_when_runtime_ready;
|
||||
pub(crate) use readiness::snapshot_dependency_readiness_report;
|
||||
pub(crate) use readiness::{collect_cluster_read_health_report, collect_cluster_write_health_report};
|
||||
|
||||
pub use crate::shared_types::RemoteAddr;
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct RemoteAddr(pub std::net::SocketAddr);
|
||||
|
||||
pub struct ShutdownHandle {
|
||||
shutdown_tx: Option<tokio::sync::broadcast::Sender<()>>,
|
||||
|
||||
@@ -74,7 +74,54 @@ fn startup_runtime_readiness_max_wait() -> Duration {
|
||||
const METRIC_RUNTIME_READINESS_READY: &str = "rustfs_runtime_readiness_ready";
|
||||
const METRIC_RUNTIME_READINESS_DEGRADED_TOTAL: &str = "rustfs_runtime_readiness_degraded_total";
|
||||
|
||||
pub use crate::shared_types::{DependencyReadiness, DependencyReadinessReport, ReadinessDegradedReason};
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct DependencyReadiness {
|
||||
pub storage_ready: bool,
|
||||
pub iam_ready: bool,
|
||||
pub lock_quorum_ready: bool,
|
||||
pub peer_health_ready: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReadinessDegradedReason {
|
||||
StorageQuorumUnavailable,
|
||||
IamNotReady,
|
||||
LockQuorumUnavailable,
|
||||
KmsNotReady,
|
||||
ObjectReadStalled,
|
||||
ObjectWriteStalled,
|
||||
ClusterHealthTimeout,
|
||||
PeerHealthUnavailable,
|
||||
StorageAndIamUnavailable,
|
||||
StorageAndLockUnavailable,
|
||||
IamAndLockUnavailable,
|
||||
StorageIamAndLockUnavailable,
|
||||
}
|
||||
|
||||
impl ReadinessDegradedReason {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ReadinessDegradedReason::StorageQuorumUnavailable => "storage_quorum_unavailable",
|
||||
ReadinessDegradedReason::IamNotReady => "iam_not_ready",
|
||||
ReadinessDegradedReason::LockQuorumUnavailable => "lock_quorum_unavailable",
|
||||
ReadinessDegradedReason::KmsNotReady => "kms_not_ready",
|
||||
ReadinessDegradedReason::ObjectReadStalled => "object_read_stalled",
|
||||
ReadinessDegradedReason::ObjectWriteStalled => "object_write_stalled",
|
||||
ReadinessDegradedReason::ClusterHealthTimeout => "cluster_health_timeout",
|
||||
ReadinessDegradedReason::PeerHealthUnavailable => "peer_health_unavailable",
|
||||
ReadinessDegradedReason::StorageAndIamUnavailable => "storage_and_iam_unavailable",
|
||||
ReadinessDegradedReason::StorageAndLockUnavailable => "storage_and_lock_unavailable",
|
||||
ReadinessDegradedReason::IamAndLockUnavailable => "iam_and_lock_unavailable",
|
||||
ReadinessDegradedReason::StorageIamAndLockUnavailable => "storage_iam_and_lock_unavailable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DependencyReadinessReport {
|
||||
pub readiness: DependencyReadiness,
|
||||
pub degraded_reasons: Vec<ReadinessDegradedReason>,
|
||||
}
|
||||
|
||||
/// ReadinessGateLayer ensures that the system components (IAM, Storage)
|
||||
/// are fully initialized before allowing any request to proceed.
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
// 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.
|
||||
|
||||
//! Layer-neutral shared types (backlog#1834).
|
||||
//!
|
||||
//! These types are consumed across the app, infra, and interface layers but
|
||||
//! used to live under `server`, so every lower-layer import was an upward
|
||||
//! edge that had to be baselined by the layer-dependency guard. `server`
|
||||
//! re-exports them for its own consumers; new code should import from here.
|
||||
|
||||
use crate::storage_api::server::event::StorageObjectInfo;
|
||||
use jiff::Timestamp;
|
||||
use rustfs_notify::NotifyObjectInfo;
|
||||
|
||||
/// Peer address of the current request, injected as a request extension.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct RemoteAddr(pub std::net::SocketAddr);
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct DependencyReadiness {
|
||||
pub storage_ready: bool,
|
||||
pub iam_ready: bool,
|
||||
pub lock_quorum_ready: bool,
|
||||
pub peer_health_ready: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReadinessDegradedReason {
|
||||
StorageQuorumUnavailable,
|
||||
IamNotReady,
|
||||
LockQuorumUnavailable,
|
||||
KmsNotReady,
|
||||
ObjectReadStalled,
|
||||
ObjectWriteStalled,
|
||||
ClusterHealthTimeout,
|
||||
PeerHealthUnavailable,
|
||||
StorageAndIamUnavailable,
|
||||
StorageAndLockUnavailable,
|
||||
IamAndLockUnavailable,
|
||||
StorageIamAndLockUnavailable,
|
||||
}
|
||||
|
||||
impl ReadinessDegradedReason {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ReadinessDegradedReason::StorageQuorumUnavailable => "storage_quorum_unavailable",
|
||||
ReadinessDegradedReason::IamNotReady => "iam_not_ready",
|
||||
ReadinessDegradedReason::LockQuorumUnavailable => "lock_quorum_unavailable",
|
||||
ReadinessDegradedReason::KmsNotReady => "kms_not_ready",
|
||||
ReadinessDegradedReason::ObjectReadStalled => "object_read_stalled",
|
||||
ReadinessDegradedReason::ObjectWriteStalled => "object_write_stalled",
|
||||
ReadinessDegradedReason::ClusterHealthTimeout => "cluster_health_timeout",
|
||||
ReadinessDegradedReason::PeerHealthUnavailable => "peer_health_unavailable",
|
||||
ReadinessDegradedReason::StorageAndIamUnavailable => "storage_and_iam_unavailable",
|
||||
ReadinessDegradedReason::StorageAndLockUnavailable => "storage_and_lock_unavailable",
|
||||
ReadinessDegradedReason::IamAndLockUnavailable => "iam_and_lock_unavailable",
|
||||
ReadinessDegradedReason::StorageIamAndLockUnavailable => "storage_iam_and_lock_unavailable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DependencyReadinessReport {
|
||||
pub readiness: DependencyReadiness,
|
||||
pub degraded_reasons: Vec<ReadinessDegradedReason>,
|
||||
}
|
||||
|
||||
pub(crate) fn convert_ecstore_object_info(object: StorageObjectInfo) -> NotifyObjectInfo {
|
||||
NotifyObjectInfo {
|
||||
bucket: object.bucket,
|
||||
name: object.name,
|
||||
size: object.size,
|
||||
etag: object.etag,
|
||||
content_type: object.content_type,
|
||||
user_defined: object
|
||||
.user_defined
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect(),
|
||||
version_id: object.version_id.map(|version_id| version_id.to_string()),
|
||||
mod_time: object.mod_time.and_then(offset_date_time_to_timestamp),
|
||||
restore_expires: object.restore_expires.and_then(offset_date_time_to_timestamp),
|
||||
storage_class: object.storage_class,
|
||||
transitioned_tier: (!object.transitioned_object.tier.is_empty()).then_some(object.transitioned_object.tier),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn offset_date_time_to_timestamp(value: time::OffsetDateTime) -> Option<Timestamp> {
|
||||
let nanosecond = value.nanosecond().try_into().ok()?;
|
||||
Timestamp::new(value.unix_timestamp(), nanosecond).ok()
|
||||
}
|
||||
@@ -21,7 +21,7 @@ use crate::auth::{
|
||||
};
|
||||
use crate::error::ApiError;
|
||||
use crate::license::license_check;
|
||||
use crate::shared_types::RemoteAddr;
|
||||
use crate::server::RemoteAddr;
|
||||
use crate::storage::request_context::RequestContext;
|
||||
use crate::storage::storage_api::contract::bucket::BUCKET_LIFECYCLE_LOCK_OBJECT;
|
||||
use crate::storage::storage_api::contract::namespace::NamespaceLocking as _;
|
||||
@@ -3452,6 +3452,35 @@ mod tests {
|
||||
assert_eq!(conditions.get("delimiter"), Some(&vec!["/".to_string()]));
|
||||
}
|
||||
|
||||
/// When policy metadata cannot be loaded, tag-based check is conservative (returns true).
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
async fn test_bucket_policy_needs_existing_object_tag_load_failure_is_conservative() {
|
||||
let conditions = HashMap::new();
|
||||
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
let hint = load_bucket_policy_existing_object_tag_hint(
|
||||
store.as_ref(),
|
||||
"test-bucket-no-policy-xyz-absent",
|
||||
Action::S3Action(S3Action::GetObjectAction),
|
||||
)
|
||||
.await;
|
||||
let no_groups: Option<Vec<String>> = None;
|
||||
let args = BucketPolicyArgs {
|
||||
bucket: "test-bucket-no-policy-xyz-absent",
|
||||
action: Action::S3Action(S3Action::GetObjectAction),
|
||||
is_owner: false,
|
||||
account: "",
|
||||
groups: &no_groups,
|
||||
conditions: &conditions,
|
||||
object: "obj",
|
||||
};
|
||||
let result = bucket_policy_needs_existing_object_tag_from_hint(&hint, &args).await;
|
||||
assert!(
|
||||
result,
|
||||
"when policy metadata cannot be loaded, ExistingObjectTag should be fetched conservatively"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bucket_policy_existing_object_tag_condition_key_detection() {
|
||||
let condition_key_policy = r#"{
|
||||
|
||||
@@ -765,44 +765,76 @@ where
|
||||
|
||||
/// Bucket validation cache to avoid repeated stat_volume() calls on every GET.
|
||||
///
|
||||
/// Backend: `RwLock<HashMap>`. A parallel opt-in starshard backend
|
||||
/// (`RUSTFS_BUCKET_CACHE_STARSHARD`) used to double-write every operation
|
||||
/// here; no deployment ever set the variable and the branch was removed in
|
||||
/// backlog#1832.
|
||||
/// **Adaptive strategy** (selected once at startup via env var):
|
||||
///
|
||||
/// | Backend | Env var | Best for |
|
||||
/// |---------|---------|----------|
|
||||
/// | `RwLock<HashMap>` | default | < 100 buckets — lower per-op overhead |
|
||||
/// | `starshard::ShardedHashMap` | `RUSTFS_BUCKET_CACHE_STARSHARD=1` | >= 100 buckets — sharded locks reduce contention |
|
||||
///
|
||||
/// Entries expire after `BUCKET_VALIDATION_TTL` (checked on read).
|
||||
/// Write operations (delete/make bucket) invalidate the cache explicitly.
|
||||
const BUCKET_VALIDATION_TTL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Tracks which backend is active: `false` = HashMap, `true` = starshard.
|
||||
static USE_STARSHARD_CACHE: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
fn use_starshard() -> bool {
|
||||
*USE_STARSHARD_CACHE.get_or_init(|| {
|
||||
std::env::var("RUSTFS_BUCKET_CACHE_STARSHARD")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<bool>().ok())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// --- HashMap backend (default) ---
|
||||
static BUCKET_CACHE_SMALL: OnceLock<RwLock<HashMap<String, Instant>>> = OnceLock::new();
|
||||
|
||||
fn small_cache() -> &'static RwLock<HashMap<String, Instant>> {
|
||||
BUCKET_CACHE_SMALL.get_or_init(|| RwLock::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Get a value from the cache.
|
||||
fn cache_get(bucket: &str) -> Option<Instant> {
|
||||
small_cache().read().ok()?.get(bucket).copied()
|
||||
/// --- starshard backend (opt-in) ---
|
||||
static BUCKET_CACHE_LARGE: OnceLock<starshard::ShardedHashMap<String, Instant>> = OnceLock::new();
|
||||
|
||||
fn large_cache() -> &'static starshard::ShardedHashMap<String, Instant> {
|
||||
BUCKET_CACHE_LARGE.get_or_init(|| starshard::ShardedHashMap::new(128))
|
||||
}
|
||||
|
||||
/// Insert a value into the cache.
|
||||
/// Get a value from the active cache backend.
|
||||
fn cache_get(bucket: &str) -> Option<Instant> {
|
||||
if use_starshard() {
|
||||
large_cache().get(&bucket.to_string())
|
||||
} else {
|
||||
small_cache().read().ok()?.get(bucket).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a value into the active cache backend.
|
||||
fn cache_insert(bucket: String, ts: Instant) {
|
||||
if let Ok(mut map) = small_cache().write() {
|
||||
if use_starshard() {
|
||||
large_cache().insert(bucket, ts);
|
||||
} else if let Ok(mut map) = small_cache().write() {
|
||||
map.insert(bucket, ts);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a value from the cache.
|
||||
/// Remove a value from the active cache backend.
|
||||
fn cache_remove(bucket: &str) {
|
||||
if let Ok(mut map) = small_cache().write() {
|
||||
if use_starshard() {
|
||||
large_cache().remove(&bucket.to_string());
|
||||
} else if let Ok(mut map) = small_cache().write() {
|
||||
map.remove(bucket);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all entries in the cache.
|
||||
/// Clear all entries in the active cache backend.
|
||||
#[allow(dead_code)]
|
||||
fn cache_clear() {
|
||||
if let Ok(mut map) = small_cache().write() {
|
||||
if use_starshard() {
|
||||
large_cache().clear();
|
||||
} else if let Ok(mut map) = small_cache().write() {
|
||||
map.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::server::{is_audit_module_enabled, is_notify_module_enabled};
|
||||
use crate::shared_types::convert_ecstore_object_info;
|
||||
use crate::server::{convert_ecstore_object_info, is_audit_module_enabled, is_notify_module_enabled};
|
||||
use crate::storage::access::{ReqInfo, request_context_from_req};
|
||||
use crate::storage::request_context::RequestContext;
|
||||
use crate::storage::sse::KmsRequestAuditScope;
|
||||
|
||||
@@ -4492,27 +4492,9 @@ mod tests {
|
||||
assert!(refresh_response.error_info.is_some());
|
||||
}
|
||||
|
||||
/// Premise guard for the no-object-layer RPC tests (backlog#1830): they
|
||||
/// assert the error surface returned while the global object layer is
|
||||
/// absent. Under nextest — the authoritative runner — every test owns its
|
||||
/// process, so the premise always holds and the assertion always runs.
|
||||
/// Under the documented shared-process `cargo test` fallback a sibling test
|
||||
/// may have initialized the store first; the premise is then unattainable,
|
||||
/// so the test skips instead of asserting against a scenario it does not
|
||||
/// describe.
|
||||
fn no_object_layer_premise_holds() -> bool {
|
||||
if crate::runtime_sources::current_object_store_handle().is_some() {
|
||||
eprintln!("skipping no-object-layer assertion: a sibling test already initialized the global object layer");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
async fn test_local_storage_info() {
|
||||
if !no_object_layer_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let service = create_test_node_service();
|
||||
|
||||
let request = Request::new(LocalStorageInfoRequest { metrics: false });
|
||||
@@ -4817,10 +4799,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
async fn test_reload_pool_meta() {
|
||||
if !no_object_layer_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let service = create_test_node_service();
|
||||
|
||||
let request = Request::new(ReloadPoolMetaRequest {});
|
||||
@@ -4835,10 +4815,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
async fn test_stop_rebalance() {
|
||||
if !no_object_layer_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let service = create_test_node_service();
|
||||
|
||||
let request = Request::new(StopRebalanceRequest {
|
||||
@@ -4855,10 +4833,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
async fn test_load_rebalance_meta() {
|
||||
if !no_object_layer_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let service = create_test_node_service();
|
||||
|
||||
let request = Request::new(LoadRebalanceMetaRequest { start_rebalance: false });
|
||||
@@ -4953,10 +4929,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
async fn test_load_bucket_metadata_no_object_layer() {
|
||||
if !no_object_layer_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let service = create_test_node_service();
|
||||
|
||||
let request = Request::new(LoadBucketMetadataRequest {
|
||||
@@ -4974,10 +4948,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
async fn test_load_transition_tier_config_no_object_layer() {
|
||||
if !no_object_layer_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let service = create_test_node_service();
|
||||
|
||||
let response = service
|
||||
@@ -5197,10 +5169,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
async fn test_reload_site_replication_config() {
|
||||
if !no_object_layer_premise_holds() {
|
||||
return;
|
||||
}
|
||||
let service = create_test_node_service();
|
||||
|
||||
let request = Request::new(ReloadSiteReplicationConfigRequest {});
|
||||
@@ -5660,6 +5630,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
#[serial_test::serial]
|
||||
async fn test_signal_service_refresh_config_requires_object_layer() {
|
||||
let service = create_test_node_service();
|
||||
@@ -5681,6 +5652,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
#[serial_test::serial]
|
||||
async fn test_signal_service_reload_dynamic_requires_object_layer() {
|
||||
let service = create_test_node_service();
|
||||
|
||||
+11
-125
@@ -1052,13 +1052,7 @@ pub async fn authorize_sse_kms_object_read(
|
||||
// Only a denial is recorded here: an allowed read goes on to unwrap the key,
|
||||
// and that operation reports its own outcome.
|
||||
if let Err(error) = &result {
|
||||
record_managed_kms_outcome(
|
||||
principal,
|
||||
sse_type,
|
||||
Some(&key_id),
|
||||
|| stored_envelope_master_key_version(metadata),
|
||||
Err(error),
|
||||
);
|
||||
record_managed_kms_outcome(principal, sse_type, Some(&key_id), Err(error));
|
||||
}
|
||||
|
||||
result
|
||||
@@ -1296,53 +1290,22 @@ impl std::error::Error for KmsDataPlaneFailure {
|
||||
/// Record the outcome of one managed-SSE operation on the request's audit entry.
|
||||
///
|
||||
/// A `None` principal marks an internal caller — replication, lifecycle, heal —
|
||||
/// which has no S3 audit entry to attach to. `key_version` is a closure for
|
||||
/// exactly that caller: extracting the version means base64-decoding and
|
||||
/// parsing the stored envelope, work that must not run on the internal hot
|
||||
/// paths that discard it.
|
||||
/// which has no S3 audit entry to attach to.
|
||||
fn record_managed_kms_outcome(
|
||||
principal: Option<&SseKmsPrincipal>,
|
||||
sse_type: SSEType,
|
||||
key_id: Option<&str>,
|
||||
key_version: impl FnOnce() -> Option<u32>,
|
||||
result: Result<(), &ApiError>,
|
||||
) {
|
||||
let Some(audit) = principal.and_then(|principal| principal.request_audit.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
audit.record(sse_type, key_id, key_version(), result.err().map(kms_data_plane_error_class));
|
||||
}
|
||||
|
||||
/// Master-key version recorded in a managed-SSE data-key envelope, if the
|
||||
/// wrapping backend recorded one.
|
||||
///
|
||||
/// `None` is the honest answer for every other shape: Transit and AWS wrap
|
||||
/// into opaque ciphertext that is not an envelope, Local records no version,
|
||||
/// and pre-versioning envelopes never carried the field. The single field is
|
||||
/// read through `serde_json::Value` rather than a full `DataKeyEnvelope`
|
||||
/// parse, so the audit path cannot double-count the envelope's unknown-field
|
||||
/// observability and touches nothing else in the envelope.
|
||||
fn envelope_master_key_version(envelope_bytes: &[u8]) -> Option<u32> {
|
||||
if !is_data_key_envelope(envelope_bytes) {
|
||||
return None;
|
||||
}
|
||||
u32::try_from(
|
||||
serde_json::from_slice::<Value>(envelope_bytes)
|
||||
.ok()?
|
||||
.get("master_key_version")?
|
||||
.as_u64()?,
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Master-key version of the envelope stored on an object, for the audit
|
||||
/// summary of a read against that object.
|
||||
fn stored_envelope_master_key_version(metadata: &HashMap<String, String>) -> Option<u32> {
|
||||
let encoded = normalize_managed_metadata(metadata);
|
||||
let encoded = encoded.get(INTERNAL_ENCRYPTION_KEY_HEADER)?;
|
||||
let envelope = BASE64_STANDARD.decode(encoded).ok()?;
|
||||
envelope_master_key_version(&envelope)
|
||||
// The KMS key version is not observable on the data path: neither the
|
||||
// generated data key nor the stored envelope surfaces the master-key version
|
||||
// that wrapped it. Recording a fabricated version would be worse than
|
||||
// omitting the tag, so it stays absent until KMS reports it.
|
||||
audit.record(sse_type, key_id, None, result.err().map(kms_data_plane_error_class));
|
||||
}
|
||||
|
||||
pub(crate) struct SseObjectEncryptionResolver;
|
||||
@@ -2324,14 +2287,8 @@ async fn apply_managed_encryption_material(
|
||||
// The resolved key is only known on success: it may come from the request,
|
||||
// the bucket default or the KMS service default. On failure the audit entry
|
||||
// records what the caller asked for, which is what a reader needs to see.
|
||||
Ok(material) => record_managed_kms_outcome(
|
||||
principal,
|
||||
material.sse_type,
|
||||
material.kms_key_id.as_deref(),
|
||||
|| material.encrypted_data_key.as_deref().and_then(envelope_master_key_version),
|
||||
Ok(()),
|
||||
),
|
||||
Err(error) => record_managed_kms_outcome(principal, requested_sse_type, requested_key_id.as_deref(), || None, Err(error)),
|
||||
Ok(material) => record_managed_kms_outcome(principal, material.sse_type, material.kms_key_id.as_deref(), Ok(())),
|
||||
Err(error) => record_managed_kms_outcome(principal, requested_sse_type, requested_key_id.as_deref(), Err(error)),
|
||||
}
|
||||
|
||||
result
|
||||
@@ -2447,22 +2404,10 @@ async fn apply_managed_decryption_material(
|
||||
// `None` means the object carries no managed-SSE metadata — SSE-C and
|
||||
// plaintext objects never reach KMS and must not appear in the summary.
|
||||
Ok(None) => {}
|
||||
Ok(Some(material)) => record_managed_kms_outcome(
|
||||
principal,
|
||||
material.sse_type,
|
||||
material.kms_key_id.as_deref(),
|
||||
|| stored_envelope_master_key_version(metadata),
|
||||
Ok(()),
|
||||
),
|
||||
Ok(Some(material)) => record_managed_kms_outcome(principal, material.sse_type, material.kms_key_id.as_deref(), Ok(())),
|
||||
Err(error) => {
|
||||
if let Some((sse_type, key_id)) = stored_managed_encryption_key(metadata) {
|
||||
record_managed_kms_outcome(
|
||||
principal,
|
||||
sse_type,
|
||||
Some(&key_id),
|
||||
|| stored_envelope_master_key_version(metadata),
|
||||
Err(error),
|
||||
);
|
||||
record_managed_kms_outcome(principal, sse_type, Some(&key_id), Err(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6500,63 +6445,4 @@ mod tests {
|
||||
let scope = super::KmsRequestAuditScope::register("quiet-request");
|
||||
assert!(scope.audit_tags().is_empty());
|
||||
}
|
||||
|
||||
/// The canonical seven-field envelope, with `master_key_version` grafted on
|
||||
/// when a wrapping version is wanted.
|
||||
fn audit_test_envelope(master_key_version: Option<u32>) -> Vec<u8> {
|
||||
let mut envelope = serde_json::json!({
|
||||
"key_id": "test-key-id",
|
||||
"master_key_id": "master-key-id",
|
||||
"key_spec": "AES_256",
|
||||
"encrypted_key": [1, 2, 3, 4],
|
||||
"nonce": [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
|
||||
"encryption_context": {},
|
||||
"created_at": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
if let Some(version) = master_key_version {
|
||||
envelope
|
||||
.as_object_mut()
|
||||
.expect("envelope is an object")
|
||||
.insert("master_key_version".to_string(), serde_json::json!(version));
|
||||
}
|
||||
serde_json::to_vec(&envelope).expect("encode envelope")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_master_key_version_reads_only_true_envelopes() {
|
||||
// A versioned envelope reports the wrapping version.
|
||||
assert_eq!(super::envelope_master_key_version(&audit_test_envelope(Some(3))), Some(3));
|
||||
// A pre-versioning envelope has no version to report.
|
||||
assert_eq!(super::envelope_master_key_version(&audit_test_envelope(None)), None);
|
||||
// Opaque backend ciphertext (Transit, AWS) is not an envelope.
|
||||
assert_eq!(super::envelope_master_key_version(b"vault:v2:abcdefgh"), None);
|
||||
// JSON that is not the envelope shape must not be probed for a version.
|
||||
assert_eq!(super::envelope_master_key_version(br#"{"master_key_version": 9}"#), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_envelope_master_key_version_reads_both_metadata_families() {
|
||||
let envelope = BASE64_STANDARD.encode(audit_test_envelope(Some(2)));
|
||||
|
||||
// RustFS-branded stored key.
|
||||
let metadata = HashMap::from([(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), envelope.clone())]);
|
||||
assert_eq!(super::stored_envelope_master_key_version(&metadata), Some(2));
|
||||
|
||||
// MinIO-branded stored key reaches the same answer through
|
||||
// normalize_managed_metadata — the dual internal metadata key rule.
|
||||
let metadata = HashMap::from([(super::MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(), envelope)]);
|
||||
assert_eq!(super::stored_envelope_master_key_version(&metadata), Some(2));
|
||||
|
||||
assert_eq!(super::stored_envelope_master_key_version(&HashMap::new()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_key_versions_reach_the_audit_tags() {
|
||||
let scope = super::KmsRequestAuditScope::register("versioned-request");
|
||||
let slot = super::kms_request_audit("versioned-request").expect("a registered request must resolve its slot");
|
||||
slot.record(SSEType::SseKms, Some("finance-key"), Some(3), None);
|
||||
let tags = scope.audit_tags();
|
||||
assert_eq!(audit_tag(&tags, "kmsKeyVersion").as_deref(), Some("3"));
|
||||
assert_eq!(audit_tag(&tags, "kmsOutcome").as_deref(), Some("success"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +369,7 @@ async fn run_concurrent_downloads(settings: DownloadSettings) -> Result<Download
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "manual performance tool: requires a running RustFS server configured via env vars"]
|
||||
#[ignore]
|
||||
async fn concurrent_download_tool() -> Result<()> {
|
||||
let settings = DownloadSettings::from_env()?;
|
||||
let summary = run_concurrent_downloads(settings).await?;
|
||||
|
||||
@@ -512,7 +512,7 @@ async fn run_bench(settings: &ToolSettings, client: &Client) -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "manual >1GiB GET benchmark: requires a running RustFS server configured via env vars"]
|
||||
#[ignore]
|
||||
async fn gt1g_get_benchmark_tool() -> Result<()> {
|
||||
let settings = ToolSettings::from_env()?;
|
||||
let client = build_client(&settings).await?;
|
||||
|
||||
@@ -179,7 +179,7 @@ impl Oss {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "requires a running RustFS server at TEST_RUSTFS_SERVER (default http://localhost:9000)"]
|
||||
#[ignore]
|
||||
async fn test_lifecycle_minio_sdk() -> Result<()> {
|
||||
let settings = Settings::new();
|
||||
let oss = Oss::new(&settings).await?;
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
"""Census of assertion-less tests (rustfs/backlog#1836 PR3).
|
||||
|
||||
Flags `#[test]` / `#[tokio::test]` functions whose bodies contain no
|
||||
verification signal: no assert!/assert_eq!/assert_ne!/panic! macro, no
|
||||
`.expect(`/`.unwrap(`, no `?` operator, no `#[should_panic]`, and no
|
||||
`insta` snapshot / proptest / matches! usage. Such a test is green no
|
||||
matter what the code under test does.
|
||||
|
||||
This is a heuristic REVIEW QUEUE, not a lint: a hit still needs human
|
||||
reading before it is fixed or deleted, because assertions may live in a
|
||||
called helper. Known false-positive classes are excluded up front:
|
||||
|
||||
- `#[test_case(...)]`-driven functions (the values are the assertion's
|
||||
parameters; the assert lives in the shared body — still scanned, but a
|
||||
body that asserts is not flagged anyway; the exclusion covers wrappers
|
||||
that only delegate to a suite runner).
|
||||
- Functions whose body calls a helper with `assert`, `verify`, `check`,
|
||||
`expect`, `run_` or `_case` in its name (suite-delegation pattern).
|
||||
|
||||
Usage:
|
||||
scripts/find_assertless_tests.py [path ...] # default: crates rustfs/src
|
||||
|
||||
Exit code is always 0; the output is the queue.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERIFY_SIGNALS = re.compile(
|
||||
r"assert!|assert_eq!|assert_ne!|debug_assert|panic!\(|\.expect\(|\.unwrap\(|"
|
||||
r"unreachable!|matches!\(|insta::|proptest!|\.await\?|\)\?|\?;|should_panic"
|
||||
)
|
||||
DELEGATION = re.compile(r"\b[a-z0-9_]*(?:assert|verify|check|expect|run_case|_case|harness|round_trip|roundtrip)[a-z0-9_]*\s*\(")
|
||||
TEST_ATTR = re.compile(r"#\[(?:tokio::)?test[\](]")
|
||||
TEST_CASE_ATTR = re.compile(r"#\[test_case")
|
||||
FN_LINE = re.compile(r"^\s*(?:pub\s+)?(?:async\s+)?fn\s+([a-zA-Z0-9_]+)")
|
||||
|
||||
|
||||
def scan_file(path: Path):
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8").split("\n")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
return
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
if not TEST_ATTR.search(lines[i]):
|
||||
i += 1
|
||||
continue
|
||||
# collect the whole attribute block (may include #[serial], #[test_case], ...)
|
||||
attrs = []
|
||||
j = i
|
||||
while j < len(lines) and (lines[j].strip().startswith("#[") or lines[j].strip().startswith("//")):
|
||||
attrs.append(lines[j])
|
||||
j += 1
|
||||
if j >= len(lines):
|
||||
break
|
||||
m = FN_LINE.match(lines[j])
|
||||
if not m:
|
||||
i = j + 1
|
||||
continue
|
||||
name = m.group(1)
|
||||
if any(TEST_CASE_ATTR.search(a) for a in attrs):
|
||||
i = j + 1
|
||||
continue
|
||||
# brace-match the body
|
||||
depth = 0
|
||||
begun = False
|
||||
body = []
|
||||
k = j
|
||||
while k < len(lines):
|
||||
for ch in lines[k]:
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
begun = True
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
body.append(lines[k])
|
||||
if begun and depth <= 0:
|
||||
break
|
||||
k += 1
|
||||
text = "\n".join(body)
|
||||
if not VERIFY_SIGNALS.search(text) and not DELEGATION.search(text):
|
||||
print(f"{path}:{j + 1}: {name}")
|
||||
i = k + 1
|
||||
|
||||
|
||||
def main():
|
||||
roots = [Path(p) for p in (sys.argv[1:] or ["crates", "rustfs/src"])]
|
||||
for root in roots:
|
||||
for path in sorted(root.rglob("*.rs")):
|
||||
if "target" in path.parts:
|
||||
continue
|
||||
scan_file(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,14 +15,22 @@ cycle|composition<->interface
|
||||
cycle|infra<->interface
|
||||
dep|rustfs/src/admin/handlers/scanner.rs|interface->composition|crate::startup_background::ENV_SCANNER_ENABLED
|
||||
dep|rustfs/src/admin/handlers/scanner.rs|interface->composition|crate::startup_background::scanner_enabled_from_env
|
||||
dep|rustfs/src/app/admin_usecase.rs|app->interface|crate::server::DependencyReadiness
|
||||
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/app/bucket_usecase.rs|app->interface|crate::server::RemoteAddr
|
||||
dep|rustfs/src/app/object_usecase.rs|app->interface|crate::server::convert_ecstore_object_info
|
||||
dep|rustfs/src/cluster_snapshot.rs|infra->interface|crate::server::DependencyReadiness
|
||||
dep|rustfs/src/cluster_snapshot.rs|infra->interface|crate::server::DependencyReadinessReport
|
||||
dep|rustfs/src/cluster_snapshot.rs|infra->interface|crate::server::ReadinessDegradedReason
|
||||
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/storage/access.rs|infra->interface|crate::server::RemoteAddr
|
||||
dep|rustfs/src/storage/ecfs_extend.rs|infra->interface|crate::server::cors
|
||||
dep|rustfs/src/storage/ecfs_extend.rs|infra->interface|crate::storage::ecfs::ListObjectUnorderedQuery
|
||||
dep|rustfs/src/storage/helper.rs|infra->interface|crate::server::convert_ecstore_object_info
|
||||
dep|rustfs/src/storage/helper.rs|infra->interface|crate::server::is_audit_module_enabled
|
||||
dep|rustfs/src/storage/helper.rs|infra->interface|crate::server::is_notify_module_enabled
|
||||
dep|rustfs/src/storage/helper.rs|infra->interface|crate::server::refresh_audit_module_enabled
|
||||
|
||||
Reference in New Issue
Block a user