Compare commits

..

3 Commits

Author SHA1 Message Date
houseme c45933fa1b Merge branch 'main' into reatang/iam-issue-analysis-d4fb97 2026-08-23 12:11:04 +08:00
cxymds ce7277a334 Merge branch 'main' into reatang/iam-issue-analysis-d4fb97 2026-08-22 11:25:42 +08:00
唐小鸭 d5ba6b4e16 fix(admin): advertise IAM admin capabilities in runtime capabilities
The v4 runtime capabilities response carried no admin.iam.* entries, so
the rc client's capability gate rejected policy detach even though the
detach route is implemented (rustfs/backlog#1900). Advertise the IAM
admin capability set as a flat named list whose statuses are derived
from the public admin route inventory, so the advertisement tracks the
actually registered routes instead of a hardcoded claim.
2026-08-21 18:42:09 +08:00
8 changed files with 159 additions and 250 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=f832043fcca8c0b616c5d820a3a652da7544298ef5812a8668a3a9a3e4607b8b sha256-darwin=9f767b37ed8b1c82da62ea441462d75487785c8086e56f08fb6f6cd89c6e2e52
sha256-linux=93b94adb110b86a41d0b7313909e0bf53cb1515e2d08e8f105652b29b249990f sha256-linux=fbdaf42b220958d4b1e8880e0f8b5a7992d38e21051bb60596dd4538424757d6
+6 -3
View File
@@ -39,10 +39,11 @@ jobs:
env: env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps: steps:
- name: Checkout repository - name: Checkout main branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
ref: main
- name: Setup Rust environment - name: Setup Rust environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
@@ -88,10 +89,11 @@ jobs:
# either casing. # either casing.
NO_PROXY: 127.0.0.1,localhost NO_PROXY: 127.0.0.1,localhost
steps: steps:
- name: Checkout repository - name: Checkout main branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
ref: main
- name: Setup Rust environment - name: Setup Rust environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
@@ -176,10 +178,11 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NO_PROXY: 127.0.0.1,localhost NO_PROXY: 127.0.0.1,localhost
steps: steps:
- name: Checkout repository - name: Checkout main branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
ref: main
- name: Setup Rust environment - name: Setup Rust environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
+36 -118
View File
@@ -14,7 +14,7 @@
//! E2E tests for group management (fixes #2028). //! E2E tests for group management (fixes #2028).
use crate::common::{RustFSTestEnvironment, admin_ok, admin_request, init_logging}; use crate::common::{RustFSTestEnvironment, admin_request, awscurl_delete, awscurl_get, awscurl_put, init_logging};
use aws_sdk_s3::config::{Credentials, Region}; use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::{Client, Config}; use aws_sdk_s3::{Client, Config};
use tracing::info; use tracing::info;
@@ -83,6 +83,7 @@ async fn update_group_members_rejects_invalid_new_group_names() -> Result<(), Bo
/// Test that deleting a group with members fails, and deleting an empty group succeeds. /// Test that deleting a group with members fails, and deleting an empty group succeeds.
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
#[ignore = "requires awscurl and spawns a real RustFS server"]
async fn test_delete_group_requires_empty_membership() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn test_delete_group_requires_empty_membership() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging(); init_logging();
@@ -90,58 +91,29 @@ async fn test_delete_group_requires_empty_membership() -> Result<(), Box<dyn std
env.start_rustfs_server(vec![]).await?; env.start_rustfs_server(vec![]).await?;
// 1. Create a user // 1. Create a user
let add_user_url = format!("{}/rustfs/admin/v3/add-user?accessKey=testuser1", env.url);
let user_body = serde_json::json!({ let user_body = serde_json::json!({
"secretKey": "testuser1secret", "secretKey": "testuser1secret",
"status": "enabled" "status": "enabled"
}); });
admin_ok( awscurl_put(&add_user_url, &user_body.to_string(), &env.access_key, &env.secret_key).await?;
&env,
http::Method::PUT,
"/rustfs/admin/v3/add-user?accessKey=testuser1",
Some(user_body.to_string()),
)
.await?;
info!("Created testuser1"); info!("Created testuser1");
// 2. Create a group with testuser1 as a member // 2. Create a group with testuser1 as a member
let update_members_url = format!("{}/rustfs/admin/v3/update-group-members", env.url);
let add_member_body = serde_json::json!({ let add_member_body = serde_json::json!({
"group": "testgroup", "group": "testgroup",
"members": ["testuser1"], "members": ["testuser1"],
"isRemove": false, "isRemove": false,
"groupStatus": "enabled" "groupStatus": "enabled"
}); });
admin_ok( awscurl_put(&update_members_url, &add_member_body.to_string(), &env.access_key, &env.secret_key).await?;
&env,
http::Method::PUT,
"/rustfs/admin/v3/update-group-members",
Some(add_member_body.to_string()),
)
.await?;
info!("Added testuser1 to testgroup"); info!("Added testuser1 to testgroup");
// 3. Attempt to delete the group while it still has members — should fail // 3. Attempt to delete the group while it still has members — should fail
let (delete_status, delete_body) = admin_request( let delete_group_url = format!("{}/rustfs/admin/v3/group/testgroup", env.url);
&env.url, let delete_result = awscurl_delete(&delete_group_url, &env.access_key, &env.secret_key).await;
http::Method::DELETE, assert!(delete_result.is_err(), "deleting a non-empty group should fail");
"/rustfs/admin/v3/group/testgroup",
None,
&env.access_key,
&env.secret_key,
)
.await?;
assert_eq!(
delete_status,
reqwest::StatusCode::BAD_REQUEST,
"deleting a non-empty group must return HTTP 400, body: {delete_body}"
);
assert!(
delete_body.contains("<Code>InvalidRequest</Code>"),
"deleting a non-empty group must return InvalidRequest, body: {delete_body}"
);
assert!(
delete_body.contains("<Message>group is not empty</Message>"),
"deleting a non-empty group returned an unexpected message: {delete_body}"
);
info!("Delete of non-empty group correctly rejected"); info!("Delete of non-empty group correctly rejected");
// 4. Remove the member from the group // 4. Remove the member from the group
@@ -151,42 +123,17 @@ async fn test_delete_group_requires_empty_membership() -> Result<(), Box<dyn std
"isRemove": true, "isRemove": true,
"groupStatus": "enabled" "groupStatus": "enabled"
}); });
admin_ok( awscurl_put(&update_members_url, &remove_member_body.to_string(), &env.access_key, &env.secret_key).await?;
&env,
http::Method::PUT,
"/rustfs/admin/v3/update-group-members",
Some(remove_member_body.to_string()),
)
.await?;
info!("Removed testuser1 from testgroup"); info!("Removed testuser1 from testgroup");
// 5. Delete the now-empty group — should succeed // 5. Delete the now-empty group — should succeed
admin_ok(&env, http::Method::DELETE, "/rustfs/admin/v3/group/testgroup", None).await?; awscurl_delete(&delete_group_url, &env.access_key, &env.secret_key).await?;
info!("Deleted empty testgroup successfully"); info!("Deleted empty testgroup successfully");
// 6. Verify the group no longer exists // 6. Verify the group no longer exists
let (get_status, get_body) = admin_request( let get_group_url = format!("{}/rustfs/admin/v3/group?group=testgroup", env.url);
&env.url, let get_result = awscurl_get(&get_group_url, &env.access_key, &env.secret_key).await;
http::Method::GET, assert!(get_result.is_err(), "group should no longer exist after deletion");
"/rustfs/admin/v3/group?group=testgroup",
None,
&env.access_key,
&env.secret_key,
)
.await?;
assert_eq!(
get_status,
reqwest::StatusCode::NOT_FOUND,
"a deleted group must return HTTP 404, body: {get_body}"
);
assert!(
get_body.contains("<Code>NoSuchResource</Code>"),
"a deleted group must return NoSuchResource, body: {get_body}"
);
assert!(
get_body.contains("<Message>group &apos;testgroup&apos; does not exist</Message>"),
"a deleted group returned an unexpected message: {get_body}"
);
info!("Confirmed testgroup no longer exists"); info!("Confirmed testgroup no longer exists");
Ok(()) Ok(())
@@ -195,6 +142,7 @@ async fn test_delete_group_requires_empty_membership() -> Result<(), Box<dyn std
/// Test that a user with only group membership (no explicit user policy) gets group policies /// Test that a user with only group membership (no explicit user policy) gets group policies
/// and can perform actions allowed by the group (regression test for #2028.1). /// and can perform actions allowed by the group (regression test for #2028.1).
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
#[ignore = "requires awscurl and spawns a real RustFS server"]
async fn test_user_with_only_group_gets_group_policies() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn test_user_with_only_group_gets_group_policies() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging(); init_logging();
@@ -212,56 +160,39 @@ async fn test_user_with_only_group_gets_group_policies() -> Result<(), Box<dyn s
"Statement": [{ "Statement": [{
"Effect": "Allow", "Effect": "Allow",
"Action": ["s3:ListAllMyBuckets"], "Action": ["s3:ListAllMyBuckets"],
"Resource": ["arn:aws:s3:::*"] "Resource": ["*"]
}] }]
}); });
admin_ok( let add_policy_url = format!("{}/rustfs/admin/v3/add-canned-policy?name={}", env.url, policy_name);
&env, awscurl_put(&add_policy_url, &policy_doc.to_string(), &env.access_key, &env.secret_key).await?;
http::Method::PUT,
&format!("/rustfs/admin/v3/add-canned-policy?name={policy_name}"),
Some(policy_doc.to_string()),
)
.await?;
info!("Created canned policy {}", policy_name); info!("Created canned policy {}", policy_name);
// 2. Create user with no explicit policy // 2. Create user with no explicit policy
let add_user_url = format!("{}/rustfs/admin/v3/add-user?accessKey={}", env.url, user_name);
let user_body = serde_json::json!({ let user_body = serde_json::json!({
"secretKey": user_secret, "secretKey": user_secret,
"status": "enabled" "status": "enabled"
}); });
admin_ok( awscurl_put(&add_user_url, &user_body.to_string(), &env.access_key, &env.secret_key).await?;
&env,
http::Method::PUT,
&format!("/rustfs/admin/v3/add-user?accessKey={user_name}"),
Some(user_body.to_string()),
)
.await?;
info!("Created user {} with no explicit policy", user_name); info!("Created user {} with no explicit policy", user_name);
// 3. Add user to group (creates group with this member; user_group_memberships must be updated) // 3. Add user to group (creates group with this member; user_group_memberships must be updated)
let update_members_url = format!("{}/rustfs/admin/v3/update-group-members", env.url);
let add_member_body = serde_json::json!({ let add_member_body = serde_json::json!({
"group": group_name, "group": group_name,
"members": [user_name], "members": [user_name],
"isRemove": false, "isRemove": false,
"groupStatus": "enabled" "groupStatus": "enabled"
}); });
admin_ok( awscurl_put(&update_members_url, &add_member_body.to_string(), &env.access_key, &env.secret_key).await?;
&env,
http::Method::PUT,
"/rustfs/admin/v3/update-group-members",
Some(add_member_body.to_string()),
)
.await?;
info!("Added {} to group {}", user_name, group_name); info!("Added {} to group {}", user_name, group_name);
// 4. Attach policy to group // 4. Attach policy to group
admin_ok( let set_policy_url = format!(
&env, "{}/rustfs/admin/v3/set-user-or-group-policy?policyName={}&userOrGroup={}&isGroup=true",
http::Method::PUT, env.url, policy_name, group_name
&format!("/rustfs/admin/v3/set-user-or-group-policy?policyName={policy_name}&userOrGroup={group_name}&isGroup=true"), );
Some(String::new()), awscurl_put(&set_policy_url, "", &env.access_key, &env.secret_key).await?;
)
.await?;
info!("Attached policy {} to group {}", policy_name, group_name); info!("Attached policy {} to group {}", policy_name, group_name);
// 5. User with only group (no user policy) should be able to list buckets // 5. User with only group (no user policy) should be able to list buckets
@@ -278,6 +209,7 @@ async fn test_user_with_only_group_gets_group_policies() -> Result<(), Box<dyn s
/// Test that after deleting a user who was the only member of a group, the group can be deleted /// Test that after deleting a user who was the only member of a group, the group can be deleted
/// (regression test for #2028.2: delete group uses backend membership, not stale cache). /// (regression test for #2028.2: delete group uses backend membership, not stale cache).
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
#[ignore = "requires awscurl and spawns a real RustFS server"]
async fn test_delete_group_after_deleting_user() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn test_delete_group_after_deleting_user() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging(); init_logging();
@@ -289,47 +221,33 @@ async fn test_delete_group_after_deleting_user() -> Result<(), Box<dyn std::erro
let group_name = "soledeletegroup"; let group_name = "soledeletegroup";
// 1. Create user // 1. Create user
let add_user_url = format!("{}/rustfs/admin/v3/add-user?accessKey={}", env.url, user_name);
let user_body = serde_json::json!({ let user_body = serde_json::json!({
"secretKey": user_secret, "secretKey": user_secret,
"status": "enabled" "status": "enabled"
}); });
admin_ok( awscurl_put(&add_user_url, &user_body.to_string(), &env.access_key, &env.secret_key).await?;
&env,
http::Method::PUT,
&format!("/rustfs/admin/v3/add-user?accessKey={user_name}"),
Some(user_body.to_string()),
)
.await?;
info!("Created user {}", user_name); info!("Created user {}", user_name);
// 2. Add user to group // 2. Add user to group
let update_members_url = format!("{}/rustfs/admin/v3/update-group-members", env.url);
let add_member_body = serde_json::json!({ let add_member_body = serde_json::json!({
"group": group_name, "group": group_name,
"members": [user_name], "members": [user_name],
"isRemove": false, "isRemove": false,
"groupStatus": "enabled" "groupStatus": "enabled"
}); });
admin_ok( awscurl_put(&update_members_url, &add_member_body.to_string(), &env.access_key, &env.secret_key).await?;
&env,
http::Method::PUT,
"/rustfs/admin/v3/update-group-members",
Some(add_member_body.to_string()),
)
.await?;
info!("Added {} to group {}", user_name, group_name); info!("Added {} to group {}", user_name, group_name);
// 3. Delete the user (backend and cache update so group membership becomes empty) // 3. Delete the user (backend and cache update so group membership becomes empty)
admin_ok( let remove_user_url = format!("{}/rustfs/admin/v3/remove-user?accessKey={}", env.url, user_name);
&env, awscurl_delete(&remove_user_url, &env.access_key, &env.secret_key).await?;
http::Method::DELETE,
&format!("/rustfs/admin/v3/remove-user?accessKey={user_name}"),
None,
)
.await?;
info!("Deleted user {}", user_name); info!("Deleted user {}", user_name);
// 4. Deleting the group should succeed (backend has empty members; no stale cache) // 4. Deleting the group should succeed (backend has empty members; no stale cache)
admin_ok(&env, http::Method::DELETE, &format!("/rustfs/admin/v3/group/{group_name}"), None).await?; let delete_group_url = format!("{}/rustfs/admin/v3/group/{}", env.url, group_name);
awscurl_delete(&delete_group_url, &env.access_key, &env.secret_key).await?;
info!("Deleted group {} after user was removed", group_name); info!("Deleted group {} after user was removed", group_name);
Ok(()) Ok(())
+6 -38
View File
@@ -784,24 +784,6 @@ pub(crate) fn create_deferred_bitrot_reader_with_stripe_handle(
/// ///
/// # Returns /// # Returns
/// A Result containing the BitrotWriterWrapper or an error /// A Result containing the BitrotWriterWrapper or an error
/// Size hint handed to `DiskAPI::create_file` for a bitrot-wrapped shard.
///
/// A known length is grown by one checksum per shard so the on-disk file size
/// matches what the bitrot writer emits. A negative length is the
/// unknown-size sentinel (`HashReader::SIZE_PRESERVE_LAYER`, used by SSE and
/// compression) and must be preserved: `RemoteDisk::create_file` forwards it
/// in the `put_file_stream` query, and the receiver only treats `size > 0` as
/// a fixed body length when locating the authenticated trailer. Clamping it
/// to `0` would claim an empty body and misframe the stream. `0` stays `0`
/// because a genuinely empty object still means an empty body.
fn bitrot_create_file_size(length: i64, shard_size: usize, checksum_algo: &HashAlgorithm) -> i64 {
if length <= 0 {
return length;
}
let length = length as usize;
(length.div_ceil(shard_size) * checksum_algo.size() + length) as i64
}
pub async fn create_bitrot_writer( pub async fn create_bitrot_writer(
is_inline_buffer: bool, is_inline_buffer: bool,
disk: Option<&DiskStore>, disk: Option<&DiskStore>,
@@ -814,7 +796,12 @@ pub async fn create_bitrot_writer(
let writer = if is_inline_buffer { let writer = if is_inline_buffer {
CustomWriter::new_inline_buffer() CustomWriter::new_inline_buffer()
} else if let Some(disk) = disk { } else if let Some(disk) = disk {
let length = bitrot_create_file_size(length, shard_size, &checksum_algo); let length = if length > 0 {
let length = length as usize;
(length.div_ceil(shard_size) * checksum_algo.size() + length) as i64
} else {
0
};
let file = disk.create_file("", volume, path, length).await?; let file = disk.create_file("", volume, path, length).await?;
#[cfg(feature = "hotpath")] #[cfg(feature = "hotpath")]
@@ -833,25 +820,6 @@ mod tests {
use rustfs_rio::ChunkReader; use rustfs_rio::ChunkReader;
use std::collections::VecDeque; use std::collections::VecDeque;
#[test]
fn bitrot_create_file_size_grows_known_length_by_checksums() {
// 10 bytes over 4-byte shards = 3 shards, each followed by a 32-byte hash.
assert_eq!(bitrot_create_file_size(10, 4, &HashAlgorithm::HighwayHash256), 10 + 3 * 32);
assert_eq!(bitrot_create_file_size(10, 4, &HashAlgorithm::None), 10);
}
#[test]
fn bitrot_create_file_size_keeps_empty_and_unknown_distinct() {
assert_eq!(bitrot_create_file_size(0, 4, &HashAlgorithm::HighwayHash256), 0);
// SSE/compression streams advertise SIZE_PRESERVE_LAYER (-1); the remote
// put_file_stream receiver relies on a non-positive size to parse the auth
// trailer from the stream tail, so the sentinel must survive untouched.
assert_eq!(
bitrot_create_file_size(rustfs_rio::HashReader::SIZE_PRESERVE_LAYER, 4, &HashAlgorithm::HighwayHash256),
rustfs_rio::HashReader::SIZE_PRESERVE_LAYER
);
}
struct TestChunkReader { struct TestChunkReader {
chunks: VecDeque<Bytes>, chunks: VecDeque<Bytes>,
} }
+25 -86
View File
@@ -16,14 +16,14 @@
//! //!
//! `scripts/test/vault_ha_kms_live.sh` owns the official Vault containers and //! `scripts/test/vault_ha_kms_live.sh` owns the official Vault containers and
//! kills the active node while this test continuously decrypts through a //! kills the active node while this test continuously decrypts through a
//! surviving standby. KV2 and Transit must recover after the bounded circuit //! surviving standby. KV2 and Transit requests must remain successful, use a
//! interval, use a bounded number of attempts, and leave the circuit and //! bounded number of attempts, and leave the circuit and in-flight gauges at
//! in-flight gauges at zero after a new leader is elected. //! zero after a new leader is elected.
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use metrics_util::MetricKind; use metrics_util::MetricKind;
@@ -43,11 +43,6 @@ const OPERATION_ATTEMPTS: &str = "rustfs_kms_backend_operation_attempts";
const IN_FLIGHT: &str = "rustfs_kms_backend_in_flight"; const IN_FLIGHT: &str = "rustfs_kms_backend_in_flight";
const CIRCUIT_OPEN: &str = "rustfs_kms_backend_circuit_open"; const CIRCUIT_OPEN: &str = "rustfs_kms_backend_circuit_open";
const MAX_ATTEMPTS: u32 = 10; const MAX_ATTEMPTS: u32 = 10;
const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2);
const HEALTHY_PROGRESS_TIMEOUT: Duration = Duration::from_secs(20);
// The circuit remains open for 30s after five failed attempts.
const POST_FAILOVER_PROGRESS_TIMEOUT: Duration = Duration::from_secs(35);
const FAILOVER_ERROR_POLL_INTERVAL: Duration = Duration::from_millis(100);
type MetricEntry = ( type MetricEntry = (
metrics_util::CompositeKey, metrics_util::CompositeKey,
@@ -69,7 +64,7 @@ fn config(backend: KmsBackend, backend_config: BackendConfig) -> KmsConfig {
backend, backend,
backend_config, backend_config,
allow_insecure_dev_defaults: true, allow_insecure_dev_defaults: true,
timeout: ATTEMPT_TIMEOUT, timeout: Duration::from_secs(2),
retry_attempts: MAX_ATTEMPTS, retry_attempts: MAX_ATTEMPTS,
enable_cache: false, enable_cache: false,
..KmsConfig::default() ..KmsConfig::default()
@@ -169,31 +164,14 @@ fn retryable_failures(snapshot: &[MetricEntry], operation: &str) -> u64 {
.sum() .sum()
} }
async fn wait_for_count( async fn wait_for_count(counter: &AtomicU64, minimum: u64, description: &str) {
counter: &AtomicU64, tokio::time::timeout(Duration::from_secs(20), async {
failure: &Mutex<Option<String>>,
minimum: u64,
description: &str,
timeout: Duration,
) {
tokio::time::timeout(timeout, async {
while counter.load(Ordering::SeqCst) < minimum { while counter.load(Ordering::SeqCst) < minimum {
if let Some(error) = failure.lock().expect("decrypt failure lock poisoned").as_ref() {
panic!(
"{description} worker failed after {} successful decrypts: {error}",
counter.load(Ordering::SeqCst)
);
}
tokio::time::sleep(Duration::from_millis(25)).await; tokio::time::sleep(Duration::from_millis(25)).await;
} }
}) })
.await .await
.unwrap_or_else(|_| { .unwrap_or_else(|_| panic!("timed out waiting for {description}"));
panic!(
"timed out after {timeout:?} waiting for {description}: completed {}, expected {minimum}",
counter.load(Ordering::SeqCst)
)
});
} }
async fn wait_for_file(path: &Path, description: &str) { async fn wait_for_file(path: &Path, description: &str) {
@@ -211,8 +189,7 @@ async fn decrypt_loop<B: KmsBackendTrait + Send + Sync + 'static>(
request: DecryptRequest, request: DecryptRequest,
expected: Vec<u8>, expected: Vec<u8>,
completed: Arc<AtomicU64>, completed: Arc<AtomicU64>,
allow_failover_errors: Arc<AtomicBool>, failed: Arc<AtomicBool>,
failure: Arc<Mutex<Option<String>>>,
stop: CancellationToken, stop: CancellationToken,
) { ) {
while !stop.is_cancelled() { while !stop.is_cancelled() {
@@ -220,18 +197,8 @@ async fn decrypt_loop<B: KmsBackendTrait + Send + Sync + 'static>(
Ok(response) if response.plaintext == expected => { Ok(response) if response.plaintext == expected => {
completed.fetch_add(1, Ordering::SeqCst); completed.fetch_add(1, Ordering::SeqCst);
} }
Ok(_) => { Ok(_) | Err(_) => {
*failure.lock().expect("decrypt failure lock poisoned") = failed.store(true, Ordering::SeqCst);
Some("decrypt returned unexpected plaintext".to_string());
return;
}
Err(rustfs_kms::KmsError::BackendError { .. } | rustfs_kms::KmsError::OperationTimedOut { .. })
if allow_failover_errors.load(Ordering::SeqCst) =>
{
tokio::time::sleep(FAILOVER_ERROR_POLL_INTERVAL).await;
}
Err(error) => {
*failure.lock().expect("decrypt failure lock poisoned") = Some(error.to_string());
return; return;
} }
} }
@@ -329,9 +296,7 @@ async fn exercise_failover(snapshotter: &Snapshotter) {
); );
let stop = CancellationToken::new(); let stop = CancellationToken::new();
let allow_failover_errors = Arc::new(AtomicBool::new(false)); let failed = Arc::new(AtomicBool::new(false));
let kv2_failure = Arc::new(Mutex::new(None));
let transit_failure = Arc::new(Mutex::new(None));
let kv2_completed = Arc::new(AtomicU64::new(0)); let kv2_completed = Arc::new(AtomicU64::new(0));
let transit_completed = Arc::new(AtomicU64::new(0)); let transit_completed = Arc::new(AtomicU64::new(0));
let kv2_worker = tokio::spawn(decrypt_loop( let kv2_worker = tokio::spawn(decrypt_loop(
@@ -339,8 +304,7 @@ async fn exercise_failover(snapshotter: &Snapshotter) {
kv2_request, kv2_request,
kv2_data_key.plaintext_key, kv2_data_key.plaintext_key,
Arc::clone(&kv2_completed), Arc::clone(&kv2_completed),
Arc::clone(&allow_failover_errors), Arc::clone(&failed),
Arc::clone(&kv2_failure),
stop.clone(), stop.clone(),
)); ));
let transit_worker = tokio::spawn(decrypt_loop( let transit_worker = tokio::spawn(decrypt_loop(
@@ -348,21 +312,12 @@ async fn exercise_failover(snapshotter: &Snapshotter) {
transit_request, transit_request,
transit_data_key.plaintext_key, transit_data_key.plaintext_key,
Arc::clone(&transit_completed), Arc::clone(&transit_completed),
Arc::clone(&allow_failover_errors), Arc::clone(&failed),
Arc::clone(&transit_failure),
stop.clone(), stop.clone(),
)); ));
wait_for_count(&kv2_completed, &kv2_failure, 2, "two healthy KV2 decrypts", HEALTHY_PROGRESS_TIMEOUT).await; wait_for_count(&kv2_completed, 2, "two healthy KV2 decrypts").await;
wait_for_count( wait_for_count(&transit_completed, 2, "two healthy Transit decrypts").await;
&transit_completed,
&transit_failure,
2,
"two healthy Transit decrypts",
HEALTHY_PROGRESS_TIMEOUT,
)
.await;
allow_failover_errors.store(true, Ordering::SeqCst);
std::fs::write(&marker, b"ready").expect("publish failover readiness marker"); std::fs::write(&marker, b"ready").expect("publish failover readiness marker");
wait_for_file(&elected, "the replacement Vault leader").await; wait_for_file(&elected, "the replacement Vault leader").await;
@@ -371,39 +326,18 @@ async fn exercise_failover(snapshotter: &Snapshotter) {
let kv2_after_election = kv2_completed.load(Ordering::SeqCst) + 2; let kv2_after_election = kv2_completed.load(Ordering::SeqCst) + 2;
let transit_after_election = transit_completed.load(Ordering::SeqCst) + 2; let transit_after_election = transit_completed.load(Ordering::SeqCst) + 2;
wait_for_count( wait_for_count(&kv2_completed, kv2_after_election, "post-failover KV2 decrypts").await;
&kv2_completed, wait_for_count(&transit_completed, transit_after_election, "post-failover Transit decrypts").await;
&kv2_failure,
kv2_after_election,
"post-failover KV2 decrypts",
POST_FAILOVER_PROGRESS_TIMEOUT,
)
.await;
wait_for_count(
&transit_completed,
&transit_failure,
transit_after_election,
"post-failover Transit decrypts",
POST_FAILOVER_PROGRESS_TIMEOUT,
)
.await;
stop.cancel(); stop.cancel();
kv2_worker.await.expect("KV2 decrypt worker must join"); kv2_worker.await.expect("KV2 decrypt worker must join");
transit_worker.await.expect("Transit decrypt worker must join"); transit_worker.await.expect("Transit decrypt worker must join");
assert!( assert!(!failed.load(Ordering::SeqCst), "no decrypt may fail or return different plaintext");
kv2_failure.lock().expect("KV2 failure lock poisoned").is_none(),
"no KV2 decrypt may fail or return different plaintext"
);
assert!(
transit_failure.lock().expect("Transit failure lock poisoned").is_none(),
"no Transit decrypt may fail or return different plaintext"
);
} }
#[test] #[test]
#[ignore = "requires a real three-node Vault Raft cluster; run scripts/test/vault_ha_kms_live.sh"] #[ignore = "requires a real three-node Vault Raft cluster; run scripts/test/vault_ha_kms_live.sh"]
fn vault_raft_leader_failure_recovers_kv2_and_transit_decrypts() { fn vault_raft_leader_failure_preserves_kv2_and_transit_decrypts() {
let recorder = DebuggingRecorder::new(); let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter(); let snapshotter = recorder.snapshotter();
metrics::with_local_recorder(&recorder, || { metrics::with_local_recorder(&recorder, || {
@@ -415,6 +349,11 @@ fn vault_raft_leader_failure_recovers_kv2_and_transit_decrypts() {
}); });
let snapshot = snapshotter.snapshot().into_vec(); let snapshot = snapshotter.snapshot().into_vec();
assert_eq!(
counter_value(&snapshot, OPERATIONS_TOTAL, &[("outcome", "circuit_open")]),
0,
"a bounded leader election must not open the circuit"
);
assert_eq!( assert_eq!(
counter_value(&snapshot, OPERATIONS_TOTAL, &[("outcome", "budget_exhausted")]), counter_value(&snapshot, OPERATIONS_TOTAL, &[("outcome", "budget_exhausted")]),
0, 0,
+2 -2
View File
@@ -52,7 +52,7 @@
| fault_proxy | 7 | | | fault_proxy | 7 | |
| get_codec_streaming_compat_test | 1 | | | get_codec_streaming_compat_test | 1 | |
| get_stream_failure_observability_test | 1 | | | get_stream_failure_observability_test | 1 | |
| group_delete_test | 4 | | | group_delete_test | 1 | |
| head_object_consistency_test | 1 | ✅ | | head_object_consistency_test | 1 | ✅ |
| head_object_range_test | 1 | ✅ | | head_object_range_test | 1 | ✅ |
| heal_erasure_disk_rebuild_test | 4 | 🌙 | | heal_erasure_disk_rebuild_test | 4 | 🌙 |
@@ -99,4 +99,4 @@
| tls_hot_reload_test | 1 | ✅ | | tls_hot_reload_test | 1 | ✅ |
| version_id_regression_test | 10 | ✅ | | version_id_regression_test | 10 | ✅ |
**Total listed: 578 tests across 82 modules · PR smoke: 163 tests / 36 modules · merge/main full: 456 tests / 73 modules · nightly replication: 55 tests · nightly cluster faults: 28 tests / 7 modules · nightly protocols: 16 tests** · updated 2026-08-23. **Total listed: 575 tests across 82 modules · PR smoke: 163 tests / 36 modules · merge/main full: 453 tests / 73 modules · nightly replication: 55 tests · nightly cluster faults: 28 tests / 7 modules · nightly protocols: 16 tests** · updated 2026-08-23.
+81
View File
@@ -70,6 +70,12 @@ const SITE_REPLICATION_EDIT_ROUTE: &str = "/rustfs/admin/v3/site-replication/edi
const SITE_REPLICATION_RESYNC_ROUTE: &str = "/rustfs/admin/v3/site-replication/resync/op"; const SITE_REPLICATION_RESYNC_ROUTE: &str = "/rustfs/admin/v3/site-replication/resync/op";
const SITE_REPLICATION_REPAIR_ROUTE: &str = "/rustfs/admin/v3/site-replication/repair"; const SITE_REPLICATION_REPAIR_ROUTE: &str = "/rustfs/admin/v3/site-replication/repair";
const SITE_REPLICATION_REPAIR_STATUS_ROUTE: &str = "/rustfs/admin/v3/site-replication/repair/status"; const SITE_REPLICATION_REPAIR_STATUS_ROUTE: &str = "/rustfs/admin/v3/site-replication/repair/status";
const IAM_POLICY_ATTACH_ROUTE: &str = "/rustfs/admin/v3/idp/builtin/policy/attach";
const IAM_POLICY_DETACH_ROUTE: &str = "/rustfs/admin/v3/idp/builtin/policy/detach";
const IAM_POLICY_ENTITIES_ROUTE: &str = "/rustfs/admin/v3/idp/builtin/policy-entities";
const IAM_ACCESS_KEYS_BULK_ROUTE: &str = "/rustfs/admin/v3/list-access-keys-bulk";
const IAM_ACCESS_KEYS_BULK_LDAP_ROUTE: &str = "/rustfs/admin/v3/idp/ldap/list-access-keys-bulk";
const IAM_ACCESS_KEYS_BULK_OPENID_ROUTE: &str = "/rustfs/admin/v3/idp/openid/list-access-keys-bulk";
macro_rules! log_system_request_rejected { macro_rules! log_system_request_rejected {
($operation:expr, $reason:expr) => { ($operation:expr, $reason:expr) => {
@@ -661,9 +667,24 @@ pub struct RuntimeCapabilitiesSummary {
pub manual_transition_jobs: CapabilityStatus, pub manual_transition_jobs: CapabilityStatus,
} }
/// One named admin capability advertised to management clients
/// (rustfs/backlog#1900). `name` is a cross-repo wire contract: the rc
/// client gates commands on these exact strings (see rustfs/cli
/// `IAM_POLICY_DETACH_CAPABILITY` etc.), so entries may be added but
/// existing names must never be renamed or removed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AdvertisedAdminCapability {
pub name: &'static str,
pub status: CapabilityStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RuntimeCapabilitiesResponse { pub struct RuntimeCapabilitiesResponse {
pub summary: RuntimeCapabilitiesSummary, pub summary: RuntimeCapabilitiesSummary,
/// Additive field: absent in responses from older servers, so clients
/// must treat a missing list as "no dynamic advertisement" and fall
/// back to their pinned per-version contract.
pub advertised: Vec<AdvertisedAdminCapability>,
pub replication: ReplicationCapabilities, pub replication: ReplicationCapabilities,
pub manual_transition_jobs: ManualTransitionJobCapabilities, pub manual_transition_jobs: ManualTransitionJobCapabilities,
pub diagnostic_probes: DiagnosticProbeCapabilities, pub diagnostic_probes: DiagnosticProbeCapabilities,
@@ -986,6 +1007,7 @@ pub(crate) async fn build_runtime_capabilities_response()
Ok(RuntimeCapabilitiesResponse { Ok(RuntimeCapabilitiesResponse {
summary, summary,
advertised: advertised_admin_capabilities(),
replication: ReplicationCapabilities::current(), replication: ReplicationCapabilities::current(),
manual_transition_jobs: ManualTransitionJobCapabilities::current(), manual_transition_jobs: ManualTransitionJobCapabilities::current(),
diagnostic_probes: DiagnosticProbeCapabilities::current(), diagnostic_probes: DiagnosticProbeCapabilities::current(),
@@ -1077,6 +1099,23 @@ fn admin_route_capability(method: HttpMethod, path: &str) -> CapabilityStatus {
admin_route_capability_from_inventory(method, path, ADMIN_ROUTE_POLICY_SPECS, DEFERRED_ADMIN_ROUTE_POLICIES) admin_route_capability_from_inventory(method, path, ADMIN_ROUTE_POLICY_SPECS, DEFERRED_ADMIN_ROUTE_POLICIES)
} }
fn advertised_admin_capabilities() -> Vec<AdvertisedAdminCapability> {
[
("admin.iam.policy-attach", HttpMethod::Post, IAM_POLICY_ATTACH_ROUTE),
("admin.iam.policy-detach", HttpMethod::Post, IAM_POLICY_DETACH_ROUTE),
("admin.iam.policy-entities", HttpMethod::Get, IAM_POLICY_ENTITIES_ROUTE),
("admin.iam.access-keys-bulk", HttpMethod::Get, IAM_ACCESS_KEYS_BULK_ROUTE),
("admin.iam.access-keys-bulk.ldap", HttpMethod::Get, IAM_ACCESS_KEYS_BULK_LDAP_ROUTE),
("admin.iam.access-keys-bulk.openid", HttpMethod::Get, IAM_ACCESS_KEYS_BULK_OPENID_ROUTE),
]
.into_iter()
.map(|(name, method, route)| AdvertisedAdminCapability {
name,
status: admin_route_capability(method, route),
})
.collect()
}
fn admin_route_capability_from_inventory( fn admin_route_capability_from_inventory(
method: HttpMethod, method: HttpMethod,
path: &str, path: &str,
@@ -1239,6 +1278,48 @@ mod tests {
); );
} }
/// Wire-contract pin (rustfs/backlog#1900): the rc client keys its
/// command gates on these exact capability names, and parses each
/// entry as `{name, status: {state, reason?}}`. Renaming or dropping
/// a name silently disables the corresponding rc command.
#[tokio::test]
async fn runtime_capabilities_response_advertises_iam_capabilities() {
let response = build_runtime_capabilities_response()
.await
.expect("runtime capabilities response should build");
let expected_supported = [
"admin.iam.policy-attach",
"admin.iam.policy-detach",
"admin.iam.policy-entities",
"admin.iam.access-keys-bulk",
"admin.iam.access-keys-bulk.ldap",
"admin.iam.access-keys-bulk.openid",
];
for name in expected_supported {
let entry = response
.advertised
.iter()
.find(|capability| capability.name == name)
.unwrap_or_else(|| panic!("{name} must be advertised"));
assert_eq!(entry.status.state, CapabilityState::Supported, "{name} must be supported");
}
let mut names: Vec<&str> = response.advertised.iter().map(|capability| capability.name).collect();
let total = names.len();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), total, "advertised capability names must be unique");
let serialized = serde_json::to_value(&response).expect("response should serialize");
let advertised = serialized["advertised"].as_array().expect("advertised must be an array");
let detach = advertised
.iter()
.find(|entry| entry["name"] == "admin.iam.policy-detach")
.expect("serialized detach entry must exist");
assert_eq!(detach["status"]["state"], "supported");
}
#[tokio::test] #[tokio::test]
async fn runtime_capabilities_response_reports_missing_topology_before_storage_init() { async fn runtime_capabilities_response_reports_missing_topology_before_storage_init() {
let response = build_runtime_capabilities_response() let response = build_runtime_capabilities_response()
+1 -1
View File
@@ -241,7 +241,7 @@ env \
RUSTFS_TEST_VAULT_FAILOVER_MARKER="$MARKER" \ RUSTFS_TEST_VAULT_FAILOVER_MARKER="$MARKER" \
RUSTFS_TEST_VAULT_OLD_LEADER="$OLD_LEADER" \ RUSTFS_TEST_VAULT_OLD_LEADER="$OLD_LEADER" \
cargo test -p rustfs-kms --test vault_ha_failover_live \ cargo test -p rustfs-kms --test vault_ha_failover_live \
vault_raft_leader_failure_recovers_kv2_and_transit_decrypts -- \ vault_raft_leader_failure_preserves_kv2_and_transit_decrypts -- \
--ignored --nocapture --test-threads=1 & --ignored --nocapture --test-threads=1 &
TEST_PID=$! TEST_PID=$!