mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-01 17:58:22 +00:00
fix(admin): preserve raw XML in metadata backups (#6936)
This commit is contained in:
@@ -68,6 +68,35 @@ const LOG_COMPONENT_ADMIN: &str = "admin";
|
||||
const LOG_SUBSYSTEM_BUCKET_META: &str = "bucket_meta";
|
||||
const EVENT_ADMIN_BUCKET_META_STATE: &str = "admin_bucket_meta_state";
|
||||
|
||||
fn export_internal_error(message: impl Into<String>) -> s3s::S3Error {
|
||||
let message = message.into();
|
||||
s3_error!(InternalError, "{message}")
|
||||
}
|
||||
|
||||
fn checked_raw_xml<T, E, F>(validated: &T, raw: Vec<u8>, parse: F) -> S3Result<Vec<u8>>
|
||||
where
|
||||
T: PartialEq,
|
||||
E: std::fmt::Display,
|
||||
F: FnOnce(&[u8]) -> Result<T, E>,
|
||||
{
|
||||
let selected = parse(&raw)
|
||||
.map_err(|e| export_internal_error(format!("persisted bucket metadata changed to invalid XML during export: {e}")))?;
|
||||
if selected != *validated {
|
||||
return Err(export_internal_error("bucket metadata changed during export"));
|
||||
}
|
||||
Ok(raw)
|
||||
}
|
||||
|
||||
fn checked_versioning_xml(validated: &VersioningConfiguration, raw: Vec<u8>) -> S3Result<Vec<u8>> {
|
||||
if raw.is_empty() {
|
||||
if *validated != VersioningConfiguration::default() {
|
||||
return Err(export_internal_error("bucket metadata changed during export"));
|
||||
}
|
||||
return serialize(validated).map_err(|e| export_internal_error(format!("serialize config failed: {e}")));
|
||||
}
|
||||
checked_raw_xml(validated, raw, deserialize::<VersioningConfiguration>)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, serde::Deserialize)]
|
||||
pub struct ExportBucketMetadataQuery {
|
||||
pub bucket: String,
|
||||
@@ -190,8 +219,13 @@ impl Operation for ExportBucketMetadata {
|
||||
Ok(None) => continue,
|
||||
};
|
||||
|
||||
let raw_config = metadata_sys::get(&bucket.name)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("get bucket metadata failed: {e}")))?
|
||||
.notification_config_xml
|
||||
.clone();
|
||||
let config_xml =
|
||||
serialize(&config).map_err(|e| s3_error!(InternalError, "serialize config failed: {e}"))?;
|
||||
checked_raw_xml(&config, raw_config, deserialize::<s3s::dto::NotificationConfiguration>)?;
|
||||
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
@@ -210,8 +244,12 @@ impl Operation for ExportBucketMetadata {
|
||||
return Err(s3_error!(InternalError, "failed to load bucket metadata: {e}"));
|
||||
}
|
||||
};
|
||||
let config_xml =
|
||||
serialize(&config).map_err(|e| s3_error!(InternalError, "failed to serialize config: {e}"))?;
|
||||
let raw_config = metadata_sys::get(&bucket.name)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("failed to load bucket metadata: {e}")))?
|
||||
.lifecycle_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_raw_xml(&config, raw_config, deserialize::<BucketLifecycleConfiguration>)?;
|
||||
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
@@ -230,8 +268,12 @@ impl Operation for ExportBucketMetadata {
|
||||
return Err(s3_error!(InternalError, "failed to load bucket metadata: {e}"));
|
||||
}
|
||||
};
|
||||
let config_xml =
|
||||
serialize(&config).map_err(|e| s3_error!(InternalError, "failed to serialize config: {e}"))?;
|
||||
let raw_config = metadata_sys::get(&bucket.name)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("failed to load bucket metadata: {e}")))?
|
||||
.tagging_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_raw_xml(&config, raw_config, deserialize::<Tagging>)?;
|
||||
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
@@ -270,8 +312,12 @@ impl Operation for ExportBucketMetadata {
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
};
|
||||
let config_xml =
|
||||
serialize(&config).map_err(|e| s3_error!(InternalError, "serialize config failed: {e}"))?;
|
||||
let raw_config = metadata_sys::get(&bucket.name)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("get bucket metadata failed: {e}")))?
|
||||
.object_lock_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_raw_xml(&config, raw_config, deserialize::<ObjectLockConfiguration>)?;
|
||||
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
@@ -290,8 +336,12 @@ impl Operation for ExportBucketMetadata {
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
};
|
||||
let config_xml =
|
||||
serialize(&config).map_err(|e| s3_error!(InternalError, "serialize config failed: {e}"))?;
|
||||
let raw_config = metadata_sys::get(&bucket.name)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("get bucket metadata failed: {e}")))?
|
||||
.encryption_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_raw_xml(&config, raw_config, deserialize::<ServerSideEncryptionConfiguration>)?;
|
||||
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
@@ -310,8 +360,12 @@ impl Operation for ExportBucketMetadata {
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
};
|
||||
let config_xml =
|
||||
serialize(&config).map_err(|e| s3_error!(InternalError, "serialize config failed: {e}"))?;
|
||||
let raw_config = metadata_sys::get(&bucket.name)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("get bucket metadata failed: {e}")))?
|
||||
.versioning_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_versioning_xml(&config, raw_config)?;
|
||||
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
@@ -330,8 +384,12 @@ impl Operation for ExportBucketMetadata {
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
};
|
||||
let config_xml =
|
||||
serialize(&config).map_err(|e| s3_error!(InternalError, "serialize config failed: {e}"))?;
|
||||
let raw_config = metadata_sys::get(&bucket.name)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("get bucket metadata failed: {e}")))?
|
||||
.replication_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_raw_xml(&config, raw_config, deserialize::<ReplicationConfiguration>)?;
|
||||
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
@@ -1275,6 +1333,235 @@ mod import_persist_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod backup_zip_compatibility_tests {
|
||||
use super::*;
|
||||
use crate::admin::runtime_sources::{AppContext, publish_test_app_context};
|
||||
use http::{Extensions, Uri};
|
||||
use http_body_util::BodyExt as _;
|
||||
use rustfs_iam::store::{Store as _, object::IAM_CONFIG_PREFIX};
|
||||
use std::sync::Arc;
|
||||
|
||||
const ROOT_ACCESS_KEY: &str = "BUCKETMETABACKUPROOT";
|
||||
const ROOT_SECRET_KEY: &str = "bucketMetaBackupRootSecret123";
|
||||
const BUCKET: &str = "backup-compatibility";
|
||||
const NOTIFICATION_XML: &[u8] = b"<NotificationConfiguration>\n</NotificationConfiguration>";
|
||||
const LIFECYCLE_XML: &[u8] = b"<LifecycleConfiguration>\n<Rule><ID>expire</ID><Status>Enabled</Status><Filter><Prefix>logs/</Prefix></Filter><Expiration><Days>30</Days></Expiration></Rule>\n</LifecycleConfiguration>";
|
||||
const SSE_XML: &[u8] = b"<ServerSideEncryptionConfiguration>\n<Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule>\n</ServerSideEncryptionConfiguration>";
|
||||
const TAGGING_XML: &[u8] = b"<Tagging>\n<TagSet><Tag><Key>team</Key><Value>storage</Value></Tag></TagSet>\n</Tagging>";
|
||||
const OBJECT_LOCK_XML: &[u8] =
|
||||
b"<ObjectLockConfiguration>\n<ObjectLockEnabled>Enabled</ObjectLockEnabled>\n</ObjectLockConfiguration>";
|
||||
const VERSIONING_XML: &[u8] = b"<VersioningConfiguration>\n<Status>Enabled</Status>\n</VersioningConfiguration>";
|
||||
const OLD_REPLICATION_XML: &[u8] = b"<ReplicationConfiguration><Role>arn:aws:iam::123456789012:role/replication</Role><FutureTopLevel>preserve-me</FutureTopLevel><Rule><Status>Enabled</Status><Priority>1</Priority><DeleteMarkerReplication><Status>Disabled</Status></DeleteMarkerReplication><Filter><Prefix></Prefix></Filter><Destination><Bucket>arn:aws:s3:::backup</Bucket></Destination></Rule></ReplicationConfiguration>";
|
||||
const DIFFERENT_REPLICATION_XML: &[u8] = b"<ReplicationConfiguration><Role>arn:aws:iam::123456789012:role/replication</Role><Rule><Status>Enabled</Status><Priority>2</Priority><DeleteMarkerReplication><Status>Disabled</Status></DeleteMarkerReplication><Filter><Prefix>changed/</Prefix></Filter><Destination><Bucket>arn:aws:s3:::replacement</Bucket></Destination></Rule></ReplicationConfiguration>";
|
||||
|
||||
fn persisted_xml_fixtures() -> [(&'static str, &'static [u8]); 7] {
|
||||
[
|
||||
(BUCKET_NOTIFICATION_CONFIG, NOTIFICATION_XML),
|
||||
(BUCKET_LIFECYCLE_CONFIG, LIFECYCLE_XML),
|
||||
(BUCKET_SSECONFIG, SSE_XML),
|
||||
(BUCKET_TAGGING_CONFIG, TAGGING_XML),
|
||||
(OBJECT_LOCK_CONFIG, OBJECT_LOCK_XML),
|
||||
(BUCKET_VERSIONING_CONFIG, VERSIONING_XML),
|
||||
(BUCKET_REPLICATION_CONFIG, OLD_REPLICATION_XML),
|
||||
]
|
||||
}
|
||||
|
||||
fn persisted_xml<'a>(metadata: &'a BucketMetadata, config_file: &str) -> &'a [u8] {
|
||||
match config_file {
|
||||
BUCKET_NOTIFICATION_CONFIG => &metadata.notification_config_xml,
|
||||
BUCKET_LIFECYCLE_CONFIG => &metadata.lifecycle_config_xml,
|
||||
BUCKET_SSECONFIG => &metadata.encryption_config_xml,
|
||||
BUCKET_TAGGING_CONFIG => &metadata.tagging_config_xml,
|
||||
OBJECT_LOCK_CONFIG => &metadata.object_lock_config_xml,
|
||||
BUCKET_VERSIONING_CONFIG => &metadata.versioning_config_xml,
|
||||
BUCKET_REPLICATION_CONFIG => &metadata.replication_config_xml,
|
||||
_ => panic!("unexpected persisted XML config {config_file}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn zip_with_entries(bucket: &str, entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut writer = ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (config_file, payload) in entries {
|
||||
writer
|
||||
.start_file(format!("{bucket}/{config_file}"), SimpleFileOptions::default())
|
||||
.expect("start compatibility archive entry");
|
||||
writer.write_all(payload).expect("write compatibility archive entry");
|
||||
}
|
||||
writer.finish().expect("finish compatibility archive").into_inner()
|
||||
}
|
||||
|
||||
fn admin_request(method: Method, uri: Uri, body: Vec<u8>) -> S3Request<Body> {
|
||||
S3Request {
|
||||
input: Body::from(body),
|
||||
method,
|
||||
uri,
|
||||
headers: HeaderMap::new(),
|
||||
extensions: Extensions::new(),
|
||||
credentials: Some(s3s::auth::Credentials {
|
||||
access_key: ROOT_ACCESS_KEY.to_string(),
|
||||
secret_key: s3s::auth::SecretKey::from(ROOT_SECRET_KEY.to_string()),
|
||||
}),
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn import_archive(archive: Vec<u8>) {
|
||||
let response = ImportBucketMetadata {}
|
||||
.call(
|
||||
admin_request(Method::PUT, Uri::from_static("/rustfs/admin/v3/import-bucket-metadata"), archive),
|
||||
Params::new(),
|
||||
)
|
||||
.await
|
||||
.expect("root admin must import the compatibility archive");
|
||||
assert_eq!(response.output.0, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn g_zip_001_002_003_use_real_admin_archive_and_persistence_paths() {
|
||||
let _ = rustfs_credentials::init_global_action_credentials(
|
||||
Some(ROOT_ACCESS_KEY.to_string()),
|
||||
Some(ROOT_SECRET_KEY.to_string()),
|
||||
);
|
||||
let temp = tempfile::tempdir().expect("create bucket metadata backup test root");
|
||||
let env = rustfs_test_utils::TestECStoreEnv::builder()
|
||||
.base_dir(temp.path())
|
||||
.disk_count(1)
|
||||
.build()
|
||||
.await;
|
||||
env.make_bucket(BUCKET, false).await;
|
||||
rustfs_iam::store::object::ObjectStore::new(Arc::clone(&env.ecstore))
|
||||
.save_iam_config(serde_json::json!({"version": 1}), format!("{}/format.json", *IAM_CONFIG_PREFIX))
|
||||
.await
|
||||
.expect("seed IAM format");
|
||||
let iam = rustfs_iam::build_iam_sys(Arc::clone(&env.ecstore))
|
||||
.await
|
||||
.expect("build test IAM");
|
||||
publish_test_app_context(Arc::new(AppContext::with_default_interfaces(
|
||||
Arc::clone(&env.ecstore),
|
||||
iam,
|
||||
Arc::new(rustfs_kms::KmsServiceManager::new()),
|
||||
)));
|
||||
|
||||
let corrupt_error = ImportBucketMetadata {}
|
||||
.call(
|
||||
admin_request(
|
||||
Method::PUT,
|
||||
Uri::from_static("/rustfs/admin/v3/import-bucket-metadata"),
|
||||
b"not a zip archive".to_vec(),
|
||||
),
|
||||
Params::new(),
|
||||
)
|
||||
.await
|
||||
.expect_err("a corrupt compatibility archive must be rejected");
|
||||
assert!(
|
||||
corrupt_error
|
||||
.message()
|
||||
.is_some_and(|message| message.contains("failed to read import archive")),
|
||||
"corrupt archive returned the wrong error: {corrupt_error:?}"
|
||||
);
|
||||
|
||||
let fixtures = persisted_xml_fixtures();
|
||||
import_archive(zip_with_entries(BUCKET, &fixtures)).await;
|
||||
let imported = metadata_sys::get_config_from_disk(BUCKET)
|
||||
.await
|
||||
.expect("old archive must persist bucket metadata");
|
||||
for (config_file, payload) in fixtures {
|
||||
assert_eq!(persisted_xml(&imported, config_file), payload, "g-zip-001 changed {config_file} bytes");
|
||||
}
|
||||
import_archive(zip_with_entries(BUCKET, &[(BUCKET_REPLICATION_CONFIG, b"not xml")])).await;
|
||||
let after_rejected_payload = metadata_sys::get_config_from_disk(BUCKET)
|
||||
.await
|
||||
.expect("rejected payload must leave persisted metadata readable");
|
||||
assert_eq!(
|
||||
after_rejected_payload.replication_config_xml, OLD_REPLICATION_XML,
|
||||
"a rejected archive payload must not replace persisted bytes"
|
||||
);
|
||||
|
||||
let (validated_replication, _) = metadata_sys::get_replication_config(BUCKET)
|
||||
.await
|
||||
.expect("load the revision validated before export");
|
||||
checked_raw_xml(
|
||||
&validated_replication,
|
||||
DIFFERENT_REPLICATION_XML.to_vec(),
|
||||
deserialize::<ReplicationConfiguration>,
|
||||
)
|
||||
.expect_err("a different valid revision must not be exported after validating the old revision");
|
||||
checked_raw_xml(&validated_replication, b"not xml".to_vec(), deserialize::<ReplicationConfiguration>)
|
||||
.expect_err("an invalid raw revision must not be exported after validating the old revision");
|
||||
let matching_raw = checked_raw_xml(
|
||||
&validated_replication,
|
||||
OLD_REPLICATION_XML.to_vec(),
|
||||
deserialize::<ReplicationConfiguration>,
|
||||
)
|
||||
.expect("the exact validated raw revision must remain exportable");
|
||||
assert_eq!(matching_raw, OLD_REPLICATION_XML);
|
||||
let default_versioning = VersioningConfiguration::default();
|
||||
assert!(
|
||||
!checked_versioning_xml(&default_versioning, Vec::new())
|
||||
.expect("empty persisted versioning keeps the existing default fallback")
|
||||
.is_empty()
|
||||
);
|
||||
let enabled_versioning: VersioningConfiguration = deserialize(VERSIONING_XML).expect("parse enabled versioning fixture");
|
||||
checked_versioning_xml(&enabled_versioning, Vec::new())
|
||||
.expect_err("an empty raw revision must not export a previously validated enabled revision");
|
||||
|
||||
let export_response = ExportBucketMetadata {}
|
||||
.call(
|
||||
admin_request(
|
||||
Method::GET,
|
||||
format!("/rustfs/admin/v3/export-bucket-metadata?bucket={BUCKET}")
|
||||
.parse()
|
||||
.expect("export URI"),
|
||||
Vec::new(),
|
||||
),
|
||||
Params::new(),
|
||||
)
|
||||
.await
|
||||
.expect("root admin must export persisted bucket metadata");
|
||||
assert_eq!(export_response.output.0, StatusCode::OK);
|
||||
let exported_archive = export_response
|
||||
.output
|
||||
.1
|
||||
.collect()
|
||||
.await
|
||||
.expect("read exported archive body")
|
||||
.to_bytes()
|
||||
.to_vec();
|
||||
let mut archive = ZipArchive::new(Cursor::new(&exported_archive)).expect("open exported archive");
|
||||
for (config_file, payload) in persisted_xml_fixtures() {
|
||||
let mut exported_payload = Vec::new();
|
||||
archive
|
||||
.by_name(&format!("{BUCKET}/{config_file}"))
|
||||
.unwrap_or_else(|_| panic!("exported archive must contain {config_file}"))
|
||||
.read_to_end(&mut exported_payload)
|
||||
.unwrap_or_else(|_| panic!("read exported {config_file}"));
|
||||
assert_eq!(exported_payload, payload, "g-zip-003 export must preserve {config_file} byte-for-byte");
|
||||
}
|
||||
drop(archive);
|
||||
|
||||
metadata_sys::update(BUCKET, BUCKET_REPLICATION_CONFIG, DIFFERENT_REPLICATION_XML.to_vec())
|
||||
.await
|
||||
.expect("replace persisted config before rollback import");
|
||||
import_archive(exported_archive).await;
|
||||
let restored = metadata_sys::get_config_from_disk(BUCKET)
|
||||
.await
|
||||
.expect("new archive must persist through old import");
|
||||
for (config_file, payload) in persisted_xml_fixtures() {
|
||||
assert_eq!(
|
||||
persisted_xml(&restored, config_file),
|
||||
payload,
|
||||
"g-zip-002 rollback import did not restore {config_file}"
|
||||
);
|
||||
}
|
||||
let _: ReplicationConfiguration =
|
||||
deserialize(&restored.replication_config_xml).expect("old parser must read the newly exported archive payload");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shared_gate_tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user