mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-21 11:56:38 +00:00
fix(migration): decrypt MinIO IAM & server config on drop-in migration (#4358)
* fix(migration): decrypt MinIO IAM & server config on drop-in migration MinIO encrypts IAM identity/service-account files and the server config at rest with a key derived from the root credentials. The drop-in migration paths read those blobs from the legacy `.minio.sys` bucket and parsed them as plaintext JSON, so any encrypted blob failed to parse and was silently skipped with "incompatible format". This is why users migrating from MinIO kept their buckets/objects/policies but lost users and access keys (#2212). The IAM load path already knows how to decrypt these blobs (RustFS master keys plus MinIO-compatible legacy keys derived from the root credentials), but that logic lived behind a private method and was never used by the migration paths. Expose it as `rustfs_iam::try_decrypt_iam_blob` and inject it into both migration paths via a `LegacyBlobDecryptFn` callback (ecstore cannot depend on the IAM crate, so the closure is wired in the binary crate). When a blob cannot be decrypted the raw bytes are used as-is, preserving the previous plaintext-only behavior with no regression. Also improve object-layer migration observability without changing control flow: `try_migrate_format` now distinguishes "no legacy format" (a normal fresh install) from "legacy format present but incompatible", and the caller logs a loud error before initializing a fresh format that would leave the existing MinIO objects unreadable. Topology/version skip reasons are promoted from debug to warn. Fixes a pre-existing test isolation race by marking `test_recovery_falls_back_to_default_config_when_blob_stays_corrupt` serial, since it reads a process-wide env var toggled by a sibling test. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(migration): box FormatV3 in LegacyFormatOutcome to satisfy clippy Co-Authored-By: heihutu <heihutu@gmail.com> * test(ecstore): stabilize concurrent multipart resend lock timeout concurrent_resend_same_part_commits_one_generation spawns 6 same-part resends whose cross-disk commits serialize on the per-uploadId commit lock. Under the full nextest suite the parallel disk load pushes those serialized commits past the small default lock-acquire timeout (5s), producing a spurious `Lock(Timeout ...)` unrelated to the property under test (observed on CI at 5.775s vs ~0.5s in isolation). Raise RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT to the production default (30s) for the concurrent-commit section via temp_env, so the regression guard reflects correctness (exactly one intact generation) rather than disk latency under CI load. The meaningful assertions are unchanged, and #[serial] keeps the process-wide env override isolated. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(lock): bound fast-lock notification wait to prevent lost-wakeup stall The real cause of the concurrent_resend_same_part_commits_one_generation failures was a lost wakeup in the fast-lock slow path, not disk latency: raising the acquire timeout to 30s only delayed the failure (it then timed out at 30s), proving a genuine stall rather than overload. In acquire_lock_slow_path a waiter that reaches the notification phase did a single `timeout(remaining, wait_for_write())` spanning the whole acquire budget, and treated that wait's elapse as a hard `Timeout`. But the release path only notifies when `writer_waiters > 0`, so if the holder releases in the gap after the waiter's `try_acquire` fails and before it registers as a waiter, no notification (and no stored permit, since the pooled `Notify` is gated) is produced. The waiter then blocks until the deadline even though the lock is free and stays free — a spurious lock-acquire timeout. The shared process-wide notify pool makes it worse: a wakeup can be consumed by a waiter of a different lock hashing to the same slot. Bound each notification wait (NOTIFY_WAIT_CAP = 50ms) and, on elapse, loop back and re-`try_acquire` instead of returning `Timeout`; the deadline check at the top of the loop is the single source of truth for timing out. A lost/stolen wakeup now degrades to bounded re-polling (acquire within ~50ms of the lock becoming free) instead of stalling for the whole timeout. Correctness (mutual exclusion) is unchanged — acquisition still only happens via `try_acquire_*`. Add a regression test that reproduces the stall (holder + late waiter across many keys): it times out without the fix and passes in ~1s with it. Revert the earlier acquire-timeout workaround in the multipart test now that the underlying stall is fixed, so it runs under the default timeout again. Co-Authored-By: heihutu <heihutu@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
@@ -4731,8 +4731,12 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
// Every concurrent resend must succeed; the fix must not serialize the
|
||||
// streaming phase into lock-acquire timeouts.
|
||||
// Every concurrent resend must succeed. The per-uploadId commit lock must
|
||||
// not starve a waiter into a lock-acquire timeout: the shared fast-lock
|
||||
// notify pool could otherwise route this lock's wakeup to a waiter of an
|
||||
// unrelated lock and strand this one until the deadline (fixed in
|
||||
// fast_lock::shard by bounding each notification wait, so a lost/stolen
|
||||
// wakeup degrades to bounded re-polling instead of a hard timeout).
|
||||
let mut results = Vec::new();
|
||||
while let Some(joined) = tasks.join_next().await {
|
||||
let outcome = joined.expect("put_object_part task should not panic");
|
||||
|
||||
@@ -52,6 +52,17 @@ type ListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
|
||||
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, Error>;
|
||||
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
|
||||
|
||||
/// Callback used to decrypt an at-rest config blob during MinIO -> RustFS migration.
|
||||
///
|
||||
/// MinIO encrypts IAM identity/service-account files and the server config at rest
|
||||
/// with a key derived from the root credentials. The migration paths live in
|
||||
/// `ecstore`, which cannot depend on the IAM crate that owns the decryption keys,
|
||||
/// so the caller injects the decryption logic. Given raw bytes read from the
|
||||
/// legacy meta bucket, it returns the plaintext when a key succeeds, or `None`
|
||||
/// when the blob cannot be decrypted (in which case the raw bytes are used as-is,
|
||||
/// preserving the original plaintext-only behavior).
|
||||
pub type LegacyBlobDecryptFn = Arc<dyn Fn(&[u8]) -> Option<Vec<u8>> + Send + Sync>;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CompatIamFormat {
|
||||
#[serde(default)]
|
||||
@@ -305,7 +316,7 @@ where
|
||||
/// Lists all objects under the IAM prefix in the source, copies each to the target if not present.
|
||||
/// Skips objects that already exist in RustFS (idempotent).
|
||||
/// If list_objects_v2 on the legacy bucket fails (e.g. format differs), migration is skipped.
|
||||
pub async fn try_migrate_iam_config<S>(store: Arc<S>)
|
||||
pub async fn try_migrate_iam_config<S>(store: Arc<S>, decrypt_fn: Option<LegacyBlobDecryptFn>)
|
||||
where
|
||||
S: ListOperations<
|
||||
Error = crate::error::Error,
|
||||
@@ -386,6 +397,13 @@ where
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// MinIO encrypts IAM identity/service-account files at rest. Decrypt
|
||||
// before normalizing; fall back to the raw bytes when no key applies
|
||||
// (plaintext blobs, or nothing to decrypt) so existing behavior holds.
|
||||
let data = match &decrypt_fn {
|
||||
Some(decrypt) => decrypt(&data).unwrap_or(data),
|
||||
None => data,
|
||||
};
|
||||
let data = match normalize_iam_config_blob(path, &data) {
|
||||
Ok(Some(normalized)) => normalized,
|
||||
Ok(None) => {
|
||||
@@ -445,6 +463,36 @@ mod tests {
|
||||
assert!(updated_at.contains('T'), "updatedAt should be RFC3339-like");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypted_identity_requires_decrypt_before_normalize() {
|
||||
// Reproduces the MinIO drop-in migration gap: an IAM identity file that
|
||||
// MinIO encrypted at rest is NOT valid JSON, so normalize fails outright.
|
||||
// The migration's decrypt callback must run first to recover it.
|
||||
let path = "config/iam/users/alice/identity.json";
|
||||
let plaintext = br#"{"version":1,"credentials":{"accessKey":"alice","secretKey":"alicesecret"}}"#;
|
||||
|
||||
// Simulate MinIO's at-rest encryption of the identity blob.
|
||||
let ciphertext = rustfs_crypto::encrypt_data(b"root-secret-key", plaintext).expect("encrypt identity blob");
|
||||
|
||||
// Without decryption (old behavior): normalize can't parse ciphertext -> Err -> skipped.
|
||||
assert!(
|
||||
normalize_iam_config_blob(path, &ciphertext).is_err(),
|
||||
"ciphertext must not parse as a JSON identity"
|
||||
);
|
||||
|
||||
// With the decrypt callback recovering plaintext first: normalize succeeds.
|
||||
let decrypt_fn: super::LegacyBlobDecryptFn =
|
||||
std::sync::Arc::new(|data: &[u8]| rustfs_crypto::decrypt_data(b"root-secret-key", data).ok());
|
||||
let recovered = decrypt_fn(&ciphertext).expect("callback should decrypt the identity blob");
|
||||
assert_eq!(recovered, plaintext);
|
||||
|
||||
let normalized = normalize_iam_config_blob(path, &recovered)
|
||||
.expect("normalize should succeed on decrypted plaintext")
|
||||
.expect("identity path should be supported");
|
||||
let v: serde_json::Value = serde_json::from_slice(&normalized).expect("output should be valid JSON");
|
||||
assert!(v.get("updatedAt").is_some(), "normalize should backfill updatedAt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_bucket_meta_blob_resync_reencode() {
|
||||
let path = ".buckets/test/.replication/resync.bin";
|
||||
|
||||
Reference in New Issue
Block a user