mirror of
https://github.com/rustfs/rustfs.git
synced 2026-11-11 12:43:54 +00:00
Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 685c563a02 | |||
| 027d076867 | |||
| 8125128d05 | |||
| 9946e19737 | |||
| 93fae96387 | |||
| 977060e457 | |||
| 4c7cc8a943 | |||
| 8172df44ec | |||
| 55d931353f | |||
| 7b3701ddc6 | |||
| fc09148f08 | |||
| 2df55c6dea | |||
| c9e1464a2a | |||
| 0e3028d29e | |||
| 365014a385 | |||
| 443e8afee1 | |||
| 782e3e5209 | |||
| cfffb1700e | |||
| cd62f3ba4f | |||
| 7bc419e368 | |||
| 7269243de7 | |||
| c4a7423f99 | |||
| 2db4ed244a | |||
| 93a61df1cf | |||
| feec0e9e4a | |||
| 38baeeb11d | |||
| 1af039c972 | |||
| a2fc7c13b4 | |||
| dbe01e5cb1 | |||
| ffc27fa6db | |||
| 7e8edab0d3 | |||
| 22057ad4c5 | |||
| 279a88ab56 | |||
| b4abe64ada | |||
| add363f084 | |||
| 1f2dbe36e5 | |||
| 67fbafa13e | |||
| 1000860cb8 | |||
| b783f404ad | |||
| 11a991c777 | |||
| 3f2b6f690a | |||
| 88b626e7a0 | |||
| 8cf6187ff4 | |||
| a0a0cee9eb | |||
| 80fff4b5c5 | |||
| d91dfcedd2 | |||
| 79c4fedf8c | |||
| 73c846e09c | |||
| 6938c467d0 | |||
| 5323df7049 | |||
| 55b6fbd260 | |||
| dd677cc121 | |||
| 0c2f327974 | |||
| df42cf1ce8 | |||
| d290d46b34 | |||
| 0d297ed8cd | |||
| b8dffa2f1d | |||
| 62b6f5f3fc | |||
| be0986115c | |||
| 1bd91c8dad | |||
| dcb22bd4d5 | |||
| fa17718444 | |||
| 99abffc57a | |||
| fc061c7930 | |||
| d86bf1360f | |||
| f6097d1df2 | |||
| 7e5d32eecf | |||
| 72da0e21c4 | |||
| 78a0cf321c | |||
| cbe38a120f | |||
| db0828aa1c | |||
| 3ca889b099 | |||
| e6112c526f | |||
| c5967686f1 | |||
| 859de565ad | |||
| 1f7cc593e7 | |||
| 1d5a50f094 | |||
| 53245be9f4 | |||
| c47432e353 | |||
| f622c1bce0 | |||
| a99d8f4f89 | |||
| 4d15b81623 | |||
| 836d30192f | |||
| 860c61ee6d | |||
| f600af4704 | |||
| 9e747e8475 | |||
| 96fc067765 | |||
| e809b4899e | |||
| 584f03f300 | |||
| d52d6fade7 | |||
| 3623d6db2d | |||
| 484f1254ca | |||
| c9c16efbb6 | |||
| f2a59073ca | |||
| c1af94d1d1 | |||
| f84bbfe28f | |||
| 378a2ff7f8 | |||
| b88b53aa6a |
@@ -75,6 +75,11 @@ test-count-ratchet-check: ## Check no workspace crate fell below its test floor
|
||||
@echo "🧪 Checking per-crate test-count ratchet..."
|
||||
./scripts/check_test_count_ratchet.sh
|
||||
|
||||
.PHONY: test-seams-check
|
||||
test-seams-check: ## Check tests outside an operation's owner call its use-case only through rustfs/src/app/test_seams
|
||||
@echo "🧪 Checking cross-owner test seams..."
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_seams.py
|
||||
|
||||
.PHONY: fips-wording-check
|
||||
fips-wording-check: ## Check docs and crates/kms do not over-claim crypto capabilities
|
||||
@echo "📣 Checking cryptographic capability wording guard..."
|
||||
|
||||
@@ -20,11 +20,11 @@ planning-docs-check: ## Check that no planning-type documents are committed
|
||||
./scripts/check_no_planning_docs.sh
|
||||
|
||||
.PHONY: pre-commit
|
||||
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
||||
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check test-seams-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
||||
@echo "✅ All pre-commit checks passed!"
|
||||
|
||||
.PHONY: pre-pr
|
||||
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check test-seams-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||
@echo "✅ All pre-PR checks passed!"
|
||||
|
||||
.PHONY: dev-check
|
||||
|
||||
@@ -62,8 +62,12 @@ script-tests: ## Run shell script tests
|
||||
./scripts/check_embedded_secrets.sh --self-test
|
||||
./scripts/check_s3s_footprint.sh --self-test
|
||||
./scripts/test_check_test_count_ratchet.sh
|
||||
./scripts/test_guard_source_set.sh
|
||||
./scripts/test_split_guard_rules.sh
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/s3_error_to_s3_types.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/split_lifecycle_transition_api_test.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_seams.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_download_release_asset.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_reference_binary.py
|
||||
@@ -93,6 +97,7 @@ script-tests: ## Run shell script tests
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_junit_diff.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_runner_tools.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_api_coverage.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_reference_parity.py
|
||||
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||
|
||||
@@ -101,6 +101,10 @@ runs:
|
||||
shell: bash
|
||||
run: ./scripts/check_test_count_ratchet.sh
|
||||
|
||||
- name: Check cross-owner tests use test seams
|
||||
shell: bash
|
||||
run: python3 ./scripts/check_test_seams.py
|
||||
|
||||
- name: Check cryptographic capability wording
|
||||
shell: bash
|
||||
run: ./scripts/check_fips_wording.sh
|
||||
|
||||
@@ -843,7 +843,7 @@ mod tests {
|
||||
};
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
DefaultBucketUsecase::from_global().execute_list_objects_v2(request),
|
||||
crate::app::test_seams::list_objects_v2(&DefaultBucketUsecase::from_global(), request),
|
||||
)
|
||||
.await
|
||||
.expect("listing must complete within its bounded source budget")
|
||||
@@ -1241,19 +1241,23 @@ mod tests {
|
||||
let mut list = get.clone().map_input(|_| input.clone());
|
||||
list.uri = http::Uri::from_static("/?list-type=2");
|
||||
let usecase = crate::app::object::DefaultObjectUsecase::from_global();
|
||||
let get_error = tokio::time::timeout(Duration::from_secs(10), usecase.execute_get_object(get))
|
||||
.await
|
||||
.expect("GET stays local")
|
||||
.expect_err("local object is absent");
|
||||
let get_error =
|
||||
tokio::time::timeout(Duration::from_secs(10), crate::app::test_seams::get_object(&usecase, get))
|
||||
.await
|
||||
.expect("GET stays local")
|
||||
.expect_err("local object is absent");
|
||||
assert_eq!(*get_error.code(), S3ErrorCode::NoSuchKey);
|
||||
let head_error = tokio::time::timeout(Duration::from_secs(10), usecase.execute_head_object(head))
|
||||
.await
|
||||
.expect("HEAD stays local")
|
||||
.expect_err("local object is absent");
|
||||
let head_error = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
crate::app::test_seams::head_object(&usecase, head),
|
||||
)
|
||||
.await
|
||||
.expect("HEAD stays local")
|
||||
.expect_err("local object is absent");
|
||||
assert_eq!(*head_error.code(), S3ErrorCode::NoSuchKey);
|
||||
let listing = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
DefaultBucketUsecase::from_global().execute_list_objects_v2(list),
|
||||
crate::app::test_seams::list_objects_v2(&DefaultBucketUsecase::from_global(), list),
|
||||
)
|
||||
.await
|
||||
.expect("LIST stays local")
|
||||
|
||||
@@ -3912,27 +3912,21 @@ mod tests {
|
||||
req
|
||||
}
|
||||
|
||||
fn usecase_method_source<'a>(source: &'a str, method: &str) -> &'a str {
|
||||
let public_start_marker = format!("pub async fn {method}");
|
||||
let private_start_marker = format!("async fn {method}");
|
||||
let (start, marker_len) = if let Some(start) = source.find(&public_start_marker) {
|
||||
(start, public_start_marker.len())
|
||||
} else {
|
||||
let start = source.find(&private_start_marker).expect("method should exist");
|
||||
(start, private_start_marker.len())
|
||||
};
|
||||
let rest = &source[start + marker_len..];
|
||||
let end = [rest.find("\n pub async fn "), rest.find("\n async fn ")]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min()
|
||||
.unwrap_or(rest.len());
|
||||
&rest[..end]
|
||||
/// Production text of this file plus `bucket_usecase/`, where a split moves its methods.
|
||||
fn bucket_usecase_sources() -> crate::guard_sources::SourceSet {
|
||||
crate::guard_sources::SourceSet::production("src/app/bucket_usecase.rs", "src/app/bucket_usecase")
|
||||
}
|
||||
|
||||
/// The one `async fn method` of the set, through its own closing line, so a neighbour of any
|
||||
/// visibility or a test module can never satisfy a check about it.
|
||||
fn usecase_method_source<'a>(source: &'a crate::guard_sources::SourceSet, method: &str) -> &'a str {
|
||||
source.method_body(method)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_create_and_delete_use_admitted_fresh_tasks() {
|
||||
let source = include_str!("bucket_usecase.rs");
|
||||
let sources = bucket_usecase_sources();
|
||||
let source = &sources;
|
||||
for (method, operation, inner_call) in [
|
||||
(
|
||||
"execute_create_bucket",
|
||||
@@ -3957,7 +3951,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bucket_metadata_config_changes_notify_peer_metadata_reload() {
|
||||
let source = include_str!("bucket_usecase.rs");
|
||||
let sources = bucket_usecase_sources();
|
||||
let source = &sources;
|
||||
for (method, operation, scanner_maintenance_change) in [
|
||||
("execute_delete_bucket_policy", "delete bucket policy", false),
|
||||
("execute_put_bucket_policy", "put bucket policy", false),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,384 @@
|
||||
// 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.
|
||||
|
||||
//! Shared fixtures for the lifecycle integration tests; owned by no operation batch.
|
||||
|
||||
use super::*;
|
||||
|
||||
static GLOBAL_ENV: OnceLock<(Vec<PathBuf>, Arc<ECStore>)> = OnceLock::new();
|
||||
|
||||
pub(super) const TRANSITION_WAIT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
pub(super) const RUSTFS_EXPECTED_CURRENT_VERSION_ID: &str = "x-rustfs-expected-current-version-id";
|
||||
|
||||
fn init_tracing() {
|
||||
rustfs_test_utils::init_tracing();
|
||||
}
|
||||
|
||||
pub(super) async fn setup_test_env() -> (Vec<PathBuf>, Arc<ECStore>) {
|
||||
init_tracing();
|
||||
|
||||
if let Some((paths, ecstore)) = GLOBAL_ENV.get() {
|
||||
return (paths.clone(), ecstore.clone());
|
||||
}
|
||||
|
||||
// Production startup installs this proof through the notification-system
|
||||
// fleet probe. This isolated single-node harness constructs ECStore
|
||||
// directly, so model that completed all-v6 probe before any journal permit
|
||||
// or background expiry worker can exist.
|
||||
install_all_v6_fleet_capability_proof();
|
||||
|
||||
let test_base_dir = format!("/tmp/rustfs_app_lifecycle_test_{}", Uuid::new_v4());
|
||||
let temp_dir = PathBuf::from(&test_base_dir);
|
||||
if temp_dir.exists() {
|
||||
fs::remove_dir_all(&temp_dir).await.ok();
|
||||
}
|
||||
fs::create_dir_all(&temp_dir).await.unwrap();
|
||||
|
||||
let disk_paths = vec![
|
||||
temp_dir.join("disk1"),
|
||||
temp_dir.join("disk2"),
|
||||
temp_dir.join("disk3"),
|
||||
temp_dir.join("disk4"),
|
||||
];
|
||||
|
||||
for disk_path in &disk_paths {
|
||||
fs::create_dir_all(disk_path).await.unwrap();
|
||||
}
|
||||
|
||||
let mut endpoints = Vec::new();
|
||||
for (i, disk_path) in disk_paths.iter().enumerate() {
|
||||
let mut endpoint = Endpoint::try_from(disk_path.to_str().unwrap()).unwrap();
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(i);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
|
||||
let pool_endpoints = PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 4,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: "test".to_string(),
|
||||
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
|
||||
};
|
||||
|
||||
let endpoint_pools = EndpointServerPools(vec![pool_endpoints]);
|
||||
|
||||
super::super::storage_api::test::runtime::init_local_disks(endpoint_pools.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let server_addr: std::net::SocketAddr = "127.0.0.1:9003".parse().unwrap();
|
||||
let ecstore = ECStore::new(server_addr, endpoint_pools, CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let buckets_list = ecstore
|
||||
.list_bucket(&BucketOptions {
|
||||
no_metadata: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let buckets = buckets_list.into_iter().map(|v| v.name).collect();
|
||||
metadata_sys::init_bucket_metadata_sys(ecstore.clone(), buckets).await;
|
||||
|
||||
lifecycle::bucket_lifecycle_ops::init_background_expiry(ecstore.clone()).await;
|
||||
|
||||
// The usecases resolve the object store through the ambient AppContext
|
||||
// since the backlog#1052 InstanceContext migration; without one installed
|
||||
// every store-touching usecase call fails with InternalError "Not init".
|
||||
// Publish a default-interface context wrapping this test store.
|
||||
crate::app::runtime_sources::install_test_app_context(ecstore.clone()).await;
|
||||
|
||||
let _ = GLOBAL_ENV.set((disk_paths.clone(), ecstore.clone()));
|
||||
|
||||
(disk_paths, ecstore)
|
||||
}
|
||||
|
||||
pub(super) async fn create_test_bucket(ecstore: &Arc<ECStore>, bucket_name: &str) {
|
||||
(**ecstore)
|
||||
.make_bucket(
|
||||
bucket_name,
|
||||
&MakeBucketOptions {
|
||||
versioning_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create test bucket");
|
||||
}
|
||||
|
||||
pub(super) async fn upload_test_object(ecstore: &Arc<ECStore>, bucket: &str, object: &str, data: &[u8]) -> ObjectInfo {
|
||||
let mut reader = PutObjReader::from_vec(data.to_vec());
|
||||
(**ecstore)
|
||||
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to upload test object")
|
||||
}
|
||||
|
||||
pub(super) async fn set_bucket_lifecycle_transition_with_tier(
|
||||
bucket_name: &str,
|
||||
storage_class: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let lifecycle_xml = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LifecycleConfiguration>
|
||||
<Rule>
|
||||
<ID>test-rule</ID>
|
||||
<Status>Enabled</Status>
|
||||
<Filter>
|
||||
<Prefix>test/</Prefix>
|
||||
</Filter>
|
||||
<Transition>
|
||||
<Days>0</Days>
|
||||
<StorageClass>{storage_class}</StorageClass>
|
||||
</Transition>
|
||||
</Rule>
|
||||
</LifecycleConfiguration>"#
|
||||
);
|
||||
|
||||
metadata_sys::update(bucket_name, BUCKET_LIFECYCLE_CONFIG, lifecycle_xml.into_bytes()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn set_bucket_object_lock_enabled(bucket_name: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let object_lock_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ObjectLockConfiguration>
|
||||
<ObjectLockEnabled>Enabled</ObjectLockEnabled>
|
||||
</ObjectLockConfiguration>"#;
|
||||
metadata_sys::update(bucket_name, OBJECT_LOCK_CONFIG, object_lock_xml.as_bytes().to_vec()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register the shared [`MockWarmBackend`] into this instance's tier config
|
||||
/// manager. Thin wrapper over the shared `register_mock_tier` helper
|
||||
/// (rustfs/backlog#1148 ilm-6).
|
||||
pub(super) async fn register_mock_tier(tier_name: &str) -> MockWarmBackend {
|
||||
register_mock_tier_util(¤t_tier_config_handle(), tier_name).await
|
||||
}
|
||||
|
||||
pub(super) async fn wait_for_transition(
|
||||
ecstore: &Arc<ECStore>,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<ObjectInfo, String> {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
let observed = (**ecstore).get_object_info(bucket, object, &ObjectOptions::default()).await;
|
||||
let observed = match observed {
|
||||
Ok(info) if info.transitioned_object.status == "complete" => return Ok(info),
|
||||
other => other,
|
||||
};
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
let last_observation = match observed {
|
||||
Ok(info) => format!("transitioned_status={:?}", info.transitioned_object.status),
|
||||
Err(error) => format!("metadata_error={:?}", error.code()),
|
||||
};
|
||||
let state = get_global_transition_state();
|
||||
return Err(format!(
|
||||
"transition did not complete within {timeout:?}; {last_observation}; pending={}, active={}, compensation_pending={}, compensation_running={}",
|
||||
state.pending_tasks(),
|
||||
state.active_tasks(),
|
||||
state.compensation_pending_tasks(),
|
||||
state.compensation_running_tasks(),
|
||||
));
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: this helper is used only by `#[serial]` tests and runs under the single-threaded Tokio
|
||||
// runtime (`worker_threads = 1`), so no concurrent test can mutate process environment during the
|
||||
// `env::set_var` / `env::remove_var` window.
|
||||
#[allow(unsafe_code)]
|
||||
pub(super) async fn with_forced_immediate_enqueue_timeout<F, Fut>(backend: &MockWarmBackend, test_fn: F)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = ()>,
|
||||
{
|
||||
let transition_state = get_global_transition_state();
|
||||
let missed_before = transition_state.missed_immediate_tasks();
|
||||
let compensation_before = transition_state.compensation_scheduled_tasks();
|
||||
let put_barrier = backend.arm_put_barrier().await;
|
||||
let original = env::var_os(ENV_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT);
|
||||
unsafe {
|
||||
env::set_var(ENV_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT, "1");
|
||||
}
|
||||
let result = std::panic::AssertUnwindSafe(test_fn()).catch_unwind().await;
|
||||
match original {
|
||||
Some(value) => unsafe {
|
||||
env::set_var(ENV_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT, value);
|
||||
},
|
||||
None => unsafe {
|
||||
env::remove_var(ENV_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT);
|
||||
},
|
||||
}
|
||||
if let Err(err) = result {
|
||||
std::panic::resume_unwind(err);
|
||||
}
|
||||
|
||||
assert!(
|
||||
transition_state.missed_immediate_tasks() > missed_before,
|
||||
"the operation should exercise the forced immediate enqueue failure"
|
||||
);
|
||||
assert!(
|
||||
transition_state.compensation_scheduled_tasks() > compensation_before,
|
||||
"the operation should schedule automatic compensation backfill"
|
||||
);
|
||||
|
||||
// Separate automatic discovery and queue latency from the metadata commit deadline.
|
||||
put_barrier.wait_until_paused().await;
|
||||
assert_eq!(backend.object_count().await, 1, "compensation should store the body before committing");
|
||||
put_barrier.release();
|
||||
}
|
||||
|
||||
pub(super) async fn wait_for_delete_marker(ecstore: &Arc<ECStore>, bucket: &str, object: &str, timeout: Duration) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
if let Ok(info) = ecstore.get_object_info(bucket, object, &ObjectOptions::default()).await
|
||||
&& info.delete_marker
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_request<T>(input: T, method: Method) -> S3Request<T> {
|
||||
S3Request {
|
||||
input,
|
||||
method,
|
||||
uri: Uri::from_static("/"),
|
||||
headers: HeaderMap::new(),
|
||||
extensions: Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn streaming_blob_from_bytes(data: &[u8]) -> StreamingBlob {
|
||||
let body = Bytes::copy_from_slice(data);
|
||||
StreamingBlob::wrap::<_, Infallible>(stream::once(async move { Ok(body) }))
|
||||
}
|
||||
|
||||
pub(super) async fn read_object_bytes(ecstore: &Arc<ECStore>, bucket: &str, object: &str) -> Vec<u8> {
|
||||
let mut reader = (**ecstore)
|
||||
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to read object");
|
||||
let mut buf = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut buf)
|
||||
.await
|
||||
.expect("Failed to drain object reader");
|
||||
buf
|
||||
}
|
||||
|
||||
pub(super) async fn live_object_version_count(ecstore: &Arc<ECStore>, bucket: &str, object: &str) -> usize {
|
||||
let versions = ecstore
|
||||
.clone()
|
||||
.list_object_versions(bucket, object, None, None, None, 1000)
|
||||
.await
|
||||
.expect("Failed to list object versions");
|
||||
|
||||
versions
|
||||
.objects
|
||||
.iter()
|
||||
.filter(|info| info.name == object && !info.delete_marker)
|
||||
.count()
|
||||
}
|
||||
|
||||
pub(super) async fn install_delete_replication_config(
|
||||
bucket: &str,
|
||||
delete_enabled: bool,
|
||||
replica_modifications: bool,
|
||||
required_tag: Option<(&str, &str)>,
|
||||
) {
|
||||
use super::super::storage_api::test::{bucket::utils::serialize, get_global_bucket_metadata_sys, set_bucket_metadata};
|
||||
|
||||
let sys = get_global_bucket_metadata_sys().expect("bucket metadata system should be initialized");
|
||||
let metadata = {
|
||||
let sys = sys.read().await;
|
||||
sys.get(bucket)
|
||||
.await
|
||||
.expect("bucket metadata should be cached before replication config injection")
|
||||
};
|
||||
let mut metadata = (*metadata).clone();
|
||||
let target = "arn:aws:s3:::target-bucket";
|
||||
let delete_status = if delete_enabled {
|
||||
Status::ENABLED.to_string()
|
||||
} else {
|
||||
Status::DISABLED.to_string()
|
||||
};
|
||||
|
||||
metadata.versioning_config_xml = b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec();
|
||||
metadata.versioning_config = Some(rustfs_gateway_types::persistence::PersistedVersioningConfiguration {
|
||||
status: Some("Enabled".to_owned()),
|
||||
..Default::default()
|
||||
});
|
||||
let filter = required_tag.map(|(key, value)| PersistedReplicationFilter {
|
||||
tag: Some(PersistedReplicationTag {
|
||||
key: Some(key.to_string()),
|
||||
value: Some(value.to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let source_selection_criteria = replica_modifications.then(|| PersistedSourceSelectionCriteria {
|
||||
replica_modifications: Some(PersistedReplicationStatus {
|
||||
status: Status::ENABLED.to_string(),
|
||||
}),
|
||||
sse_kms_encrypted_objects: None,
|
||||
});
|
||||
let replication_config = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![PersistedReplicationRule {
|
||||
delete_marker_replication: Some(PersistedOptionalReplicationStatus {
|
||||
status: Some(Status::DISABLED.to_string()),
|
||||
}),
|
||||
delete_replication: Some(PersistedReplicationStatus { status: delete_status }),
|
||||
destination: PersistedReplicationDestination {
|
||||
bucket: target.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
existing_object_replication: None,
|
||||
filter,
|
||||
id: Some("delete".to_string()),
|
||||
prefix: Some(String::new()),
|
||||
priority: Some(1),
|
||||
source_selection_criteria,
|
||||
status: Status::ENABLED.to_string(),
|
||||
}],
|
||||
};
|
||||
metadata.replication_config_xml = serialize(&replication_config).expect("replication test config should serialize");
|
||||
metadata.replication_config = Some(replication_config);
|
||||
set_bucket_metadata(bucket.to_string(), metadata)
|
||||
.await
|
||||
.expect("delete replication metadata should be installed for the request test");
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
// 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.
|
||||
|
||||
//! CopyObject tests, including tests that also PUT (batch E owns CopyObject).
|
||||
|
||||
use super::common::*;
|
||||
use super::*;
|
||||
use crate::app::test_seams;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn copy_object_if_none_match_existing_destination_returns_precondition_failed() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let src_bucket = format!("test-copy-if-none-match-src-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let dst_bucket = format!("test-copy-if-none-match-dst-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let src_object = "test/source.txt";
|
||||
let dst_object = "test/destination.txt";
|
||||
let src_payload = b"conditional copy source payload";
|
||||
let dst_payload = b"conditional copy destination payload";
|
||||
|
||||
create_test_bucket(&ecstore, src_bucket.as_str()).await;
|
||||
create_test_bucket(&ecstore, dst_bucket.as_str()).await;
|
||||
let _ = upload_test_object(&ecstore, src_bucket.as_str(), src_object, src_payload).await;
|
||||
let _ = upload_test_object(&ecstore, dst_bucket.as_str(), dst_object, dst_payload).await;
|
||||
|
||||
let dst_info = ecstore
|
||||
.get_object_info(dst_bucket.as_str(), dst_object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to fetch destination object info");
|
||||
let dst_etag = dst_info.etag.expect("destination object should have an ETag");
|
||||
let initial_versions = live_object_version_count(&ecstore, dst_bucket.as_str(), dst_object).await;
|
||||
|
||||
for (condition, expected_code) in [
|
||||
(ETagCondition::Any, S3ErrorCode::PreconditionFailed),
|
||||
(ETagCondition::ETag(ETag::Strong(dst_etag)), S3ErrorCode::NotImplemented),
|
||||
(
|
||||
ETagCondition::ETag(ETag::Strong("different-etag".to_string())),
|
||||
S3ErrorCode::NotImplemented,
|
||||
),
|
||||
] {
|
||||
let header = condition.to_http_header().expect("conditional COPY header should encode");
|
||||
// Cover header-only callers and the DTO-plus-header shape of parsed HTTP requests.
|
||||
for if_none_match in [None, Some(condition)] {
|
||||
let has_dto = if_none_match.is_some();
|
||||
let copy_input = CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: src_bucket.clone().into(),
|
||||
key: src_object.to_string().into(),
|
||||
version_id: None,
|
||||
})
|
||||
.bucket(dst_bucket.clone())
|
||||
.key(dst_object.to_string())
|
||||
.if_none_match(if_none_match)
|
||||
.build()
|
||||
.expect("conditional COPY input should build");
|
||||
let mut req = build_request(copy_input, Method::PUT);
|
||||
req.headers.insert(IF_NONE_MATCH, header.clone());
|
||||
|
||||
let err = Box::pin(usecase.execute_copy_object(req))
|
||||
.await
|
||||
.expect_err("conditional COPY must reject the replacement");
|
||||
assert_eq!(err.code(), &expected_code, "header={header:?}, has_dto={has_dto}");
|
||||
assert_eq!(
|
||||
read_object_bytes(&ecstore, dst_bucket.as_str(), dst_object).await,
|
||||
dst_payload,
|
||||
"failed conditional CopyObject must not overwrite the current destination object"
|
||||
);
|
||||
assert_eq!(
|
||||
live_object_version_count(&ecstore, dst_bucket.as_str(), dst_object).await,
|
||||
initial_versions,
|
||||
"failed conditional CopyObject must not create a version"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state object-version integration test: runs serialized in the CI ILM Integration (serial) lane"]
|
||||
async fn undo_copy_requires_the_observed_current_version() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let fs = FS::new();
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-undo-copy-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
let put = |payload: &'static [u8]| {
|
||||
PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(payload)))
|
||||
.content_length(Some(payload.len() as i64))
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
let original = Box::pin(test_seams::put_object(&usecase, &fs, build_request(put(b"original"), Method::PUT)))
|
||||
.await
|
||||
.expect("initial version should be written")
|
||||
.output
|
||||
.version_id
|
||||
.expect("versioned PUT should return a version ID");
|
||||
let observed_current = Box::pin(test_seams::put_object(&usecase, &fs, build_request(put(b"replacement"), Method::PUT)))
|
||||
.await
|
||||
.expect("replacement version should be written")
|
||||
.output
|
||||
.version_id
|
||||
.expect("versioned PUT should return a version ID");
|
||||
|
||||
let copy = || {
|
||||
CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: bucket.clone().into(),
|
||||
key: object.to_string().into(),
|
||||
version_id: Some(original.clone().into()),
|
||||
})
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
let mut stale_req = build_request(copy(), Method::PUT);
|
||||
stale_req.headers.insert(
|
||||
RUSTFS_EXPECTED_CURRENT_VERSION_ID,
|
||||
HeaderValue::from_str(&Uuid::new_v4().to_string()).unwrap(),
|
||||
);
|
||||
let versions_before = live_object_version_count(&ecstore, bucket.as_str(), object).await;
|
||||
let err = Box::pin(usecase.execute_copy_object(stale_req))
|
||||
.await
|
||||
.expect_err("stale destination must fail");
|
||||
assert_eq!(err.code(), &S3ErrorCode::PreconditionFailed);
|
||||
assert_eq!(live_object_version_count(&ecstore, bucket.as_str(), object).await, versions_before);
|
||||
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), object).await, b"replacement");
|
||||
|
||||
let mut matching_req = build_request(copy(), Method::PUT);
|
||||
matching_req
|
||||
.headers
|
||||
.insert(RUSTFS_EXPECTED_CURRENT_VERSION_ID, HeaderValue::from_str(&observed_current).unwrap());
|
||||
Box::pin(usecase.execute_copy_object(matching_req))
|
||||
.await
|
||||
.expect("matching destination version should permit undo copy");
|
||||
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), object).await, b"original");
|
||||
assert_eq!(live_object_version_count(&ecstore, bucket.as_str(), object).await, versions_before + 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
#[ignore = "global-state object-version integration test: runs serialized in the CI ILM Integration (serial) lane"]
|
||||
async fn undo_copy_serializes_a_concurrent_put_until_after_commit() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("test-undo-race-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
let put = |payload: &'static [u8]| {
|
||||
PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(payload)))
|
||||
.content_length(Some(payload.len() as i64))
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
let fs = FS::new();
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let original = Box::pin(test_seams::put_object(&usecase, &fs, build_request(put(b"original"), Method::PUT)))
|
||||
.await
|
||||
.expect("original should be written")
|
||||
.output
|
||||
.version_id
|
||||
.expect("original should be versioned");
|
||||
let observed = Box::pin(test_seams::put_object(&usecase, &fs, build_request(put(b"observed"), Method::PUT)))
|
||||
.await
|
||||
.expect("observed should be written")
|
||||
.output
|
||||
.version_id
|
||||
.expect("observed should be versioned");
|
||||
let encoded_object = encode_dir_object(object);
|
||||
let lock = ecstore
|
||||
.new_ns_lock(bucket.as_str(), &encoded_object)
|
||||
.await
|
||||
.expect("namespace lock should be available");
|
||||
let blocker = lock
|
||||
.get_write_lock(Duration::from_secs(10))
|
||||
.await
|
||||
.expect("test should acquire blocker");
|
||||
|
||||
let mut undo_req = build_request(
|
||||
CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: bucket.clone().into(),
|
||||
key: object.to_string().into(),
|
||||
version_id: Some(original.into()),
|
||||
})
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::PUT,
|
||||
);
|
||||
undo_req
|
||||
.headers
|
||||
.insert(RUSTFS_EXPECTED_CURRENT_VERSION_ID, HeaderValue::from_str(&observed).unwrap());
|
||||
|
||||
let undo = tokio::spawn(async move {
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
Box::pin(usecase.execute_copy_object(undo_req)).await
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
let put = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(b"concurrent")))
|
||||
.content_length(Some(10))
|
||||
.build()
|
||||
.unwrap();
|
||||
let concurrent_put = tokio::spawn(async move {
|
||||
let fs = FS::new();
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
Box::pin(test_seams::put_object(&usecase, &fs, build_request(put, Method::PUT))).await
|
||||
});
|
||||
|
||||
drop(blocker);
|
||||
let undo_result = undo.await.expect("undo task should not panic");
|
||||
let put_result = concurrent_put.await.expect("PUT task should not panic");
|
||||
assert!(put_result.is_ok(), "ordinary PUT should eventually succeed: {put_result:?}");
|
||||
if let Err(err) = undo_result {
|
||||
assert_eq!(err.code(), &S3ErrorCode::PreconditionFailed);
|
||||
}
|
||||
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), object).await, b"concurrent");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn copy_object_allows_new_version_for_locked_destination_but_blocks_explicit_overwrite() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let fs = FS::new();
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-copy-object-lock-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let src_object = "test/source.txt";
|
||||
let dst_object = "test/destination.txt";
|
||||
let locked_payload = b"locked destination payload";
|
||||
let source_payload = b"copy source payload";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_object_lock_enabled(bucket.as_str())
|
||||
.await
|
||||
.expect("Failed to enable object lock for bucket");
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), src_object, source_payload).await;
|
||||
|
||||
let retain_until = Timestamp::from(time::OffsetDateTime::now_utc().saturating_add(time::Duration::days(1)));
|
||||
let locked_input = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(dst_object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(locked_payload)))
|
||||
.content_length(Some(locked_payload.len() as i64))
|
||||
.object_lock_mode(Some(ObjectLockMode::from_static(ObjectLockMode::COMPLIANCE)))
|
||||
.object_lock_retain_until_date(Some(retain_until))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
Box::pin(test_seams::put_object(&usecase, &fs, build_request(locked_input, Method::PUT)))
|
||||
.await
|
||||
.expect("Failed to upload locked destination object");
|
||||
|
||||
let locked_info = ecstore
|
||||
.get_object_info(bucket.as_str(), dst_object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to fetch locked destination object info");
|
||||
let locked_version_id = locked_info
|
||||
.version_id
|
||||
.expect("locked destination should have a version ID")
|
||||
.to_string();
|
||||
|
||||
let copy_input = CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: bucket.clone().into(),
|
||||
key: src_object.to_string().into(),
|
||||
version_id: None,
|
||||
})
|
||||
.bucket(bucket.clone())
|
||||
.key(dst_object.to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let copy_output = Box::pin(usecase.execute_copy_object(build_request(copy_input, Method::PUT)))
|
||||
.await
|
||||
.expect("CopyObject should create a new version over a locked current version");
|
||||
let copied_version_id = copy_output
|
||||
.output
|
||||
.version_id
|
||||
.expect("versioned CopyObject should return the created version ID");
|
||||
|
||||
assert_ne!(copied_version_id, locked_version_id);
|
||||
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), dst_object).await, source_payload);
|
||||
assert_eq!(live_object_version_count(&ecstore, bucket.as_str(), dst_object).await, 2);
|
||||
|
||||
let explicit_overwrite_input = CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: bucket.clone().into(),
|
||||
key: src_object.to_string().into(),
|
||||
version_id: None,
|
||||
})
|
||||
.bucket(bucket.clone())
|
||||
.key(dst_object.to_string())
|
||||
.version_id(Some(locked_version_id))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let err = Box::pin(usecase.execute_copy_object(build_request(explicit_overwrite_input, Method::PUT)))
|
||||
.await
|
||||
.expect_err("explicit CopyObject overwrite of a locked version should be blocked");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
assert_eq!(
|
||||
err.message(),
|
||||
Some("Invalid argument: A destination versionId requires replication authorization")
|
||||
);
|
||||
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), dst_object).await, source_payload);
|
||||
assert_eq!(live_object_version_count(&ecstore, bucket.as_str(), dst_object).await, 2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn concurrent_reverse_copy_object_does_not_deadlock_with_reader_locks() {
|
||||
temp_env::async_with_vars([(ENV_OBJECT_LOCK_OPTIMIZATION_ENABLE, Some("false"))], async {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
|
||||
let bucket = format!("test-reverse-copy-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object_a = "test/a.bin";
|
||||
let object_b = "test/b.bin";
|
||||
let payload_a = vec![b'a'; 4 * 1024 * 1024];
|
||||
let payload_b = vec![b'b'; 4 * 1024 * 1024];
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object_a, &payload_a).await;
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object_b, &payload_b).await;
|
||||
|
||||
let a_to_b_input = CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: bucket.clone().into(),
|
||||
key: object_a.to_string().into(),
|
||||
version_id: None,
|
||||
})
|
||||
.bucket(bucket.clone())
|
||||
.key(object_b.to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
let b_to_a_input = CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: bucket.clone().into(),
|
||||
key: object_b.to_string().into(),
|
||||
version_id: None,
|
||||
})
|
||||
.bucket(bucket.clone())
|
||||
.key(object_a.to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let start = Arc::new(Barrier::new(3));
|
||||
let start_a = start.clone();
|
||||
let a_to_b = tokio::spawn(async move {
|
||||
start_a.wait().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
Box::pin(usecase.execute_copy_object(build_request(a_to_b_input, Method::PUT))).await
|
||||
});
|
||||
let start_b = start.clone();
|
||||
let b_to_a = tokio::spawn(async move {
|
||||
start_b.wait().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
Box::pin(usecase.execute_copy_object(build_request(b_to_a_input, Method::PUT))).await
|
||||
});
|
||||
|
||||
start.wait().await;
|
||||
|
||||
let (a_to_b_result, b_to_a_result) = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let (a_to_b_result, b_to_a_result) = tokio::join!(a_to_b, b_to_a);
|
||||
(
|
||||
a_to_b_result.expect("A-to-B CopyObject task should not panic"),
|
||||
b_to_a_result.expect("B-to-A CopyObject task should not panic"),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("reverse CopyObject operations should not deadlock");
|
||||
|
||||
a_to_b_result.expect("A-to-B CopyObject should succeed");
|
||||
b_to_a_result.expect("B-to-A CopyObject should succeed");
|
||||
|
||||
let final_a = read_object_bytes(&ecstore, bucket.as_str(), object_a).await;
|
||||
let final_b = read_object_bytes(&ecstore, bucket.as_str(), object_b).await;
|
||||
assert!(
|
||||
final_a == payload_a || final_a == payload_b,
|
||||
"object A must contain a complete copied payload"
|
||||
);
|
||||
assert!(
|
||||
final_b == payload_a || final_b == payload_b,
|
||||
"object B must contain a complete copied payload"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_and_copy_object_transition_immediately_via_usecases() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let fs = FS::new();
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let put_bucket = format!("test-api-put-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let put_object = "test/object.txt";
|
||||
let put_payload = b"Hello, immediate transition through put API!";
|
||||
|
||||
create_test_bucket(&ecstore, put_bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(put_bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
|
||||
let put_input = PutObjectInput::builder()
|
||||
.bucket(put_bucket.clone())
|
||||
.key(put_object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(put_payload)))
|
||||
.content_length(Some(put_payload.len() as i64))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
Box::pin(test_seams::put_object(&usecase, &fs, build_request(put_input, Method::PUT)))
|
||||
.await
|
||||
.expect("Failed to put object through usecase");
|
||||
|
||||
let put_info = wait_for_transition(&ecstore, put_bucket.as_str(), put_object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should transition immediately after put usecase");
|
||||
|
||||
assert_eq!(put_info.transitioned_object.status, "complete");
|
||||
assert_eq!(put_info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.contains(&put_info.transitioned_object.name).await);
|
||||
|
||||
let src_bucket = format!("test-api-copy-src-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let dst_bucket = format!("test-api-copy-dst-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let src_object = "test/source.txt";
|
||||
let dst_object = "test/copied.txt";
|
||||
let copy_payload = b"copy object immediate transition through copy API";
|
||||
|
||||
create_test_bucket(&ecstore, src_bucket.as_str()).await;
|
||||
create_test_bucket(&ecstore, dst_bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(dst_bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set destination lifecycle configuration");
|
||||
let _ = upload_test_object(&ecstore, src_bucket.as_str(), src_object, copy_payload).await;
|
||||
|
||||
let copy_input = CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: src_bucket.clone().into(),
|
||||
key: src_object.to_string().into(),
|
||||
version_id: None,
|
||||
})
|
||||
.bucket(dst_bucket.clone())
|
||||
.key(dst_object.to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
Box::pin(usecase.execute_copy_object(build_request(copy_input, Method::PUT)))
|
||||
.await
|
||||
.expect("Failed to copy object through usecase");
|
||||
|
||||
let copy_info = wait_for_transition(&ecstore, dst_bucket.as_str(), dst_object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("copied object should transition immediately after copy usecase");
|
||||
|
||||
assert_eq!(copy_info.transitioned_object.status, "complete");
|
||||
assert_eq!(copy_info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.contains(©_info.transitioned_object.name).await);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn compensation_driven_copy_still_completes_transition() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let src_bucket = format!("test-comp-copy-src-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let dst_bucket = format!("test-comp-copy-dst-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let src_object = "test/source.txt";
|
||||
let dst_object = "test/copied.txt";
|
||||
let payload = b"copy object should still transition after compensation";
|
||||
|
||||
create_test_bucket(&ecstore, src_bucket.as_str()).await;
|
||||
create_test_bucket(&ecstore, dst_bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(dst_bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set destination lifecycle configuration");
|
||||
let _ = upload_test_object(&ecstore, src_bucket.as_str(), src_object, payload).await;
|
||||
|
||||
let copy_input = CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: src_bucket.clone().into(),
|
||||
key: src_object.to_string().into(),
|
||||
version_id: None,
|
||||
})
|
||||
.bucket(dst_bucket.clone())
|
||||
.key(dst_object.to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
with_forced_immediate_enqueue_timeout(&backend, || async {
|
||||
Box::pin(usecase.execute_copy_object(build_request(copy_input, Method::PUT)))
|
||||
.await
|
||||
.expect("Failed to copy object through usecase");
|
||||
})
|
||||
.await;
|
||||
|
||||
let info = wait_for_transition(&ecstore, dst_bucket.as_str(), dst_object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("copied object should eventually transition after compensation backfill");
|
||||
|
||||
assert_eq!(backend.put_count().await, 1, "compensation should upload the copied version once");
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.contains(&info.transitioned_object.name).await);
|
||||
assert_eq!(read_object_bytes(&ecstore, dst_bucket.as_str(), dst_object).await, payload);
|
||||
}
|
||||
|
||||
/// CopyObject creates a new object on the destination key, so it must join
|
||||
/// bucket replication exactly like PutObject: compute one replication decision
|
||||
/// that drives both the persisted pending marker and the post-commit schedule
|
||||
/// (MinIO CopyObjectHandler parity). A count of 0 means a copied object is
|
||||
/// never scheduled for replication and — because the copy path clones the
|
||||
/// source metadata wholesale — the destination silently inherits the source's
|
||||
/// replication bookkeeping, faking a COMPLETED/REPLICA state for an object
|
||||
/// that never replicated. The second half of this test pins the metadata
|
||||
/// cleanup (MinIO filterReplicationStatusMetadata parity).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state usecase integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn copy_object_computes_replication_decision_and_strips_stale_status() {
|
||||
use super::super::storage_api::object_usecase::bucket::replication::MUST_REPLICATE_OBJECT_CALLS;
|
||||
use rustfs_utils::http::{
|
||||
SUFFIX_REPLICA_STATUS, SUFFIX_REPLICA_TIMESTAMP, SUFFIX_REPLICATION_STATUS, SUFFIX_REPLICATION_TIMESTAMP, get_str,
|
||||
insert_str,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-copy-repl-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let src_object = "copy/source.txt";
|
||||
let dst_object = "copy/destination.txt";
|
||||
let payload = b"copy replication decision payload";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
// Seed the source object with stale replication bookkeeping, as if it had
|
||||
// already replicated elsewhere (internal status/timestamp under both
|
||||
// compatibility prefixes, replica state, and the surfaced x-amz header).
|
||||
let mut stale_metadata = HashMap::new();
|
||||
insert_str(
|
||||
&mut stale_metadata,
|
||||
SUFFIX_REPLICATION_STATUS,
|
||||
"arn:minio:replication::stale:dst=COMPLETED;".to_string(),
|
||||
);
|
||||
insert_str(&mut stale_metadata, SUFFIX_REPLICATION_TIMESTAMP, "2024-01-01T00:00:00Z".to_string());
|
||||
insert_str(&mut stale_metadata, SUFFIX_REPLICA_STATUS, "REPLICA".to_string());
|
||||
insert_str(&mut stale_metadata, SUFFIX_REPLICA_TIMESTAMP, "2024-01-01T00:00:00Z".to_string());
|
||||
stale_metadata.insert(AMZ_BUCKET_REPLICATION_STATUS.to_string(), "COMPLETED".to_string());
|
||||
let mut reader = PutObjReader::from_vec(payload.to_vec());
|
||||
(*ecstore)
|
||||
.put_object(
|
||||
bucket.as_str(),
|
||||
src_object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
user_defined: stale_metadata,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to upload source object with stale replication metadata");
|
||||
|
||||
MUST_REPLICATE_OBJECT_CALLS.store(0, Ordering::SeqCst);
|
||||
|
||||
let copy_input = CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: bucket.clone().into(),
|
||||
key: src_object.into(),
|
||||
version_id: None,
|
||||
})
|
||||
.bucket(bucket.clone())
|
||||
.key(dst_object.to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
Box::pin(usecase.execute_copy_object(build_request(copy_input, Method::PUT)))
|
||||
.await
|
||||
.expect("Failed to copy object through usecase");
|
||||
|
||||
assert_eq!(
|
||||
MUST_REPLICATE_OBJECT_CALLS.load(Ordering::SeqCst),
|
||||
1,
|
||||
"CopyObject must compute the replication decision exactly once; 0 means the copied \
|
||||
object never enters bucket replication (P0-6, MinIO CopyObjectHandler parity)"
|
||||
);
|
||||
|
||||
let copied = ecstore
|
||||
.get_object_info(bucket.as_str(), dst_object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to read copied destination object info");
|
||||
assert!(
|
||||
get_str(&copied.user_defined, SUFFIX_REPLICATION_STATUS).is_none(),
|
||||
"destination must not inherit the source's replication status; got {:?}",
|
||||
get_str(&copied.user_defined, SUFFIX_REPLICATION_STATUS)
|
||||
);
|
||||
assert!(
|
||||
get_str(&copied.user_defined, SUFFIX_REPLICATION_TIMESTAMP).is_none(),
|
||||
"destination must not inherit the source's replication timestamp"
|
||||
);
|
||||
assert!(
|
||||
get_str(&copied.user_defined, SUFFIX_REPLICA_STATUS).is_none(),
|
||||
"destination must not inherit the source's replica status"
|
||||
);
|
||||
assert!(
|
||||
!copied
|
||||
.user_defined
|
||||
.keys()
|
||||
.any(|k| k.eq_ignore_ascii_case(AMZ_BUCKET_REPLICATION_STATUS)),
|
||||
"destination must not inherit the surfaced x-amz-replication-status header key"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
// 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.
|
||||
|
||||
//! DeleteObject and DeleteObjects tests (batch E owns both).
|
||||
|
||||
use super::common::*;
|
||||
use super::*;
|
||||
use crate::app::test_seams;
|
||||
|
||||
async fn wait_for_object_absence(ecstore: &Arc<ECStore>, bucket: &str, object: &str, timeout: Duration) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
if ecstore
|
||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state object-version integration test: runs serialized in the CI ILM Integration (serial) lane"]
|
||||
async fn undo_delete_only_removes_the_current_matching_delete_marker() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let fs = FS::new();
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-undo-delete-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
let put = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(b"visible again")))
|
||||
.content_length(Some(13))
|
||||
.build()
|
||||
.unwrap();
|
||||
Box::pin(test_seams::put_object(&usecase, &fs, build_request(put, Method::PUT)))
|
||||
.await
|
||||
.expect("object version should be written");
|
||||
let marker = Box::pin(
|
||||
usecase.execute_delete_object(build_request(
|
||||
DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::DELETE,
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.expect("delete marker should be created")
|
||||
.output
|
||||
.version_id
|
||||
.expect("versioned delete should return the marker version");
|
||||
|
||||
let delete_marker = || {
|
||||
DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.version_id(Some(marker.clone()))
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
let mut stale_req = build_request(delete_marker(), Method::DELETE);
|
||||
stale_req.headers.insert(
|
||||
RUSTFS_EXPECTED_CURRENT_VERSION_ID,
|
||||
HeaderValue::from_str(&Uuid::new_v4().to_string()).unwrap(),
|
||||
);
|
||||
let err = Box::pin(usecase.execute_delete_object(stale_req))
|
||||
.await
|
||||
.expect_err("mismatched delete marker must fail");
|
||||
assert_eq!(err.code(), &S3ErrorCode::PreconditionFailed);
|
||||
|
||||
let mut matching_req = build_request(delete_marker(), Method::DELETE);
|
||||
matching_req
|
||||
.headers
|
||||
.insert(RUSTFS_EXPECTED_CURRENT_VERSION_ID, HeaderValue::from_str(&marker).unwrap());
|
||||
Box::pin(usecase.execute_delete_object(matching_req))
|
||||
.await
|
||||
.expect("current matching delete marker should be removed");
|
||||
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), object).await, b"visible again");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn delete_transitioned_object_removes_remote_tier_copy_via_usecase() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-api-delete-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
let payload = b"delete transitioned object through delete API";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||
|
||||
lifecycle::bucket_lifecycle_ops::enqueue_transition_for_existing_objects(ecstore.clone(), bucket.as_str())
|
||||
.await
|
||||
.expect("Failed to enqueue transitioned object");
|
||||
|
||||
let transitioned = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should transition before delete usecase runs");
|
||||
let remote_object = transitioned.transitioned_object.name.clone();
|
||||
|
||||
assert!(backend.contains(&remote_object).await);
|
||||
|
||||
let mut req = build_request(
|
||||
DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::DELETE,
|
||||
);
|
||||
insert_header(&mut req.headers, SUFFIX_FORCE_DELETE, "true");
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
Box::pin(usecase.execute_delete_object(req))
|
||||
.await
|
||||
.expect("Failed to delete object through usecase");
|
||||
|
||||
assert!(
|
||||
wait_for_object_absence(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"object should be removed from hot tier after delete usecase"
|
||||
);
|
||||
|
||||
assert!(
|
||||
backend.wait_for_remote_absence(&remote_object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"transitioned object should be removed from remote tier after delete usecase"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn compensation_driven_transition_still_cleans_remote_tier_on_delete() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-compensation-delete-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
let payload = b"compensation should still preserve delete cleanup";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
|
||||
with_forced_immediate_enqueue_timeout(&backend, || async {
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||
})
|
||||
.await;
|
||||
|
||||
let transitioned = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should eventually transition after compensation backfill");
|
||||
assert_eq!(backend.put_count().await, 1, "compensation should upload the version once");
|
||||
let remote_object = transitioned.transitioned_object.name.clone();
|
||||
|
||||
assert!(backend.contains(&remote_object).await);
|
||||
|
||||
let mut req = build_request(
|
||||
DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::DELETE,
|
||||
);
|
||||
insert_header(&mut req.headers, SUFFIX_FORCE_DELETE, "true");
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
Box::pin(usecase.execute_delete_object(req))
|
||||
.await
|
||||
.expect("Failed to delete object through usecase after compensation-driven transition");
|
||||
|
||||
assert!(
|
||||
wait_for_object_absence(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"object should be removed from hot tier after delete usecase"
|
||||
);
|
||||
|
||||
assert!(
|
||||
backend.wait_for_remote_absence(&remote_object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"transitioned object should be removed from remote tier after delete usecase"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn compensation_driven_versioned_delete_still_creates_delete_marker() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-comp-versioned-delete-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
let payload = b"versioned delete should preserve transitioned remote version behind delete marker";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
|
||||
with_forced_immediate_enqueue_timeout(&backend, || async {
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||
})
|
||||
.await;
|
||||
|
||||
let transitioned = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should eventually transition after compensation backfill");
|
||||
assert_eq!(backend.put_count().await, 1, "compensation should upload the version once");
|
||||
let remote_object = transitioned.transitioned_object.name.clone();
|
||||
|
||||
assert!(backend.contains(&remote_object).await);
|
||||
|
||||
let req = build_request(
|
||||
DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::DELETE,
|
||||
);
|
||||
|
||||
Box::pin(usecase.execute_delete_object(req))
|
||||
.await
|
||||
.expect("Failed to issue versioned delete after compensation-driven transition");
|
||||
|
||||
assert!(
|
||||
wait_for_delete_marker(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"versioned delete should create a delete marker after compensation-driven transition"
|
||||
);
|
||||
assert!(
|
||||
backend.contains(&remote_object).await,
|
||||
"creating a delete marker should not remove the transitioned remote object version"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression pin for the DeleteObjects hot path: the bucket's versioning
|
||||
/// configuration must be resolved exactly once per request — not once per
|
||||
/// key, which used to cost up to 1000 identical metadata lookups per call.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial"]
|
||||
async fn delete_objects_resolves_bucket_versioning_once_per_request() {
|
||||
use super::super::storage_api::test::bucket::replication::DELETE_CONFIG_SNAPSHOT_LOADS;
|
||||
use super::super::storage_api::test::{ReqInfo, VERSIONING_CONFIG_LOOKUPS};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-delobjs-vers-once-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
let keys = ["batch/a.txt", "batch/b.txt", "batch/c.txt"];
|
||||
for key in keys {
|
||||
upload_test_object(&ecstore, bucket.as_str(), key, b"delete objects versioning payload").await;
|
||||
}
|
||||
|
||||
let input = DeleteObjectsInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.delete(Delete {
|
||||
objects: keys
|
||||
.iter()
|
||||
.map(|key| ObjectIdentifier {
|
||||
key: key.to_string(),
|
||||
version_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
quiet: None,
|
||||
})
|
||||
.build()
|
||||
.expect("delete objects input should build");
|
||||
|
||||
let mut req = build_request(input, Method::POST);
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let lookups_before = VERSIONING_CONFIG_LOOKUPS.load(Ordering::SeqCst);
|
||||
let snapshots_before = DELETE_CONFIG_SNAPSHOT_LOADS.load(Ordering::SeqCst);
|
||||
let response = usecase
|
||||
.execute_delete_objects(req)
|
||||
.await
|
||||
.expect("delete objects should succeed");
|
||||
|
||||
let output = response.output;
|
||||
let errors = output.errors.unwrap_or_default();
|
||||
assert!(errors.is_empty(), "DeleteObjects reported per-key errors: {errors:?}");
|
||||
assert_eq!(
|
||||
output.deleted.unwrap_or_default().len(),
|
||||
keys.len(),
|
||||
"every requested key should be deleted"
|
||||
);
|
||||
|
||||
let lookups = VERSIONING_CONFIG_LOOKUPS.load(Ordering::SeqCst) - lookups_before;
|
||||
let snapshots = DELETE_CONFIG_SNAPSHOT_LOADS.load(Ordering::SeqCst) - snapshots_before;
|
||||
assert_eq!(
|
||||
snapshots,
|
||||
1,
|
||||
"DeleteObjects must resolve delete admission config exactly once per request; got {snapshots} snapshots for {} keys",
|
||||
keys.len()
|
||||
);
|
||||
assert_eq!(lookups, 0, "DeleteObjects must not fall back to legacy per-key versioning lookups");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial"]
|
||||
async fn delete_objects_treats_empty_directory_version_as_absent() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-delobjs-empty-version-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "directory/";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
upload_test_object(&ecstore, &bucket, object, b"").await;
|
||||
|
||||
let input = DeleteObjectsInput::builder()
|
||||
.bucket(bucket)
|
||||
.delete(Delete {
|
||||
objects: vec![ObjectIdentifier {
|
||||
key: object.to_string(),
|
||||
version_id: Some(" \t ".to_string()),
|
||||
..Default::default()
|
||||
}],
|
||||
quiet: None,
|
||||
})
|
||||
.build()
|
||||
.expect("delete objects input should build");
|
||||
let mut req = build_request(input, Method::POST);
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let response = usecase
|
||||
.execute_delete_objects(req)
|
||||
.await
|
||||
.expect("empty VersionId should be treated as an absent version");
|
||||
let deleted = response
|
||||
.output
|
||||
.deleted
|
||||
.expect("delete response should contain the directory entry");
|
||||
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert_eq!(
|
||||
deleted[0].delete_marker, None,
|
||||
"the synthetic null version must be purged, not hidden by a new marker"
|
||||
);
|
||||
assert_eq!(deleted[0].version_id, None, "the synthetic null sentinel must stay off the wire");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial"]
|
||||
async fn delete_objects_preserves_explicit_null_directory_version() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-delobjs-null-version-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "directory/";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
upload_test_object(&ecstore, &bucket, object, b"").await;
|
||||
|
||||
let input = DeleteObjectsInput::builder()
|
||||
.bucket(bucket)
|
||||
.delete(Delete {
|
||||
objects: vec![ObjectIdentifier {
|
||||
key: object.to_string(),
|
||||
version_id: Some("null".to_string()),
|
||||
..Default::default()
|
||||
}],
|
||||
quiet: None,
|
||||
})
|
||||
.build()
|
||||
.expect("delete objects input should build");
|
||||
let mut req = build_request(input, Method::POST);
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let response = usecase
|
||||
.execute_delete_objects(req)
|
||||
.await
|
||||
.expect("explicit null VersionId should delete the directory's null version");
|
||||
let deleted = response
|
||||
.output
|
||||
.deleted
|
||||
.expect("delete response should contain the directory entry");
|
||||
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert_eq!(deleted[0].delete_marker, None);
|
||||
assert_eq!(deleted[0].version_id.as_deref(), Some("null"));
|
||||
}
|
||||
|
||||
async fn install_malformed_versioning_config(bucket: &str) {
|
||||
use super::super::storage_api::test::{get_global_bucket_metadata_sys, set_bucket_metadata};
|
||||
|
||||
let sys = get_global_bucket_metadata_sys().expect("bucket metadata system should be initialized");
|
||||
let metadata = {
|
||||
let sys = sys.read().await;
|
||||
sys.get(bucket)
|
||||
.await
|
||||
.expect("bucket metadata should be cached before corruption injection")
|
||||
};
|
||||
let mut metadata = (*metadata).clone();
|
||||
metadata.versioning_config_xml = b"<VersioningConfiguration>".to_vec();
|
||||
metadata.versioning_config = None;
|
||||
set_bucket_metadata(bucket.to_string(), metadata)
|
||||
.await
|
||||
.expect("malformed versioning metadata should be installed for the request test");
|
||||
}
|
||||
|
||||
async fn install_malformed_replication_config(bucket: &str) {
|
||||
use super::super::storage_api::test::{get_global_bucket_metadata_sys, set_bucket_metadata};
|
||||
|
||||
let sys = get_global_bucket_metadata_sys().expect("bucket metadata system should be initialized");
|
||||
let metadata = {
|
||||
let sys = sys.read().await;
|
||||
sys.get(bucket)
|
||||
.await
|
||||
.expect("bucket metadata should be cached before corruption injection")
|
||||
};
|
||||
let mut metadata = (*metadata).clone();
|
||||
metadata.replication_config_xml = b"<ReplicationConfiguration>".to_vec();
|
||||
metadata.replication_config = None;
|
||||
set_bucket_metadata(bucket.to_string(), metadata)
|
||||
.await
|
||||
.expect("malformed replication metadata should be installed for the request test");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn delete_object_versioning_config_failure_leaves_latest_object_intact() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-delete-versioning-fail-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "object.txt";
|
||||
let payload = b"local delete must not commit before versioning admission";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
upload_test_object(&ecstore, &bucket, object, payload).await;
|
||||
install_malformed_versioning_config(&bucket).await;
|
||||
|
||||
let input = DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.expect("delete input should build");
|
||||
let mut req = build_request(input, Method::DELETE);
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let err = usecase
|
||||
.execute_delete_object(req)
|
||||
.await
|
||||
.expect_err("versioning config failure must reject DeleteObject");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable);
|
||||
assert_eq!(read_object_bytes(&ecstore, &bucket, object).await, payload);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn delete_object_replication_config_failure_leaves_latest_object_intact() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-delete-repl-fail-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "object.txt";
|
||||
let payload = b"local delete must not commit before replication admission";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
upload_test_object(&ecstore, &bucket, object, payload).await;
|
||||
install_malformed_replication_config(&bucket).await;
|
||||
|
||||
let input = DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.expect("delete input should build");
|
||||
let mut req = build_request(input, Method::DELETE);
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let err = usecase
|
||||
.execute_delete_object(req)
|
||||
.await
|
||||
.expect_err("replication config failure must reject DeleteObject");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
assert_eq!(read_object_bytes(&ecstore, &bucket, object).await, payload);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
async fn delete_object_uses_one_replication_config_generation() {
|
||||
use super::super::object_usecase::install_delete_snapshot_test_hook;
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
use super::super::storage_api::test::bucket::replication::take_scheduled_replication_deletes;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-delete-config-generation-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "object.txt";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
|
||||
let mut reader = PutObjReader::from_vec(b"generation-bound delete".to_vec());
|
||||
let uploaded = ecstore
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("versioned object should be uploaded");
|
||||
let version_id = uploaded.version_id.expect("versioned upload should return a version ID");
|
||||
let version_id_string = version_id.to_string();
|
||||
|
||||
install_delete_replication_config(&bucket, true, false, None).await;
|
||||
assert!(take_scheduled_replication_deletes().is_empty());
|
||||
|
||||
let loaded = Arc::new(Barrier::new(2));
|
||||
let resume = Arc::new(Barrier::new(2));
|
||||
install_delete_snapshot_test_hook(bucket.clone(), Arc::clone(&loaded), Arc::clone(&resume));
|
||||
|
||||
let input = DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.version_id(Some(version_id_string.clone()))
|
||||
.build()
|
||||
.expect("version delete input should build");
|
||||
let mut req = build_request(input, Method::DELETE);
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let update_config = async {
|
||||
loaded.wait().await;
|
||||
install_delete_replication_config(&bucket, false, false, None).await;
|
||||
resume.wait().await;
|
||||
};
|
||||
let (response, ()) = tokio::join!(usecase.execute_delete_object(req), update_config);
|
||||
let response = response.expect("delete admitted by the first config generation should succeed");
|
||||
|
||||
assert_eq!(response.output.version_id.as_deref(), Some(version_id_string.as_str()));
|
||||
let scheduled = take_scheduled_replication_deletes();
|
||||
assert_eq!(scheduled.len(), 1, "the first config generation must control post-delete queueing");
|
||||
assert_eq!(scheduled[0].version_id, Some(version_id));
|
||||
let state = scheduled[0]
|
||||
.replication_state
|
||||
.as_ref()
|
||||
.expect("admitted version purge should carry replication state");
|
||||
assert_eq!(
|
||||
state.version_purge_status_internal.as_deref(),
|
||||
Some("arn:aws:s3:::target-bucket=PENDING;")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn replica_delete_uses_authorized_source_version_and_its_tags() {
|
||||
use super::super::object_usecase::install_delete_source_test_hook;
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
use super::super::storage_api::test::bucket::replication::{ReplicationStatusType, take_scheduled_replication_deletes};
|
||||
use rustfs_utils::http::headers::AMZ_OBJECT_TAGGING;
|
||||
use rustfs_utils::http::{SUFFIX_SOURCE_REPLICATION_REQUEST, SUFFIX_SOURCE_VERSION_ID};
|
||||
use std::collections::HashMap;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-replica-source-version-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "object.txt";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
install_delete_replication_config(&bucket, true, true, Some(("generation", "updated"))).await;
|
||||
|
||||
let mut old_opts = ObjectOptions {
|
||||
versioned: true,
|
||||
user_defined: HashMap::from([(AMZ_OBJECT_TAGGING.to_string(), "generation=old".to_string())]),
|
||||
..Default::default()
|
||||
};
|
||||
old_opts.set_replica_status(ReplicationStatusType::Replica);
|
||||
let mut old_reader = PutObjReader::from_vec(b"old replica version".to_vec());
|
||||
let old = ecstore
|
||||
.put_object(&bucket, object, &mut old_reader, &old_opts)
|
||||
.await
|
||||
.expect("old replica version should be uploaded");
|
||||
let old_version_id = old.version_id.expect("old replica version should have a version ID");
|
||||
let old_version_id_string = old_version_id.to_string();
|
||||
|
||||
let mut latest_opts = ObjectOptions {
|
||||
versioned: true,
|
||||
user_defined: HashMap::from([(AMZ_OBJECT_TAGGING.to_string(), "generation=latest".to_string())]),
|
||||
..Default::default()
|
||||
};
|
||||
latest_opts.set_replica_status(ReplicationStatusType::Replica);
|
||||
let mut latest_reader = PutObjReader::from_vec(b"latest replica version".to_vec());
|
||||
let latest = ecstore
|
||||
.put_object(&bucket, object, &mut latest_reader, &latest_opts)
|
||||
.await
|
||||
.expect("latest replica version should be uploaded");
|
||||
assert_ne!(latest.version_id, Some(old_version_id));
|
||||
assert!(take_scheduled_replication_deletes().is_empty());
|
||||
|
||||
let source_loaded = Arc::new(Barrier::new(2));
|
||||
let resume_delete = Arc::new(Barrier::new(2));
|
||||
install_delete_source_test_hook(bucket.clone(), Arc::clone(&source_loaded), Arc::clone(&resume_delete));
|
||||
|
||||
let input = DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.expect("replica delete input should build");
|
||||
let mut req = build_request(input, Method::DELETE);
|
||||
req.headers
|
||||
.insert(AMZ_BUCKET_REPLICATION_STATUS, HeaderValue::from_static("REPLICA"));
|
||||
insert_header(&mut req.headers, SUFFIX_SOURCE_VERSION_ID, old_version_id_string.clone());
|
||||
insert_header(&mut req.headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let update_tags = async {
|
||||
source_loaded.wait().await;
|
||||
ecstore
|
||||
.put_object_tags(
|
||||
&bucket,
|
||||
object,
|
||||
"generation=updated",
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(old_version_id_string.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("tag update ordered before the locked delete should succeed");
|
||||
resume_delete.wait().await;
|
||||
};
|
||||
let (response, ()) = tokio::join!(usecase.execute_delete_object(req), update_tags);
|
||||
let response = response.expect("authorized replica version delete should succeed");
|
||||
|
||||
assert_eq!(response.output.version_id.as_deref(), Some(old_version_id_string.as_str()));
|
||||
let scheduled = take_scheduled_replication_deletes();
|
||||
assert_eq!(
|
||||
scheduled.len(),
|
||||
1,
|
||||
"the tag committed after the advisory pre-read must control downstream fanout"
|
||||
);
|
||||
assert_eq!(scheduled[0].version_id, Some(old_version_id));
|
||||
assert_eq!(
|
||||
scheduled[0]
|
||||
.replication_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.version_purge_status_internal.as_deref()),
|
||||
Some("arn:aws:s3:::target-bucket=PENDING;")
|
||||
);
|
||||
assert_eq!(read_object_bytes(&ecstore, &bucket, object).await, b"latest replica version");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn force_delete_replication_config_failure_leaves_prefix_intact() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-force-delete-repl-fail-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let prefix = "prefix/";
|
||||
let object = "prefix/object.txt";
|
||||
let payload = b"force delete must validate replication metadata before deleting descendants";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
upload_test_object(&ecstore, &bucket, object, payload).await;
|
||||
install_malformed_replication_config(&bucket).await;
|
||||
|
||||
let input = DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(prefix.to_string())
|
||||
.build()
|
||||
.expect("delete input should build");
|
||||
let mut req = build_request(input, Method::DELETE);
|
||||
insert_header(&mut req.headers, SUFFIX_FORCE_DELETE, "true");
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let err = usecase
|
||||
.execute_delete_object(req)
|
||||
.await
|
||||
.expect_err("replication config failure must reject force delete");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
assert_eq!(read_object_bytes(&ecstore, &bucket, object).await, payload);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn replica_delete_replication_config_failure_leaves_object_intact() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-replica-delete-repl-fail-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "object.txt";
|
||||
let payload = b"incoming replica delete must use the admitted request snapshot";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
upload_test_object(&ecstore, &bucket, object, payload).await;
|
||||
install_malformed_replication_config(&bucket).await;
|
||||
|
||||
let input = DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.expect("delete input should build");
|
||||
let mut req = build_request(input, Method::DELETE);
|
||||
req.headers
|
||||
.insert(AMZ_BUCKET_REPLICATION_STATUS, HeaderValue::from_static("REPLICA"));
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let err = usecase
|
||||
.execute_delete_object(req)
|
||||
.await
|
||||
.expect_err("replication config failure must reject incoming replica delete");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
assert_eq!(read_object_bytes(&ecstore, &bucket, object).await, payload);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn delete_objects_replication_config_failure_does_not_call_storage_delete() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-delete-repls-fail-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "object.txt";
|
||||
let payload = b"batch delete must fail closed on replication metadata errors";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
upload_test_object(&ecstore, &bucket, object, payload).await;
|
||||
install_malformed_replication_config(&bucket).await;
|
||||
|
||||
let input = DeleteObjectsInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.delete(Delete {
|
||||
objects: vec![ObjectIdentifier {
|
||||
key: object.to_string(),
|
||||
version_id: None,
|
||||
..Default::default()
|
||||
}],
|
||||
quiet: None,
|
||||
})
|
||||
.build()
|
||||
.expect("delete objects input should build");
|
||||
let mut req = build_request(input, Method::POST);
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let err = usecase
|
||||
.execute_delete_objects(req)
|
||||
.await
|
||||
.expect_err("replication config failure must reject DeleteObjects");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
assert_eq!(read_object_bytes(&ecstore, &bucket, object).await, payload);
|
||||
}
|
||||
|
||||
/// Regression pin for the nonexistent-bucket fast path: GET/HEAD/DeleteObject
|
||||
/// must decide NoSuchBucket from the bucket-existence check, before any bucket
|
||||
/// versioning lookup runs — and cheap request-shape validation still wins over
|
||||
/// bucket existence for GET.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial"]
|
||||
async fn nonexistent_bucket_fails_before_versioning_lookup_on_get_head_delete() {
|
||||
use super::super::storage_api::test::VERSIONING_CONFIG_LOOKUPS;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (_disk_paths, _ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
// Never created.
|
||||
let bucket = format!("test-missing-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
|
||||
// Request-shape validation keeps precedence over bucket existence: GET with
|
||||
// both range and partNumber is InvalidArgument even on a missing bucket.
|
||||
let get_invalid = GetObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key("k.txt".to_string())
|
||||
.range(Some(Range::Int { first: 0, last: Some(1) }))
|
||||
.part_number(Some(1))
|
||||
.build()
|
||||
.expect("get input should build");
|
||||
let err = Box::pin(test_seams::get_object(&usecase, build_request(get_invalid, Method::GET)))
|
||||
.await
|
||||
.expect_err("range+partNumber must be rejected");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
|
||||
let lookups_before = VERSIONING_CONFIG_LOOKUPS.load(Ordering::SeqCst);
|
||||
|
||||
let get = GetObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key("k.txt".to_string())
|
||||
.build()
|
||||
.expect("get input should build");
|
||||
let err = Box::pin(test_seams::get_object(&usecase, build_request(get, Method::GET)))
|
||||
.await
|
||||
.expect_err("GET on a missing bucket must fail");
|
||||
assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket);
|
||||
|
||||
// Bucket existence beats versionId-format validation (which lives in the
|
||||
// opts builders) for HEAD and DeleteObject.
|
||||
let head = HeadObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key("k.txt".to_string())
|
||||
.version_id(Some("not-a-uuid".to_string()))
|
||||
.build()
|
||||
.expect("head input should build");
|
||||
let err = Box::pin(test_seams::head_object(&usecase, build_request(head, Method::HEAD)))
|
||||
.await
|
||||
.expect_err("HEAD on a missing bucket must fail");
|
||||
assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket);
|
||||
|
||||
let del = DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key("k.txt".to_string())
|
||||
.version_id(Some("not-a-uuid".to_string()))
|
||||
.build()
|
||||
.expect("delete input should build");
|
||||
let err = Box::pin(usecase.execute_delete_object(build_request(del, Method::DELETE)))
|
||||
.await
|
||||
.expect_err("DeleteObject on a missing bucket must fail");
|
||||
assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket);
|
||||
|
||||
let lookups = VERSIONING_CONFIG_LOOKUPS.load(Ordering::SeqCst) - lookups_before;
|
||||
assert_eq!(
|
||||
lookups, 0,
|
||||
"requests naming a nonexistent bucket must fail before any bucket versioning lookup; got {lookups}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// 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.
|
||||
|
||||
//! PutBucketLifecycleConfiguration tests (batch A3 owns the lifecycle operations).
|
||||
|
||||
use super::common::*;
|
||||
use super::*;
|
||||
|
||||
fn expiration_lifecycle_configuration(prefix: &str) -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
expiration: Some(LifecycleExpiration {
|
||||
date: Some(Timestamp::from(
|
||||
time::OffsetDateTime::now_utc()
|
||||
.replace_time(time::Time::MIDNIGHT)
|
||||
.saturating_sub(time::Duration::days(1)),
|
||||
)),
|
||||
days: None,
|
||||
expired_object_delete_marker: None,
|
||||
..Default::default()
|
||||
}),
|
||||
filter: Some(LifecycleRuleFilter {
|
||||
and: None,
|
||||
object_size_greater_than: None,
|
||||
object_size_less_than: None,
|
||||
prefix: Some(prefix.to_string()),
|
||||
tag: None,
|
||||
..Default::default()
|
||||
}),
|
||||
id: Some("expire-existing".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn expired_object_delete_marker_lifecycle_configuration() -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
expiration: Some(LifecycleExpiration {
|
||||
expired_object_delete_marker: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
filter: Some(LifecycleRuleFilter {
|
||||
prefix: Some("test/".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
id: Some("expired-delete-marker".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn del_marker_expiration_lifecycle_configuration(days: i32) -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: Some(DelMarkerExpiration { days: Some(days) }),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(30),
|
||||
..Default::default()
|
||||
}),
|
||||
filter: Some(LifecycleRuleFilter {
|
||||
prefix: Some("test/".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
id: Some("del-marker-expiration".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn compensation_driven_delete_marker_still_honors_lifecycle_cleanup() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket_usecase = DefaultBucketUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-comp-del-marker-cleanup-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
let payload = b"delete marker lifecycle should still clean up after compensation-driven transition";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set transition lifecycle configuration");
|
||||
|
||||
with_forced_immediate_enqueue_timeout(&backend, || async {
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||
})
|
||||
.await;
|
||||
|
||||
let transitioned = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should eventually transition after compensation backfill");
|
||||
assert_eq!(backend.put_count().await, 1, "compensation should upload the version once");
|
||||
let remote_object = transitioned.transitioned_object.name.clone();
|
||||
|
||||
assert!(backend.contains(&remote_object).await);
|
||||
|
||||
let req = build_request(
|
||||
DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::DELETE,
|
||||
);
|
||||
|
||||
Box::pin(usecase.execute_delete_object(req))
|
||||
.await
|
||||
.expect("Failed to issue versioned delete after compensation-driven transition");
|
||||
|
||||
assert!(
|
||||
wait_for_delete_marker(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"versioned delete should create a delete marker before lifecycle cleanup"
|
||||
);
|
||||
assert!(
|
||||
backend.contains(&remote_object).await,
|
||||
"delete marker creation should keep the transitioned remote object version"
|
||||
);
|
||||
|
||||
let req = build_request(
|
||||
PutBucketLifecycleConfigurationInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.lifecycle_configuration(Some(expiration_lifecycle_configuration("test/")))
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::PUT,
|
||||
);
|
||||
let prepared = req
|
||||
.input
|
||||
.lifecycle_configuration
|
||||
.clone()
|
||||
.map(crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s)
|
||||
.transpose();
|
||||
bucket_usecase
|
||||
.execute_put_bucket_lifecycle_configuration(req, prepared)
|
||||
.await
|
||||
.expect("Failed to update lifecycle configuration for delete marker cleanup");
|
||||
|
||||
assert!(
|
||||
wait_for_delete_marker(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"delete marker should remain visible after lifecycle update until cleanup completes"
|
||||
);
|
||||
assert!(
|
||||
backend.contains(&remote_object).await,
|
||||
"delete marker lifecycle cleanup should not remove the transitioned remote object version"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_bucket_lifecycle_configuration_expires_existing_objects() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultBucketUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-api-expire-existing-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/existing.txt";
|
||||
let payload = b"expire existing object after lifecycle update";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||
|
||||
let req = build_request(
|
||||
PutBucketLifecycleConfigurationInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.lifecycle_configuration(Some(expiration_lifecycle_configuration("test/")))
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::PUT,
|
||||
);
|
||||
|
||||
let prepared = req
|
||||
.input
|
||||
.lifecycle_configuration
|
||||
.clone()
|
||||
.map(crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s)
|
||||
.transpose();
|
||||
usecase
|
||||
.execute_put_bucket_lifecycle_configuration(req, prepared)
|
||||
.await
|
||||
.expect("Failed to update lifecycle configuration");
|
||||
|
||||
assert!(
|
||||
wait_for_delete_marker(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"existing object should be lifecycle-deleted after lifecycle update"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_bucket_lifecycle_configuration_allows_expired_delete_marker_on_object_lock_bucket() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultBucketUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-lock-expired-del-marker-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_object_lock_enabled(bucket.as_str())
|
||||
.await
|
||||
.expect("Failed to enable object lock for bucket");
|
||||
|
||||
let req = build_request(
|
||||
PutBucketLifecycleConfigurationInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.lifecycle_configuration(Some(expired_object_delete_marker_lifecycle_configuration()))
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::PUT,
|
||||
);
|
||||
|
||||
let prepared = req
|
||||
.input
|
||||
.lifecycle_configuration
|
||||
.clone()
|
||||
.map(crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s)
|
||||
.transpose();
|
||||
usecase
|
||||
.execute_put_bucket_lifecycle_configuration(req, prepared)
|
||||
.await
|
||||
.expect("ExpiredObjectDeleteMarker should be accepted on object-lock bucket");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_bucket_lifecycle_configuration_rejects_del_marker_expiration_on_object_lock_bucket() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultBucketUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-lock-del-marker-exp-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_object_lock_enabled(bucket.as_str())
|
||||
.await
|
||||
.expect("Failed to enable object lock for bucket");
|
||||
|
||||
let req = build_request(
|
||||
PutBucketLifecycleConfigurationInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.lifecycle_configuration(Some(del_marker_expiration_lifecycle_configuration(1)))
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::PUT,
|
||||
);
|
||||
|
||||
let prepared = req
|
||||
.input
|
||||
.lifecycle_configuration
|
||||
.clone()
|
||||
.map(crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s)
|
||||
.transpose();
|
||||
let err = usecase
|
||||
.execute_put_bucket_lifecycle_configuration(req, prepared)
|
||||
.await
|
||||
.expect_err("DelMarkerExpiration must be rejected on object-lock bucket");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
let message = err.message().unwrap_or_default();
|
||||
assert!(
|
||||
message.contains(
|
||||
"ExpiredObjectAllVersions element and DelMarkerExpiration action cannot be used on an object locked bucket"
|
||||
),
|
||||
"unexpected error message: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_bucket_lifecycle_configuration_rejects_zero_day_del_marker_expiration_on_object_lock_bucket() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultBucketUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-lock-del-marker-exp0-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_object_lock_enabled(bucket.as_str())
|
||||
.await
|
||||
.expect("Failed to enable object lock for bucket");
|
||||
|
||||
let req = build_request(
|
||||
PutBucketLifecycleConfigurationInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.lifecycle_configuration(Some(del_marker_expiration_lifecycle_configuration(0)))
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::PUT,
|
||||
);
|
||||
|
||||
let prepared = req
|
||||
.input
|
||||
.lifecycle_configuration
|
||||
.clone()
|
||||
.map(crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s)
|
||||
.transpose();
|
||||
let err = usecase
|
||||
.execute_put_bucket_lifecycle_configuration(req, prepared)
|
||||
.await
|
||||
.expect_err("DelMarkerExpiration with zero days must be rejected on object-lock bucket");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
let message = err.message().unwrap_or_default();
|
||||
assert!(
|
||||
message.contains("Days must be a positive integer with DelMarkerExpiration"),
|
||||
"unexpected error message: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_bucket_lifecycle_configuration_rejects_zero_day_expiration() {
|
||||
// S3 compatibility (ceph s3-tests `test_lifecycle_expiration_days0`): a rule with
|
||||
// Expiration{Days:0} must be rejected with InvalidArgument through the real
|
||||
// PutBucketLifecycleConfiguration handler, not merely at the crates/lifecycle
|
||||
// io::Error layer. No object lock required.
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultBucketUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-zero-day-exp-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
let lifecycle = BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(0),
|
||||
..Default::default()
|
||||
}),
|
||||
filter: Some(LifecycleRuleFilter {
|
||||
prefix: Some("days0/".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
id: Some("expire-zero-days".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let req = build_request(
|
||||
PutBucketLifecycleConfigurationInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.lifecycle_configuration(Some(lifecycle))
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::PUT,
|
||||
);
|
||||
|
||||
let prepared = req
|
||||
.input
|
||||
.lifecycle_configuration
|
||||
.clone()
|
||||
.map(crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s)
|
||||
.transpose();
|
||||
let err = usecase
|
||||
.execute_put_bucket_lifecycle_configuration(req, prepared)
|
||||
.await
|
||||
.expect_err("zero-day expiration must be rejected with InvalidArgument");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
let message = err.message().unwrap_or_default();
|
||||
assert!(
|
||||
message.contains("'Days' for Expiration action must be a positive integer"),
|
||||
"unexpected error message: {message}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// 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.
|
||||
|
||||
//! CompleteMultipartUpload tests (batch E owns CompleteMultipartUpload).
|
||||
|
||||
use super::common::*;
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn complete_multipart_upload_transitions_immediately_via_usecase() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultMultipartUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-api-mpu-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/multipart.txt";
|
||||
let payload = b"multipart immediate transition through complete API";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
|
||||
let upload = ecstore
|
||||
.new_multipart_upload(bucket.as_str(), object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to create multipart upload");
|
||||
|
||||
let mut reader = PutObjReader::from_vec(payload.to_vec());
|
||||
let uploaded_part = ecstore
|
||||
.put_object_part(bucket.as_str(), object, &upload.upload_id, 1, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to upload multipart part");
|
||||
|
||||
let complete_input = CompleteMultipartUploadInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.upload_id(upload.upload_id.clone())
|
||||
.multipart_upload(Some(CompletedMultipartUpload {
|
||||
parts: Some(vec![CompletedPart {
|
||||
part_number: Some(1),
|
||||
e_tag: uploaded_part.etag.clone().map(|etag| to_s3s_etag(&etag)),
|
||||
..Default::default()
|
||||
}]),
|
||||
}))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let complete_output = Box::pin(usecase.execute_complete_multipart_upload(build_request(complete_input, Method::POST)))
|
||||
.await
|
||||
.expect("Failed to complete multipart upload through usecase");
|
||||
assert!(
|
||||
complete_output.output.version_id.is_some(),
|
||||
"versioned CompleteMultipartUpload should return the created version ID"
|
||||
);
|
||||
|
||||
let info = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("multipart object should transition immediately after complete usecase");
|
||||
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.contains(&info.transitioned_object.name).await);
|
||||
}
|
||||
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
#[test]
|
||||
fn compensation_driven_complete_multipart_upload_still_transitions() {
|
||||
std::thread::Builder::new()
|
||||
.name("lifecycle-compensation-complete-multipart".to_string())
|
||||
.stack_size(32 * 1024 * 1024)
|
||||
.spawn(|| {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("compensation multipart test runtime should build");
|
||||
|
||||
runtime.block_on(compensation_driven_complete_multipart_upload_still_transitions_inner());
|
||||
})
|
||||
.expect("compensation multipart test thread should spawn")
|
||||
.join()
|
||||
.expect("compensation multipart test thread should finish");
|
||||
}
|
||||
|
||||
async fn compensation_driven_complete_multipart_upload_still_transitions_inner() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultMultipartUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-comp-mpu-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/multipart.txt";
|
||||
let payload = b"multipart should still transition after compensation";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
|
||||
let upload = ecstore
|
||||
.new_multipart_upload(bucket.as_str(), object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to create multipart upload");
|
||||
|
||||
let mut reader = PutObjReader::from_vec(payload.to_vec());
|
||||
let uploaded_part = ecstore
|
||||
.put_object_part(bucket.as_str(), object, &upload.upload_id, 1, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to upload multipart part");
|
||||
|
||||
let complete_input = CompleteMultipartUploadInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.upload_id(upload.upload_id.clone())
|
||||
.multipart_upload(Some(CompletedMultipartUpload {
|
||||
parts: Some(vec![CompletedPart {
|
||||
part_number: Some(1),
|
||||
e_tag: uploaded_part.etag.clone().map(|etag| to_s3s_etag(&etag)),
|
||||
..Default::default()
|
||||
}]),
|
||||
}))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
with_forced_immediate_enqueue_timeout(&backend, || async {
|
||||
Box::pin(usecase.execute_complete_multipart_upload(build_request(complete_input, Method::POST)))
|
||||
.await
|
||||
.expect("Failed to complete multipart upload through usecase");
|
||||
})
|
||||
.await;
|
||||
|
||||
let info = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("multipart object should eventually transition after compensation backfill");
|
||||
|
||||
assert_eq!(backend.put_count().await, 1, "compensation should upload the version once");
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.contains(&info.transitioned_object.name).await);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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.
|
||||
|
||||
//! PutObjectRetention and PutObjectLegalHold tests (batch B1 owns both).
|
||||
|
||||
use super::common::*;
|
||||
use super::*;
|
||||
|
||||
/// The object-lock handlers do not go through the object PUT path, so they must schedule
|
||||
/// replication themselves. Without it a retention or legal hold applied after upload stays
|
||||
/// local and the replica keeps its previous, unprotected state (a WORM object that is still
|
||||
/// deletable on the peer). This observes the shared decision counter: each handler must
|
||||
/// compute a replication decision exactly once when replication is configured, which is
|
||||
/// the step that drives both the persisted pending marker and the schedule.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state usecase integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn object_lock_handlers_schedule_replication() {
|
||||
use super::super::storage_api::object_usecase::bucket::replication::MUST_REPLICATE_OBJECT_CALLS;
|
||||
use crate::app::storage_api::s3::S3;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let fs = FS::new();
|
||||
|
||||
let bucket = format!("test-lock-repl-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "locked/object.txt";
|
||||
|
||||
(*ecstore)
|
||||
.make_bucket(
|
||||
bucket.as_str(),
|
||||
&MakeBucketOptions {
|
||||
lock_enabled: true,
|
||||
versioning_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create object-lock bucket");
|
||||
upload_test_object(&ecstore, bucket.as_str(), object, b"worm payload").await;
|
||||
|
||||
// PutObjectRetention must compute a replication decision.
|
||||
MUST_REPLICATE_OBJECT_CALLS.store(0, Ordering::SeqCst);
|
||||
let retention_input = PutObjectRetentionInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.retention(Some(ObjectLockRetention {
|
||||
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
|
||||
retain_until_date: Some(time::macros::datetime!(2030-01-01 00:00:00 UTC).into()),
|
||||
}))
|
||||
.build()
|
||||
.unwrap();
|
||||
fs.put_object_retention(build_request(retention_input, Method::PUT))
|
||||
.await
|
||||
.expect("put_object_retention should succeed");
|
||||
assert_eq!(
|
||||
MUST_REPLICATE_OBJECT_CALLS.load(Ordering::SeqCst),
|
||||
1,
|
||||
"PutObjectRetention must compute the replication decision exactly once; 0 means the \
|
||||
retention change never replicates and the peer copy stays unprotected"
|
||||
);
|
||||
|
||||
// A bucket without a replication configuration can skip the decision.
|
||||
MUST_REPLICATE_OBJECT_CALLS.store(0, Ordering::SeqCst);
|
||||
let no_replication_input = PutObjectLegalHoldInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.legal_hold(Some(ObjectLockLegalHold {
|
||||
status: Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::OFF)),
|
||||
}))
|
||||
.build()
|
||||
.unwrap();
|
||||
fs.put_object_legal_hold(build_request(no_replication_input, Method::PUT))
|
||||
.await
|
||||
.expect("legal hold without replication should succeed");
|
||||
assert_eq!(
|
||||
MUST_REPLICATE_OBJECT_CALLS.load(Ordering::SeqCst),
|
||||
0,
|
||||
"a bucket without replication must skip the decision"
|
||||
);
|
||||
|
||||
// A configured bucket must still evaluate the metadata replication decision.
|
||||
install_delete_replication_config(&bucket, true, false, None).await;
|
||||
MUST_REPLICATE_OBJECT_CALLS.store(0, Ordering::SeqCst);
|
||||
let legal_hold_input = PutObjectLegalHoldInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.legal_hold(Some(ObjectLockLegalHold {
|
||||
status: Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::ON)),
|
||||
}))
|
||||
.build()
|
||||
.unwrap();
|
||||
fs.put_object_legal_hold(build_request(legal_hold_input, Method::PUT))
|
||||
.await
|
||||
.expect("put_object_legal_hold should succeed");
|
||||
assert_eq!(
|
||||
MUST_REPLICATE_OBJECT_CALLS.load(Ordering::SeqCst),
|
||||
1,
|
||||
"PutObjectLegalHold must compute the replication decision exactly once; 0 means the \
|
||||
legal hold never replicates and the peer copy stays deletable"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// 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.
|
||||
|
||||
//! PutObject tests (batch D owns PutObject).
|
||||
|
||||
use super::common::*;
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_object_if_none_match_existing_object_returns_precondition_failed() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let fs = FS::new();
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-put-if-none-match-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
let initial_payload = b"initial conditional put payload";
|
||||
let replacement_payload = b"replacement conditional put payload";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
let initial_input = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(initial_payload)))
|
||||
.content_length(Some(initial_payload.len() as i64))
|
||||
.build()
|
||||
.unwrap();
|
||||
Box::pin(usecase.execute_put_object(&fs, build_request(initial_input, Method::PUT)))
|
||||
.await
|
||||
.expect("Failed to upload initial object through usecase");
|
||||
|
||||
let existing_info = ecstore
|
||||
.get_object_info(bucket.as_str(), object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to fetch existing object info");
|
||||
let existing_etag = existing_info.etag.expect("existing object should have an ETag");
|
||||
let initial_versions = live_object_version_count(&ecstore, bucket.as_str(), object).await;
|
||||
|
||||
for (condition, expected_code) in [
|
||||
(ETagCondition::Any, S3ErrorCode::PreconditionFailed),
|
||||
(ETagCondition::ETag(ETag::Strong(existing_etag)), S3ErrorCode::NotImplemented),
|
||||
(
|
||||
ETagCondition::ETag(ETag::Strong("different-etag".to_string())),
|
||||
S3ErrorCode::NotImplemented,
|
||||
),
|
||||
] {
|
||||
let header = condition.to_http_header().expect("conditional PUT header should encode");
|
||||
// Cover header-only callers and the DTO-plus-header shape of parsed HTTP requests.
|
||||
for if_none_match in [None, Some(condition)] {
|
||||
let has_dto = if_none_match.is_some();
|
||||
let replacement_input = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(replacement_payload)))
|
||||
.content_length(Some(replacement_payload.len() as i64))
|
||||
.if_none_match(if_none_match)
|
||||
.build()
|
||||
.expect("conditional replacement PUT input should build");
|
||||
let mut req = build_request(replacement_input, Method::PUT);
|
||||
req.headers.insert(IF_NONE_MATCH, header.clone());
|
||||
|
||||
let err = Box::pin(usecase.execute_put_object(&fs, req))
|
||||
.await
|
||||
.expect_err("conditional PUT must reject the replacement");
|
||||
assert_eq!(err.code(), &expected_code, "header={header:?}, has_dto={has_dto}");
|
||||
assert_eq!(
|
||||
read_object_bytes(&ecstore, bucket.as_str(), object).await,
|
||||
initial_payload,
|
||||
"failed conditional PutObject must not overwrite the current object"
|
||||
);
|
||||
assert_eq!(
|
||||
live_object_version_count(&ecstore, bucket.as_str(), object).await,
|
||||
initial_versions,
|
||||
"failed conditional PutObject must not create a version"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// rustfs/backlog#1320: a single PUT must compute the replication decision
|
||||
/// exactly once. Historically the PUT path computed `must_replicate_object`
|
||||
/// twice with the same inputs — once before commit to persist the pending
|
||||
/// replication metadata, and again after commit to drive the schedule. Besides
|
||||
/// repeating the versioning/config/target traversal on the hot path, a
|
||||
/// replication-config hot update between the two computations could split the
|
||||
/// two phases (pending-without-schedule or schedule-without-pending). The fix
|
||||
/// reuses one immutable decision for both phases; this test observes the
|
||||
/// computation counter and fails if a second computation is reintroduced.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state usecase integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_object_computes_replication_decision_exactly_once() {
|
||||
use super::super::storage_api::object_usecase::bucket::replication::MUST_REPLICATE_OBJECT_CALLS;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let fs = FS::new();
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-repl-decision-once-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
let payload = b"replication decision single-compute payload";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
// Reset immediately before the single PUT so the counter reflects only this
|
||||
// request. `#[serial]` guarantees no other usecase PUT runs concurrently, so
|
||||
// no unrelated `must_replicate_object` call can perturb the count.
|
||||
MUST_REPLICATE_OBJECT_CALLS.store(0, Ordering::SeqCst);
|
||||
|
||||
let input = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(payload)))
|
||||
.content_length(Some(payload.len() as i64))
|
||||
.build()
|
||||
.unwrap();
|
||||
Box::pin(usecase.execute_put_object(&fs, build_request(input, Method::PUT)))
|
||||
.await
|
||||
.expect("Failed to upload object through usecase");
|
||||
|
||||
let calls = MUST_REPLICATE_OBJECT_CALLS.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
calls, 1,
|
||||
"a single PUT must compute the replication decision exactly once; reverting to a \
|
||||
pre-commit + post-commit recompute makes this observe 2 (rustfs/backlog#1320)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Snowball auto-extract writes each archive member as an independent object,
|
||||
/// so each member must join bucket replication like a regular PUT (MinIO
|
||||
/// PutObjectExtract parity). The archive holds two file entries; a count other
|
||||
/// than 2 means extracted objects bypass the replication decision entirely and
|
||||
/// stay local forever.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state usecase integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_object_extract_computes_replication_decision_per_entry() {
|
||||
use super::super::storage_api::object_usecase::bucket::replication::MUST_REPLICATE_OBJECT_CALLS;
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
use super::super::storage_api::test::bucket::metadata::BUCKET_POLICY_CONFIG;
|
||||
use std::io::Cursor;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-extract-repl-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
// The extract path re-authorizes every extracted entry internally. The test
|
||||
// harness has no IAM system, so allow anonymous PutObject via bucket policy.
|
||||
let policy_json = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [format!("arn:aws:s3:::{bucket}/*")]
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
metadata_sys::update(bucket.as_str(), BUCKET_POLICY_CONFIG, policy_json.into_bytes())
|
||||
.await
|
||||
.expect("Failed to install anonymous put bucket policy");
|
||||
|
||||
let mut builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
|
||||
for (path, data) in [
|
||||
("member-one.txt", b"first member payload".as_slice()),
|
||||
("nested/member-two.txt", b"second member payload".as_slice()),
|
||||
] {
|
||||
let mut header = tokio_tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, path, Cursor::new(data))
|
||||
.await
|
||||
.expect("Failed to append archive member");
|
||||
}
|
||||
let archive = builder.into_inner().await.expect("Failed to finish archive").into_inner();
|
||||
|
||||
MUST_REPLICATE_OBJECT_CALLS.store(0, Ordering::SeqCst);
|
||||
|
||||
let input = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key("archive.tar".to_string())
|
||||
.body(Some(streaming_blob_from_bytes(&archive)))
|
||||
.content_length(Some(archive.len() as i64))
|
||||
.build()
|
||||
.unwrap();
|
||||
let mut req = build_request(input, Method::PUT);
|
||||
req.extensions.insert(ReqInfo::default());
|
||||
Box::pin(usecase.execute_put_object_extract(req))
|
||||
.await
|
||||
.expect("Failed to extract archive through usecase");
|
||||
|
||||
assert_eq!(
|
||||
MUST_REPLICATE_OBJECT_CALLS.load(Ordering::SeqCst),
|
||||
2,
|
||||
"snowball auto-extract must compute one replication decision per extracted member \
|
||||
object; 0 means extracted objects bypass bucket replication (P0-6 companion, MinIO \
|
||||
PutObjectExtract parity)"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
// 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.
|
||||
|
||||
//! RestoreObject tests (batch E owns RestoreObject).
|
||||
|
||||
use super::common::*;
|
||||
use super::*;
|
||||
|
||||
const RESTORE_SUSPENDED_WAIT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
async fn suspend_test_bucket(bucket: &str) {
|
||||
DefaultBucketUsecase::from_global()
|
||||
.execute_put_bucket_versioning(
|
||||
build_request(
|
||||
PutBucketVersioningInput::builder()
|
||||
.bucket(bucket.to_string())
|
||||
.versioning_configuration(VersioningConfiguration {
|
||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::SUSPENDED)),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.expect("suspended versioning request should build"),
|
||||
Method::PUT,
|
||||
),
|
||||
Ok(rustfs_gateway_types::persistence::PersistedVersioningConfiguration {
|
||||
status: Some("Suspended".to_owned()),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("bucket versioning should be suspended");
|
||||
}
|
||||
|
||||
async fn transition_uploaded_object_directly(
|
||||
ecstore: &Arc<ECStore>,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
tier_name: &str,
|
||||
uploaded: &ObjectInfo,
|
||||
) -> ObjectInfo {
|
||||
let transition_opts = ObjectOptions {
|
||||
transition: lifecycle::lifecycle_contract::TransitionOptions {
|
||||
status: lifecycle::lifecycle_contract::TRANSITION_PENDING.to_string(),
|
||||
tier: tier_name.to_string(),
|
||||
etag: uploaded.etag.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
version_id: uploaded.version_id.map(|version| version.to_string()),
|
||||
versioned: uploaded.version_id.is_some(),
|
||||
mod_time: uploaded.mod_time,
|
||||
..Default::default()
|
||||
};
|
||||
ecstore
|
||||
.transition_object(bucket, object, &transition_opts)
|
||||
.await
|
||||
.expect("Failed to transition object directly");
|
||||
wait_for_transition(ecstore, bucket, object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should transition before restore assertions")
|
||||
}
|
||||
|
||||
async fn wait_for_restore_completion(
|
||||
ecstore: &Arc<ECStore>,
|
||||
backend: &MockWarmBackend,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<ObjectInfo, String> {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
let mut last_state = None;
|
||||
|
||||
loop {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
let tier_gets = backend.get_count().await;
|
||||
let op_log = backend.op_log().await;
|
||||
return Err(format!(
|
||||
"restore copy-back should complete within {timeout:?}; tier_gets={tier_gets}, op_log={op_log:?}; last observed state: {}",
|
||||
last_state.unwrap_or_else(|| "no object info observed".to_string())
|
||||
));
|
||||
}
|
||||
|
||||
match (**ecstore).get_object_info(bucket, object, &ObjectOptions::default()).await {
|
||||
Ok(info) => {
|
||||
if !info.restore_ongoing && info.restore_expires.is_some() {
|
||||
return Ok(info);
|
||||
}
|
||||
last_state = Some(format!(
|
||||
"restore_ongoing={}, restore_expires={:?}, transitioned_status={}",
|
||||
info.restore_ongoing, info.restore_expires, info.transitioned_object.status
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
last_state = Some(format!("get_object_info failed: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// backlog#1148 ilm-8: the RestoreObject API surface on a transitioned object.
|
||||
///
|
||||
/// POST restore(days=1) is accepted and flips the object to
|
||||
/// `x-amz-restore: ongoing-request="true"` while the mock tier GET barrier
|
||||
/// proves the background copy-back has reached the remote read; a second POST
|
||||
/// during that window is rejected with 409 `RestoreAlreadyInProgress`.
|
||||
/// Synchronous SetDisks transition tests cover copy-back completion, restore
|
||||
/// metadata, and local byte-identical reads.
|
||||
///
|
||||
/// Re-enabled in the serial lane by backlog#1304: the accept path now flips
|
||||
/// the ongoing flag under a short compare-and-set guard and the copy-back
|
||||
/// runs without the #4877 whole-copy-back lock, so the mid-restore
|
||||
/// `ongoing-request="true"` read and the fast 409 rejection asserted here are
|
||||
/// the implemented contract.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-8)"]
|
||||
async fn restore_object_usecase_reports_ongoing_conflict() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-api-restore-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
// Keep the object under the shared ILM test prefix even though this setup
|
||||
// transitions it directly; it keeps diagnostics aligned with sibling tests.
|
||||
let object = "test/restore/api-object.bin";
|
||||
let payload: Vec<u8> = (0..128 * 1024).map(|i| (i % 251) as u8).collect();
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
let uploaded = upload_test_object(&ecstore, bucket.as_str(), object, &payload).await;
|
||||
assert!(uploaded.version_id.is_none(), "fixture must create the null version");
|
||||
let _ = transition_uploaded_object_directly(&ecstore, bucket.as_str(), object, &tier_name, &uploaded).await;
|
||||
backend.clear_op_log().await;
|
||||
let get_barrier = backend.arm_get_barrier().await;
|
||||
|
||||
let restore_request = || RestoreRequest {
|
||||
days: Some(1),
|
||||
description: None,
|
||||
glacier_job_parameters: None,
|
||||
output_location: None,
|
||||
select_parameters: None,
|
||||
tier: None,
|
||||
type_: None,
|
||||
};
|
||||
let restore_input = || {
|
||||
RestoreObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.restore_request(Some(restore_request()))
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
Box::pin(usecase.execute_restore_object(build_request(restore_input(), Method::POST)))
|
||||
.await
|
||||
.expect("restore request should be accepted");
|
||||
|
||||
get_barrier.wait_until_paused().await;
|
||||
|
||||
// The barrier proves the detached copy-back reached the tier GET and is
|
||||
// still paused, so the ongoing state and conflict rejection are not timing
|
||||
// assumptions about task scheduling.
|
||||
let ongoing = ecstore
|
||||
.get_object_info(bucket.as_str(), object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to load object info during restore");
|
||||
assert!(
|
||||
ongoing.restore_ongoing,
|
||||
"x-amz-restore must report ongoing-request=true while the copy-back tier GET is paused"
|
||||
);
|
||||
|
||||
// A second restore while one is in flight is rejected.
|
||||
let err = Box::pin(usecase.execute_restore_object(build_request(restore_input(), Method::POST)))
|
||||
.await
|
||||
.expect_err("a second restore while one is ongoing must be rejected");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
&S3ErrorCode::RestoreAlreadyInProgress,
|
||||
"unexpected rejection for a repeated restore: {err:?}"
|
||||
);
|
||||
|
||||
get_barrier.release();
|
||||
}
|
||||
|
||||
/// rustfs/backlog#1337: cancellation after the ongoing metadata commit but
|
||||
/// before detached worker creation must not strand the object forever. The
|
||||
/// replacement POST proves the abandoned v1 generation has no live worker,
|
||||
/// atomically supersedes it, and performs exactly one remote copy-back.
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1337"]
|
||||
#[test]
|
||||
fn restore_object_usecase_recovers_cancelled_post_commit_generation() {
|
||||
std::thread::Builder::new()
|
||||
.name("lifecycle-restore-orphan-recovery".to_string())
|
||||
.stack_size(32 * 1024 * 1024)
|
||||
.spawn(|| {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("orphan restore recovery test runtime should build");
|
||||
runtime.block_on(restore_object_usecase_recovers_cancelled_post_commit_generation_inner());
|
||||
})
|
||||
.expect("orphan restore recovery test thread should spawn")
|
||||
.join()
|
||||
.expect("orphan restore recovery test thread should finish");
|
||||
}
|
||||
|
||||
async fn restore_object_usecase_recovers_cancelled_post_commit_generation_inner() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
let bucket = format!("test-api-restore-orphan-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/restore/orphaned-generation.bin";
|
||||
let payload: Vec<u8> = (0..128 * 1024).map(|i| (i % 251) as u8).collect();
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
let uploaded = upload_test_object(&ecstore, bucket.as_str(), object, &payload).await;
|
||||
let _ = transition_uploaded_object_directly(&ecstore, bucket.as_str(), object, &tier_name, &uploaded).await;
|
||||
backend.clear_op_log().await;
|
||||
let tier_gets_before_restore = backend.get_count().await;
|
||||
|
||||
let restore_input = || {
|
||||
RestoreObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.restore_request(Some(RestoreRequest {
|
||||
days: Some(1),
|
||||
description: None,
|
||||
glacier_job_parameters: None,
|
||||
output_location: None,
|
||||
select_parameters: None,
|
||||
tier: None,
|
||||
type_: None,
|
||||
}))
|
||||
.build()
|
||||
.expect("restore request should build")
|
||||
};
|
||||
|
||||
let commit_barrier = crate::app::object::RestoreStatusCommitBarrier::install(bucket.as_str(), object);
|
||||
let first_input = restore_input();
|
||||
let first = tokio::spawn(async move {
|
||||
DefaultObjectUsecase::from_global()
|
||||
.execute_restore_object(build_request(first_input, Method::POST))
|
||||
.await
|
||||
});
|
||||
commit_barrier.wait_until_paused().await;
|
||||
|
||||
first.abort();
|
||||
assert!(first.await.expect_err("the first request must be cancelled").is_cancelled());
|
||||
drop(commit_barrier);
|
||||
|
||||
let orphaned = ecstore
|
||||
.get_object_info(bucket.as_str(), object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the committed orphan generation should remain readable");
|
||||
assert!(orphaned.restore_ongoing, "the interrupted request must have committed ongoing=true");
|
||||
assert_eq!(
|
||||
rustfs_utils::http::get_consistent_str(orphaned.user_defined.as_ref(), rustfs_utils::http::SUFFIX_RESTORE_WORKER_LOCK,),
|
||||
Some(rustfs_utils::http::RESTORE_WORKER_LOCK_PROTOCOL_V1),
|
||||
"recoverable generations must advertise the worker-lock protocol"
|
||||
);
|
||||
let orphaned_operation_id =
|
||||
rustfs_utils::http::get_consistent_str(orphaned.user_defined.as_ref(), rustfs_utils::http::SUFFIX_RESTORE_OPERATION_ID)
|
||||
.and_then(|value| Uuid::parse_str(value).ok())
|
||||
.expect("recoverable generations must persist a non-nil operation id");
|
||||
assert!(!orphaned_operation_id.is_nil());
|
||||
|
||||
DefaultObjectUsecase::from_global()
|
||||
.execute_restore_object(build_request(restore_input(), Method::POST))
|
||||
.await
|
||||
.expect("a new POST must supersede the committed generation whose worker lock was released");
|
||||
|
||||
let completed = wait_for_restore_completion(&ecstore, &backend, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("{err}"));
|
||||
assert!(!completed.restore_ongoing);
|
||||
assert!(completed.restore_expires.is_some());
|
||||
assert!(
|
||||
rustfs_utils::http::get_str(completed.user_defined.as_ref(), rustfs_utils::http::SUFFIX_RESTORE_OPERATION_ID,).is_none(),
|
||||
"completed replacement restore must consume its operation id"
|
||||
);
|
||||
assert!(
|
||||
rustfs_utils::http::get_str(completed.user_defined.as_ref(), rustfs_utils::http::SUFFIX_RESTORE_WORKER_LOCK,).is_none(),
|
||||
"completed replacement restore must consume its liveness marker"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.get_count().await - tier_gets_before_restore,
|
||||
1,
|
||||
"the cancelled pre-spawn generation must not issue a tier GET"
|
||||
);
|
||||
}
|
||||
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#4879"]
|
||||
#[test]
|
||||
fn restore_object_usecase_completes_suspended_null_version_in_place() {
|
||||
std::thread::Builder::new()
|
||||
.name("lifecycle-restore-suspended-null".to_string())
|
||||
.stack_size(32 * 1024 * 1024)
|
||||
.spawn(|| {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("suspended null-version restore test runtime should build");
|
||||
|
||||
runtime.block_on(restore_object_usecase_completes_suspended_null_version_in_place_inner());
|
||||
})
|
||||
.expect("suspended null-version restore test thread should spawn")
|
||||
.join()
|
||||
.expect("suspended null-version restore test thread should finish");
|
||||
}
|
||||
|
||||
async fn restore_object_usecase_completes_suspended_null_version_in_place_inner() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
let bucket = format!("test-api-restore-suspended-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/restore/suspended-null.bin";
|
||||
let payload: Vec<u8> = (0..128 * 1024).map(|i| (i % 251) as u8).collect();
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
let uploaded = upload_test_object(&ecstore, bucket.as_str(), object, &payload).await;
|
||||
assert!(uploaded.version_id.is_none(), "fixture must create the null version");
|
||||
let _ = transition_uploaded_object_directly(&ecstore, bucket.as_str(), object, &tier_name, &uploaded).await;
|
||||
suspend_test_bucket(bucket.as_str()).await;
|
||||
backend.clear_op_log().await;
|
||||
let tier_gets_before_restore = backend.get_count().await;
|
||||
let get_barrier = backend.arm_get_barrier().await;
|
||||
|
||||
Box::pin(
|
||||
usecase.execute_restore_object(build_request(
|
||||
RestoreObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.restore_request(Some(RestoreRequest {
|
||||
days: Some(1),
|
||||
description: None,
|
||||
glacier_job_parameters: None,
|
||||
output_location: None,
|
||||
select_parameters: None,
|
||||
tier: None,
|
||||
type_: None,
|
||||
}))
|
||||
.build()
|
||||
.expect("restore request should build"),
|
||||
Method::POST,
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.expect("suspended null-version restore should be accepted");
|
||||
|
||||
get_barrier.wait_until_paused().await;
|
||||
get_barrier.release();
|
||||
let completed = wait_for_restore_completion(&ecstore, &backend, bucket.as_str(), object, RESTORE_SUSPENDED_WAIT_TIMEOUT)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("{err}"));
|
||||
|
||||
assert!(!completed.restore_ongoing, "the original null version must complete in place");
|
||||
assert!(completed.restore_expires.is_some(), "completed restore must carry an expiry");
|
||||
assert!(
|
||||
completed.version_id.is_none() || completed.version_id.is_some_and(|version_id| version_id.is_nil()),
|
||||
"suspended restore must remain on the null version"
|
||||
);
|
||||
assert_eq!(
|
||||
live_object_version_count(&ecstore, bucket.as_str(), object).await,
|
||||
1,
|
||||
"suspended restore must not create a UUID version"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.get_count().await - tier_gets_before_restore,
|
||||
1,
|
||||
"suspended restore copy-back must fetch the tier exactly once"
|
||||
);
|
||||
}
|
||||
|
||||
/// backlog#1304: the restore-accept compare-and-set itself, under real
|
||||
/// concurrency. Two POST ?restore for the same transitioned object race each
|
||||
/// other from separate tasks; the accept guard must let exactly one through —
|
||||
/// the winner flips `ongoing-request="true"`, the loser must read that flag
|
||||
/// and be rejected with 409 `RestoreAlreadyInProgress` — and the tier must
|
||||
/// see exactly one copy-back GET, never two. Without the accept guard both
|
||||
/// requests can observe ongoing=false and double-start the copy-back (the
|
||||
/// #4859 race), which this test catches via the tier GET count.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-8)"]
|
||||
async fn restore_object_usecase_accepts_exactly_one_of_two_concurrent_restores() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-api-restore-cas-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
// Keep the object under the shared ILM test prefix for diagnostics parity.
|
||||
let object = "test/restore/cas-object.bin";
|
||||
let payload: Vec<u8> = (0..128 * 1024).map(|i| (i % 251) as u8).collect();
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
let uploaded = upload_test_object(&ecstore, bucket.as_str(), object, &payload).await;
|
||||
let _ = transition_uploaded_object_directly(&ecstore, bucket.as_str(), object, &tier_name, &uploaded).await;
|
||||
backend.clear_op_log().await;
|
||||
|
||||
// Hold the accepted copy-back at the tier GET until both accept attempts
|
||||
// return, so the loser cannot observe an already-restored object.
|
||||
let get_barrier = backend.arm_get_barrier().await;
|
||||
|
||||
let tier_gets_before_restore = backend.get_count().await;
|
||||
|
||||
let restore_input = || {
|
||||
RestoreObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.restore_request(Some(RestoreRequest {
|
||||
days: Some(1),
|
||||
description: None,
|
||||
glacier_job_parameters: None,
|
||||
output_location: None,
|
||||
select_parameters: None,
|
||||
tier: None,
|
||||
type_: None,
|
||||
}))
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
let spawn_restore = |input: RestoreObjectInput| {
|
||||
tokio::spawn(async move {
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
Box::pin(usecase.execute_restore_object(build_request(input, Method::POST))).await
|
||||
})
|
||||
};
|
||||
|
||||
let (first, second) = tokio::join!(spawn_restore(restore_input()), spawn_restore(restore_input()));
|
||||
let results = [
|
||||
first.expect("first concurrent restore task panicked"),
|
||||
second.expect("second concurrent restore task panicked"),
|
||||
];
|
||||
|
||||
let accepted = results.iter().filter(|result| result.is_ok()).count();
|
||||
assert_eq!(accepted, 1, "exactly one of two concurrent restores must be accepted, got {accepted}");
|
||||
let rejection = results
|
||||
.iter()
|
||||
.find_map(|result| result.as_ref().err())
|
||||
.expect("the losing concurrent restore must surface an error");
|
||||
assert_eq!(
|
||||
rejection.code(),
|
||||
&S3ErrorCode::RestoreAlreadyInProgress,
|
||||
"the losing concurrent restore must be rejected as already in progress: {rejection:?}"
|
||||
);
|
||||
|
||||
get_barrier.wait_until_paused().await;
|
||||
get_barrier.release();
|
||||
|
||||
// This test is scoped to the accept CAS: completion, expiry metadata, and
|
||||
// local restored GET service are covered by the single-request restore test
|
||||
// above. Here it is enough to prove exactly one copy-back was admitted.
|
||||
let expected_tier_gets = tier_gets_before_restore + 1;
|
||||
let deadline = tokio::time::Instant::now() + TRANSITION_WAIT_TIMEOUT;
|
||||
loop {
|
||||
let actual_tier_gets = backend.get_count().await;
|
||||
if actual_tier_gets >= expected_tier_gets {
|
||||
assert_eq!(
|
||||
actual_tier_gets - tier_gets_before_restore,
|
||||
1,
|
||||
"two concurrent restore requests must trigger exactly one tier copy-back GET"
|
||||
);
|
||||
break;
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
let op_log = backend.op_log().await;
|
||||
panic!(
|
||||
"mock tier should record exactly one restore GET within {TRANSITION_WAIT_TIMEOUT:?}; \
|
||||
tier_gets_before_restore={tier_gets_before_restore}, actual_tier_gets={actual_tier_gets}, op_log={op_log:?}"
|
||||
);
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
// 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 transition-engine tests that call no S3 use-case; owned by no operation batch.
|
||||
|
||||
use super::common::*;
|
||||
use super::*;
|
||||
|
||||
const ENV_GET_CODEC_STREAMING_ENABLE: &str = "RUSTFS_GET_CODEC_STREAMING_ENABLE";
|
||||
|
||||
const ENV_GET_CODEC_STREAMING_ROLLOUT: &str = "RUSTFS_GET_CODEC_STREAMING_ROLLOUT";
|
||||
|
||||
const ENV_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED: &str = "RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED";
|
||||
|
||||
const ENV_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED: &str = "RUSTFS_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED";
|
||||
|
||||
const ENV_GET_CODEC_STREAMING_MIN_SIZE: &str = "RUSTFS_GET_CODEC_STREAMING_MIN_SIZE";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn transition_wait_timeout_reports_metadata_state_and_read_failure() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("test-transition-wait-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
upload_test_object(&ecstore, &bucket, "plain.txt", b"not transitioned").await;
|
||||
|
||||
let pending = wait_for_transition(&ecstore, &bucket, "plain.txt", Duration::ZERO)
|
||||
.await
|
||||
.expect_err("an untransitioned object must still fail the observation deadline");
|
||||
assert!(pending.contains("transitioned_status=\"\""), "{pending}");
|
||||
assert!(pending.contains("compensation_pending="), "{pending}");
|
||||
assert!(pending.contains("compensation_running="), "{pending}");
|
||||
|
||||
let missing = wait_for_transition(&ecstore, &bucket, "missing.txt", Duration::ZERO)
|
||||
.await
|
||||
.expect_err("metadata read failures must not become a completed transition");
|
||||
assert!(missing.contains("metadata_error=ObjectNotFound"), "{missing}");
|
||||
}
|
||||
|
||||
async fn with_get_codec_streaming_remote_probe_env<F, Fut>(test_fn: F)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = ()>,
|
||||
{
|
||||
let metrics_was_enabled = rustfs_io_metrics::get_stage_metrics_enabled();
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
|
||||
let result = std::panic::AssertUnwindSafe(temp_env::async_with_vars(
|
||||
[
|
||||
(ENV_GET_CODEC_STREAMING_ENABLE, Some("true")),
|
||||
(ENV_GET_CODEC_STREAMING_ROLLOUT, Some("benchmark")),
|
||||
(ENV_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED, Some("true")),
|
||||
(ENV_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED, Some("true")),
|
||||
(ENV_GET_CODEC_STREAMING_MIN_SIZE, Some("1")),
|
||||
],
|
||||
test_fn(),
|
||||
))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(metrics_was_enabled);
|
||||
if let Err(err) = result {
|
||||
std::panic::resume_unwind(err);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
fn get_transitioned_object_uses_remote_codec_fallback_path() {
|
||||
// CI's default test stack overflows this deep async path; run it on a dedicated large-stack thread.
|
||||
std::thread::Builder::new()
|
||||
.name("lifecycle-transition-remote-codec-fallback".to_string())
|
||||
.stack_size(16 * 1024 * 1024)
|
||||
.spawn(|| {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("remote codec fallback test runtime should build");
|
||||
|
||||
runtime.block_on(async {
|
||||
with_get_codec_streaming_remote_probe_env(|| async {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-api-get-remote-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/remote-codec-fallback.txt";
|
||||
let payload: Vec<u8> = (0..(1024 * 1024))
|
||||
.map(|index| u8::try_from(index % 251).expect("payload byte fits in u8"))
|
||||
.collect();
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
let uploaded = upload_test_object(&ecstore, bucket.as_str(), object, &payload).await;
|
||||
let transition_opts = ObjectOptions {
|
||||
transition: lifecycle::lifecycle_contract::TransitionOptions {
|
||||
status: lifecycle::lifecycle_contract::TRANSITION_PENDING.to_string(),
|
||||
tier: tier_name.clone(),
|
||||
etag: uploaded.etag.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
version_id: uploaded.version_id.map(|version| version.to_string()),
|
||||
versioned: true,
|
||||
mod_time: uploaded.mod_time,
|
||||
..Default::default()
|
||||
};
|
||||
ecstore
|
||||
.transition_object(bucket.as_str(), object, &transition_opts)
|
||||
.await
|
||||
.expect("Failed to transition object directly");
|
||||
|
||||
let transitioned = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should transition before remote fallback GET");
|
||||
|
||||
assert_eq!(transitioned.transitioned_object.status, "complete");
|
||||
assert_eq!(transitioned.transitioned_object.tier, tier_name);
|
||||
assert!(!transitioned.transitioned_object.name.is_empty());
|
||||
assert!(backend.contains(&transitioned.transitioned_object.name).await);
|
||||
|
||||
let actual = read_object_bytes(&ecstore, bucket.as_str(), object).await;
|
||||
assert_eq!(actual, payload);
|
||||
})
|
||||
.await;
|
||||
});
|
||||
})
|
||||
.expect("remote codec fallback test thread should spawn")
|
||||
.join()
|
||||
.expect("remote codec fallback test thread should not panic");
|
||||
}
|
||||
|
||||
/// Regression test for rustfs/rustfs#4827: a duplicate transition task that runs
|
||||
/// after the winning transition completed must return early without a second
|
||||
/// tier upload and without clobbering the winner's remote object / metadata.
|
||||
///
|
||||
/// The duplicate models the residual race left after the in-flight enqueue dedup
|
||||
/// (#4839): a stale-snapshot task admitted after the winner released its claim.
|
||||
/// On an unversioned bucket the duplicate's metadata re-read is eligible for the
|
||||
/// GET metadata cache; before the fix the cache still held the pre-transition
|
||||
/// entry (transition_object never invalidated it after persisting), so the
|
||||
/// TRANSITION_COMPLETE early-return never fired and the duplicate streamed the
|
||||
/// already-deleted local data to the tier again (NotFound reader errors +
|
||||
/// rejected duplicate tier PUT).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn duplicate_transition_task_skips_already_transitioned_version() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-api-dup-transition-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/duplicate-transition.txt";
|
||||
let payload = b"duplicate transition tasks must not re-upload";
|
||||
|
||||
// Unversioned bucket: the duplicate's re-read goes through the GET metadata
|
||||
// cache (versioned reads bypass it), which is the stale-read window under test.
|
||||
(*ecstore)
|
||||
.make_bucket(bucket.as_str(), &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("Failed to create unversioned test bucket");
|
||||
|
||||
let uploaded = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||
let transition_opts = ObjectOptions {
|
||||
transition: lifecycle::lifecycle_contract::TransitionOptions {
|
||||
status: lifecycle::lifecycle_contract::TRANSITION_PENDING.to_string(),
|
||||
tier: tier_name.clone(),
|
||||
etag: uploaded.etag.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
version_id: uploaded.version_id.map(|version| version.to_string()),
|
||||
mod_time: uploaded.mod_time,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Winner: transitions the object, persists transition_status=complete, and
|
||||
// deletes the local data.
|
||||
ecstore
|
||||
.transition_object(bucket.as_str(), object, &transition_opts)
|
||||
.await
|
||||
.expect("winning transition should succeed");
|
||||
|
||||
let puts_after_winner: Vec<String> = backend
|
||||
.op_log()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter_map(|op| match op {
|
||||
MockWarmOp::Put { object, .. } => Some(object),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(puts_after_winner.len(), 1, "winner must upload exactly one tier object");
|
||||
let winner_remote_object = puts_after_winner[0].clone();
|
||||
|
||||
// Duplicate task with the same stale snapshot (same mod_time / etag), run
|
||||
// immediately so the pre-fix stale metadata-cache entry is still live. It
|
||||
// must observe transition_status=complete and return Ok without uploading.
|
||||
ecstore
|
||||
.transition_object(bucket.as_str(), object, &transition_opts)
|
||||
.await
|
||||
.expect("duplicate transition must return early without error");
|
||||
|
||||
let puts_after_duplicate = backend
|
||||
.op_log()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|op| matches!(op, MockWarmOp::Put { .. }))
|
||||
.count();
|
||||
assert_eq!(puts_after_duplicate, 1, "duplicate transition must not attempt a second tier upload");
|
||||
|
||||
// The winner's remote object and local metadata must be intact.
|
||||
assert!(backend.contains(&winner_remote_object).await);
|
||||
let info = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object must remain transitioned after duplicate task");
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert_eq!(
|
||||
info.transitioned_object.name, winner_remote_object,
|
||||
"duplicate transition must not repoint metadata at a new remote object"
|
||||
);
|
||||
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), object).await, payload);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn transition_rejects_stale_etag_before_remote_upload() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
let bucket = format!("test-api-transition-stale-etag-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/transition-stale-etag.txt";
|
||||
(*ecstore)
|
||||
.make_bucket(bucket.as_str(), &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("unversioned test bucket should be created");
|
||||
let original = upload_test_object(&ecstore, bucket.as_str(), object, b"original transition candidate").await;
|
||||
let replacement_payload = b"replacement committed before the stale transition starts";
|
||||
let replacement = upload_test_object(&ecstore, bucket.as_str(), object, replacement_payload).await;
|
||||
let transition_opts = ObjectOptions {
|
||||
transition: lifecycle::lifecycle_contract::TransitionOptions {
|
||||
status: lifecycle::lifecycle_contract::TRANSITION_PENDING.to_string(),
|
||||
tier: tier_name,
|
||||
etag: original.etag.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
version_id: replacement.version_id.map(|version| version.to_string()),
|
||||
mod_time: replacement.mod_time,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
ecstore
|
||||
.transition_object(bucket.as_str(), object, &transition_opts)
|
||||
.await
|
||||
.expect_err("stale ETag must reject the transition before remote upload");
|
||||
assert_eq!(backend.put_count().await, 0, "rejected stale work must not upload a remote candidate");
|
||||
assert_eq!(
|
||||
read_object_bytes(&ecstore, bucket.as_str(), object).await,
|
||||
replacement_payload,
|
||||
"the replacement object must remain local and readable"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn transition_commit_rejects_replaced_source() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
let bucket = format!("test-api-transition-replace-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/transition-replaced-source.txt";
|
||||
let original_payload = b"transition source that must not replace a concurrent overwrite";
|
||||
let replacement_payload = b"concurrent replacement must remain the authoritative local object";
|
||||
|
||||
(*ecstore)
|
||||
.make_bucket(bucket.as_str(), &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("unversioned test bucket should be created");
|
||||
let original = upload_test_object(&ecstore, bucket.as_str(), object, original_payload).await;
|
||||
let transition_opts = ObjectOptions {
|
||||
transition: lifecycle::lifecycle_contract::TransitionOptions {
|
||||
status: lifecycle::lifecycle_contract::TRANSITION_PENDING.to_string(),
|
||||
tier: tier_name.clone(),
|
||||
etag: original.etag.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
version_id: original.version_id.map(|version| version.to_string()),
|
||||
mod_time: original.mod_time,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let put_barrier = backend.arm_put_barrier().await;
|
||||
let transition_store = Arc::clone(&ecstore);
|
||||
let transition_bucket = bucket.clone();
|
||||
let transition = tokio::spawn(async move {
|
||||
transition_store
|
||||
.transition_object(&transition_bucket, object, &transition_opts)
|
||||
.await
|
||||
});
|
||||
put_barrier.wait_until_paused().await;
|
||||
|
||||
upload_test_object(&ecstore, bucket.as_str(), object, replacement_payload).await;
|
||||
put_barrier.release();
|
||||
|
||||
transition
|
||||
.await
|
||||
.expect("transition task should not panic")
|
||||
.expect_err("transition must reject a source replaced during remote upload");
|
||||
assert_eq!(
|
||||
backend.put_count().await,
|
||||
1,
|
||||
"the stale transition should upload exactly one remote candidate"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.remove_count().await,
|
||||
1,
|
||||
"the uncommitted remote candidate must be removed after identity revalidation fails"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.remove_versions().await,
|
||||
backend.put_versions().await,
|
||||
"cleanup must delete the exact object version returned by the tier PUT"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.object_count().await,
|
||||
0,
|
||||
"the rejected transition must not leave a remote candidate"
|
||||
);
|
||||
assert_eq!(
|
||||
read_object_bytes(&ecstore, bucket.as_str(), object).await,
|
||||
replacement_payload,
|
||||
"the replacement object must remain readable and unmodified"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn cancelled_transition_waiting_for_prepared_reader_cleans_remote() {
|
||||
temp_env::async_with_vars([(ENV_OBJECT_LOCK_OPTIMIZATION_ENABLE, Some("false"))], async {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
let bucket = format!("test-api-transition-reader-lock-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/transition-reader-lock.txt";
|
||||
let payload = b"prepared reader must keep transition commit behind the namespace lock".repeat(1024);
|
||||
|
||||
(*ecstore)
|
||||
.make_bucket(bucket.as_str(), &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("unversioned test bucket should be created");
|
||||
let original = upload_test_object(&ecstore, bucket.as_str(), object, &payload).await;
|
||||
let transition_opts = ObjectOptions {
|
||||
transition: lifecycle::lifecycle_contract::TransitionOptions {
|
||||
status: lifecycle::lifecycle_contract::TRANSITION_PENDING.to_string(),
|
||||
tier: tier_name,
|
||||
etag: original.etag.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
version_id: original.version_id.map(|version| version.to_string()),
|
||||
mod_time: original.mod_time,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let put_barrier = backend.arm_put_barrier().await;
|
||||
let transition_store = Arc::clone(&ecstore);
|
||||
let transition_bucket = bucket.clone();
|
||||
let mut transition = tokio::spawn(async move {
|
||||
transition_store
|
||||
.transition_object(&transition_bucket, object, &transition_opts)
|
||||
.await
|
||||
});
|
||||
tokio::select! {
|
||||
result = &mut transition => panic!("transition ended before reaching the tier PUT barrier: {result:?}"),
|
||||
() = put_barrier.wait_until_paused() => {}
|
||||
}
|
||||
|
||||
let prepared_reader = ecstore
|
||||
.prepare_get_object_reader(bucket.as_str(), object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("prepared reader should resolve while the local source still exists");
|
||||
put_barrier.release();
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(200), &mut transition)
|
||||
.await
|
||||
.is_err(),
|
||||
"transition commit must wait while a prepared reader owns the namespace read lock"
|
||||
);
|
||||
|
||||
transition.abort();
|
||||
assert!(
|
||||
transition
|
||||
.await
|
||||
.expect_err("transition task should be cancelled")
|
||||
.is_cancelled(),
|
||||
"transition cancellation should not be reported as a task panic"
|
||||
);
|
||||
drop(prepared_reader);
|
||||
assert_eq!(backend.put_count().await, 1, "the transition should upload exactly one remote candidate");
|
||||
assert!(
|
||||
backend.wait_for_object_count(0, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"cancelling while the commit waits for the namespace lock must clean the exact uploaded candidate"
|
||||
);
|
||||
assert_eq!(backend.remove_versions().await, backend.put_versions().await);
|
||||
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), object).await, payload);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn lifecycle_transition_marks_dirty_disks_for_capacity_manager() {
|
||||
let (disk_paths, ecstore) = setup_test_env().await;
|
||||
let manager = create_isolated_manager(HybridStrategyConfig::default());
|
||||
let _ = manager.get_dirty_disks().await;
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let _backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-capacity-transition-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
let payload = b"transition should mark dirty scope";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||
|
||||
lifecycle::bucket_lifecycle_ops::enqueue_transition_for_existing_objects(ecstore.clone(), bucket.as_str())
|
||||
.await
|
||||
.expect("Failed to enqueue transitioned object");
|
||||
|
||||
let _ = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should transition before dirty scope assertion");
|
||||
|
||||
let dirty_disks = manager.get_dirty_disks().await;
|
||||
assert_eq!(dirty_disks.len(), disk_paths.len());
|
||||
|
||||
let actual_paths: std::collections::HashSet<_> = dirty_disks
|
||||
.into_iter()
|
||||
.map(|disk| stdfs::canonicalize(&disk.drive_path).unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let expected_paths: std::collections::HashSet<_> = disk_paths
|
||||
.iter()
|
||||
.map(|path| stdfs::canonicalize(path).unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(actual_paths, expected_paths);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn immediate_transition_timeout_eventually_completes_via_compensation() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-compensation-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
let payload = b"transition compensation should eventually complete";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
|
||||
with_forced_immediate_enqueue_timeout(&backend, || async {
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||
})
|
||||
.await;
|
||||
|
||||
let info = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should eventually transition after compensation backfill");
|
||||
|
||||
assert_eq!(backend.put_count().await, 1, "compensation should upload the version once");
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.contains(&info.transitioned_object.name).await);
|
||||
}
|
||||
@@ -49,3 +49,5 @@ pub(crate) mod gating_test_env;
|
||||
mod lifecycle_transition_api_test;
|
||||
#[cfg(test)]
|
||||
mod put_prelookup_gating_test;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_seams;
|
||||
|
||||
@@ -2011,12 +2011,10 @@ mod tests {
|
||||
.version_id(requested_version)
|
||||
.build()
|
||||
.expect("HEAD request");
|
||||
let get_error = usecase
|
||||
.execute_get_object(build_request(get, Method::GET))
|
||||
let get_error = crate::app::test_seams::get_object(&usecase, build_request(get, Method::GET))
|
||||
.await
|
||||
.expect_err("marker GET");
|
||||
let head_error = usecase
|
||||
.execute_head_object(build_request(head, Method::HEAD))
|
||||
let head_error = crate::app::test_seams::head_object(&usecase, build_request(head, Method::HEAD))
|
||||
.await
|
||||
.expect_err("marker HEAD");
|
||||
for (method, error) in [("GET", get_error), ("HEAD", head_error)] {
|
||||
@@ -2063,8 +2061,7 @@ mod tests {
|
||||
.version_id(Some(original_id.clone()))
|
||||
.build()
|
||||
.expect("historical GET request");
|
||||
let response = usecase
|
||||
.execute_get_object(build_request(get, Method::GET))
|
||||
let response = crate::app::test_seams::get_object(&usecase, build_request(get, Method::GET))
|
||||
.await
|
||||
.expect("historical GET");
|
||||
assert!(!response.headers.contains_key("x-amz-delete-marker"));
|
||||
@@ -2080,8 +2077,7 @@ mod tests {
|
||||
.version_id(Some(original_id.clone()))
|
||||
.build()
|
||||
.expect("historical HEAD request");
|
||||
let response = usecase
|
||||
.execute_head_object(build_request(head, Method::HEAD))
|
||||
let response = crate::app::test_seams::head_object(&usecase, build_request(head, Method::HEAD))
|
||||
.await
|
||||
.expect("historical HEAD");
|
||||
assert_eq!(response.output.content_length, Some(i64::try_from(payload.len()).unwrap()));
|
||||
@@ -2101,12 +2097,10 @@ mod tests {
|
||||
.version_id(version)
|
||||
.build()
|
||||
.expect("missing HEAD request");
|
||||
let get_error = usecase
|
||||
.execute_get_object(build_request(get, Method::GET))
|
||||
let get_error = crate::app::test_seams::get_object(&usecase, build_request(get, Method::GET))
|
||||
.await
|
||||
.expect_err("missing GET");
|
||||
let head_error = usecase
|
||||
.execute_head_object(build_request(head, Method::HEAD))
|
||||
let head_error = crate::app::test_seams::head_object(&usecase, build_request(head, Method::HEAD))
|
||||
.await
|
||||
.expect_err("missing HEAD");
|
||||
for error in [get_error, head_error] {
|
||||
@@ -2201,7 +2195,7 @@ mod tests {
|
||||
.version_id(Some(marker_id.clone()))
|
||||
.build()
|
||||
.unwrap();
|
||||
let get_err = Box::pin(usecase.execute_get_object(build_request(get, Method::GET)))
|
||||
let get_err = Box::pin(crate::app::test_seams::get_object(&usecase, build_request(get, Method::GET)))
|
||||
.await
|
||||
.expect_err("GET of a marker remains forbidden");
|
||||
assert_eq!(get_err.code(), &S3ErrorCode::MethodNotAllowed);
|
||||
@@ -2211,7 +2205,7 @@ mod tests {
|
||||
.version_id(Some(marker_id.clone()))
|
||||
.build()
|
||||
.unwrap();
|
||||
let head_err = Box::pin(usecase.execute_head_object(build_request(head, Method::HEAD)))
|
||||
let head_err = Box::pin(crate::app::test_seams::head_object(&usecase, build_request(head, Method::HEAD)))
|
||||
.await
|
||||
.expect_err("HEAD of a marker remains forbidden");
|
||||
assert_eq!(head_err.code(), &S3ErrorCode::MethodNotAllowed);
|
||||
@@ -2287,7 +2281,7 @@ mod tests {
|
||||
.version_id(Some(marker_id))
|
||||
.build()
|
||||
.unwrap();
|
||||
let missing = Box::pin(usecase.execute_get_object(build_request(removed, Method::GET)))
|
||||
let missing = Box::pin(crate::app::test_seams::get_object(&usecase, build_request(removed, Method::GET)))
|
||||
.await
|
||||
.expect_err("the marker version must actually be gone");
|
||||
assert_eq!(
|
||||
@@ -2300,7 +2294,7 @@ mod tests {
|
||||
.key(key.to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
let response = Box::pin(usecase.execute_get_object(build_request(get, Method::GET)))
|
||||
let response = Box::pin(crate::app::test_seams::get_object(&usecase, build_request(get, Method::GET)))
|
||||
.await
|
||||
.expect("GET restores the historical payload");
|
||||
let mut body = response.output.body.expect("GET body");
|
||||
|
||||
@@ -2674,9 +2674,7 @@ mod tests {
|
||||
.expect("metadata GET input must build");
|
||||
let metadata_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
|
||||
let metadata_get = tokio::spawn(async move {
|
||||
metadata_usecase
|
||||
.execute_get_object(build_request(metadata_input, Method::GET))
|
||||
.await
|
||||
crate::app::test_seams::get_object(&metadata_usecase, build_request(metadata_input, Method::GET)).await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(2), metadata_entered.wait())
|
||||
.await
|
||||
@@ -2705,7 +2703,10 @@ mod tests {
|
||||
.build()
|
||||
.expect("GET input must build");
|
||||
let get_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
|
||||
let get = tokio::spawn(async move { get_usecase.execute_get_object(build_request(get_input, Method::GET)).await });
|
||||
let get =
|
||||
tokio::spawn(
|
||||
async move { crate::app::test_seams::get_object(&get_usecase, build_request(get_input, Method::GET)).await },
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !object_traffic_health.read_storage_stalled_for_test() {
|
||||
tokio::task::yield_now().await;
|
||||
@@ -4225,7 +4226,7 @@ mod tests {
|
||||
.expect("SSE-C HEAD input must build");
|
||||
let mut head_request = build_request(head_input, Method::HEAD);
|
||||
head_request.headers = read_headers.clone();
|
||||
let head = Box::pin(usecase.execute_head_object(head_request))
|
||||
let head = Box::pin(crate::app::test_seams::head_object(&usecase, head_request))
|
||||
.await
|
||||
.expect("matching SSE-C key must authorize HEAD");
|
||||
assert_eq!(head.output.server_side_encryption, None);
|
||||
@@ -4242,7 +4243,7 @@ mod tests {
|
||||
.expect("SSE-C GET input must build");
|
||||
let mut get_request = build_request(get_input, Method::GET);
|
||||
get_request.headers = read_headers;
|
||||
let get = Box::pin(usecase.execute_get_object(get_request))
|
||||
let get = Box::pin(crate::app::test_seams::get_object(&usecase, get_request))
|
||||
.await
|
||||
.expect("matching SSE-C key must authorize GET");
|
||||
assert_eq!(get.output.server_side_encryption, None);
|
||||
|
||||
@@ -59,6 +59,8 @@ pub(crate) mod s3 {
|
||||
#[cfg(test)]
|
||||
pub(crate) use s3s::dto::{GetObjectInput, HeadObjectInput, ListObjectsV2Input, ListObjectsV2Output};
|
||||
#[cfg(test)]
|
||||
pub(crate) use s3s::dto::{GetObjectOutput, HeadObjectOutput, PutObjectInput, PutObjectOutput};
|
||||
#[cfg(test)]
|
||||
pub(crate) use s3s::dto::{ListObjectsInput, StreamingBlob, UploadPartInput, UploadPartOutput};
|
||||
#[cfg(test)]
|
||||
pub(crate) use s3s::service::{S3Service, S3ServiceBuilder};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// 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::app::object_usecase::DefaultObjectUsecase;
|
||||
use crate::app::storage_api::s3::{GetObjectInput, GetObjectOutput, S3Request, S3Response, S3Result};
|
||||
use std::future::Future;
|
||||
|
||||
/// Calls `DefaultObjectUsecase::execute_get_object` with today's arguments.
|
||||
///
|
||||
/// Owned by the GetObject batch (C). Tests outside that batch's files call this seam instead of the use-case method;
|
||||
/// when batch C changes the use-case signature it changes only this body.
|
||||
pub(crate) fn get_object(
|
||||
usecase: &DefaultObjectUsecase,
|
||||
req: S3Request<GetObjectInput>,
|
||||
) -> impl Future<Output = S3Result<S3Response<GetObjectOutput>>> + '_ {
|
||||
usecase.execute_get_object(req)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// 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::app::object_usecase::DefaultObjectUsecase;
|
||||
use crate::app::storage_api::s3::{HeadObjectInput, HeadObjectOutput, S3Request, S3Response, S3Result};
|
||||
use std::future::Future;
|
||||
|
||||
/// Calls `DefaultObjectUsecase::execute_head_object` with today's arguments.
|
||||
///
|
||||
/// Owned by the HeadObject batch (C). Tests outside that batch's files call this seam instead of the use-case method;
|
||||
/// when batch C changes the use-case signature it changes only this body.
|
||||
pub(crate) fn head_object(
|
||||
usecase: &DefaultObjectUsecase,
|
||||
req: S3Request<HeadObjectInput>,
|
||||
) -> impl Future<Output = S3Result<S3Response<HeadObjectOutput>>> + '_ {
|
||||
usecase.execute_head_object(req)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// 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::app::bucket_usecase::DefaultBucketUsecase;
|
||||
use crate::app::storage_api::s3::{ListObjectsV2Input, ListObjectsV2Output, S3Request, S3Response, S3Result};
|
||||
use std::future::Future;
|
||||
|
||||
/// Calls `DefaultBucketUsecase::execute_list_objects_v2` with today's arguments.
|
||||
///
|
||||
/// Owned by the ListObjectsV2 batch (B2). Tests outside that batch's files call this seam instead of the use-case
|
||||
/// method; when batch B2 changes the use-case signature it changes only this body.
|
||||
pub(crate) fn list_objects_v2(
|
||||
usecase: &DefaultBucketUsecase,
|
||||
req: S3Request<ListObjectsV2Input>,
|
||||
) -> impl Future<Output = S3Result<S3Response<ListObjectsV2Output>>> + '_ {
|
||||
usecase.execute_list_objects_v2(req)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// 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.
|
||||
|
||||
//! Test-only seams over the use-case entry points that tests of other operation families call as an oracle.
|
||||
//!
|
||||
//! Each seam is owned by the batch that owns its operation. A test outside that family calls the seam, never the
|
||||
//! use-case method, so a signature change touches one seam body instead of another family's tests.
|
||||
//! `scripts/check_test_seams.py` enforces this.
|
||||
|
||||
mod get_object;
|
||||
mod head_object;
|
||||
mod list_objects_v2;
|
||||
mod put_object;
|
||||
|
||||
pub(crate) use get_object::get_object;
|
||||
pub(crate) use head_object::head_object;
|
||||
pub(crate) use list_objects_v2::list_objects_v2;
|
||||
pub(crate) use put_object::put_object;
|
||||
@@ -0,0 +1,30 @@
|
||||
// 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::app::object_usecase::DefaultObjectUsecase;
|
||||
use crate::app::storage_api::s3::{PutObjectInput, PutObjectOutput, S3Request, S3Response, S3Result};
|
||||
use crate::app::storage_api::test::ecfs::FS;
|
||||
use std::future::Future;
|
||||
|
||||
/// Calls `DefaultObjectUsecase::execute_put_object` with today's arguments.
|
||||
///
|
||||
/// Owned by the PutObject batch (D). Tests outside that batch's files call this seam instead of the use-case method;
|
||||
/// when batch D changes the use-case signature it changes only this body.
|
||||
pub(crate) fn put_object<'a>(
|
||||
usecase: &'a DefaultObjectUsecase,
|
||||
fs: &'a FS,
|
||||
req: S3Request<PutObjectInput>,
|
||||
) -> impl Future<Output = S3Result<S3Response<PutObjectOutput>>> + 'a {
|
||||
usecase.execute_put_object(fs, req)
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
// 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.
|
||||
|
||||
//! Test-only source reader for text guards that must survive a mechanical file split.
|
||||
//!
|
||||
//! A text guard asserts that some literal appears in production source. When it reads one file
|
||||
//! with `include_str!`, moving the guarded function into another file either breaks the guard or,
|
||||
//! worse, makes it stop seeing the code it guards. A guard built on [`SourceSet`] instead names
|
||||
//! the file it reads today plus the sibling directory a split moves that file's code into, and
|
||||
//! reads every `.rs` file of both at test time.
|
||||
//!
|
||||
//! The named file is required: a missing file panics with its path, so a guard never passes on
|
||||
//! absent input. The directory is optional while the split has not happened; once it exists,
|
||||
//! every `.rs` file under it (recursively) is read in byte order of its manifest-relative path,
|
||||
//! after the named file.
|
||||
//!
|
||||
//! [`SourceSet::production`] keeps production text only, because a guard about production code
|
||||
//! must not be satisfied by a test that merely mentions the guarded call: it leaves out files
|
||||
//! that are test-only by path (a `tests` directory, `tests.rs`, `*_test.rs`, `*_tests.rs`,
|
||||
//! `test_support.rs`, a file with `#![cfg(test)]`, or a child of a `#[cfg(test)] mod x;`) and
|
||||
//! removes every item annotated `#[cfg(test)]` or `#[cfg(all(test, ...))]`. The stripper relies on
|
||||
//! rustfmt's layout and panics, naming the file and line, on any shape it does not recognise
|
||||
//! instead of guessing which text to keep.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// One source file of a [`SourceSet`].
|
||||
pub(crate) struct SourceFile {
|
||||
/// Path relative to the `rustfs` package root, with `/` separators.
|
||||
pub(crate) path: String,
|
||||
pub(crate) text: String,
|
||||
}
|
||||
|
||||
/// The ordered files a text guard reads: one required file, then the split directory.
|
||||
pub(crate) struct SourceSet {
|
||||
files: Vec<SourceFile>,
|
||||
}
|
||||
|
||||
impl SourceSet {
|
||||
/// Production text of `file` and of every `.rs` file under `split_dir`, both relative to the
|
||||
/// `rustfs` package root.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics, naming the path, when `file` is missing, unreadable or test-only, when `split_dir`
|
||||
/// exists but is not a directory, when a file cannot be read, or when a `#[cfg(test)]` item
|
||||
/// has a shape the stripper does not recognise.
|
||||
pub(crate) fn production(file: &str, split_dir: &str) -> Self {
|
||||
Self::production_under(Path::new(env!("CARGO_MANIFEST_DIR")), file, split_dir)
|
||||
}
|
||||
|
||||
/// [`SourceSet::production`] under another package root, for guard fixtures.
|
||||
pub(crate) fn production_under(root: &Path, file: &str, split_dir: &str) -> Self {
|
||||
assert!(
|
||||
!is_test_only_path(file),
|
||||
"guard source {file} is a test-only file; a production guard cannot be about it"
|
||||
);
|
||||
let mut stripped = Vec::new();
|
||||
let mut test_module_paths = Vec::new();
|
||||
for source in Self::read_under(root, file, split_dir).files {
|
||||
if is_test_only_path(&source.path) {
|
||||
continue;
|
||||
}
|
||||
let Some((text, test_modules)) = strip_test_items(&source.path, &source.text) else {
|
||||
assert!(source.path != file, "guard source {file} is test-only (#![cfg(test)])");
|
||||
continue;
|
||||
};
|
||||
let children = child_module_dir(&source.path);
|
||||
test_module_paths.extend(test_modules.into_iter().map(|name| format!("{children}/{name}")));
|
||||
stripped.push(SourceFile { path: source.path, text });
|
||||
}
|
||||
stripped.retain(|source| {
|
||||
!test_module_paths
|
||||
.iter()
|
||||
.any(|module| source.path == format!("{module}.rs") || source.path.starts_with(&format!("{module}/")))
|
||||
});
|
||||
Self { files: stripped }
|
||||
}
|
||||
|
||||
fn read_under(root: &Path, file: &str, split_dir: &str) -> Self {
|
||||
let required = root.join(file);
|
||||
let text = std::fs::read_to_string(&required)
|
||||
.unwrap_or_else(|err| panic!("guard source {} must exist and be readable: {err}", required.display()));
|
||||
let mut files = vec![SourceFile {
|
||||
path: file.to_owned(),
|
||||
text,
|
||||
}];
|
||||
|
||||
let dir = root.join(split_dir);
|
||||
if dir.exists() {
|
||||
assert!(dir.is_dir(), "guard split path {} must be a directory", dir.display());
|
||||
let mut paths = Vec::new();
|
||||
collect_rust_files(&dir, &mut paths);
|
||||
let mut split_files: Vec<SourceFile> = paths
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|err| panic!("guard source {} must be readable: {err}", path.display()));
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or_else(|_| panic!("guard source {} must be under {}", path.display(), root.display()));
|
||||
let path = relative
|
||||
.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
SourceFile { path, text }
|
||||
})
|
||||
.collect();
|
||||
split_files.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
files.extend(split_files);
|
||||
}
|
||||
|
||||
Self { files }
|
||||
}
|
||||
|
||||
/// Whether any one file of the set contains `needle`.
|
||||
pub(crate) fn contains(&self, needle: &str) -> bool {
|
||||
self.files.iter().any(|file| file.text.contains(needle))
|
||||
}
|
||||
|
||||
/// Non-overlapping occurrences of `needle`, summed over the files; a match never spans two
|
||||
/// files.
|
||||
pub(crate) fn count(&self, needle: &str) -> usize {
|
||||
self.files.iter().map(|file| file.text.matches(needle).count()).sum()
|
||||
}
|
||||
|
||||
/// The text of the one `async fn name` method of the set: from its definition line, which
|
||||
/// must be indented by exactly four spaces (a method of a top-level `impl`), through the first
|
||||
/// following line that is exactly ` }`. A neighbouring method of any visibility, a test
|
||||
/// module, or the rest of the file is never part of it.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics unless exactly one such definition exists across the set and it has that closing
|
||||
/// line.
|
||||
pub(crate) fn method_body(&self, name: &str) -> &str {
|
||||
let definitions: Vec<(&SourceFile, usize)> = self
|
||||
.files
|
||||
.iter()
|
||||
.flat_map(|file| {
|
||||
line_starts(&file.text)
|
||||
.into_iter()
|
||||
.filter(move |&start| is_method_definition(line_at(&file.text, start), name))
|
||||
.map(move |start| (file, start))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
definitions.len(),
|
||||
1,
|
||||
"method {name} must be defined exactly once, as a four-space-indented `async fn`, across {}",
|
||||
self.describe()
|
||||
);
|
||||
let (file, start) = definitions[0];
|
||||
let end = line_starts(&file.text)
|
||||
.into_iter()
|
||||
.filter(|&line| line > start)
|
||||
.find(|&line| line_at(&file.text, line) == " }")
|
||||
.unwrap_or_else(|| panic!("method {name} in {} has no closing ` }}` line", file.path));
|
||||
let close = end + " }".len();
|
||||
&file.text[start..close]
|
||||
}
|
||||
|
||||
/// Manifest-relative paths of the set, for panic messages.
|
||||
pub(crate) fn describe(&self) -> String {
|
||||
self.files
|
||||
.iter()
|
||||
.map(|file| file.path.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte offsets at which each line of `text` starts.
|
||||
fn line_starts(text: &str) -> Vec<usize> {
|
||||
std::iter::once(0)
|
||||
.chain(text.match_indices('\n').map(|(index, _)| index + 1))
|
||||
.filter(|&start| start < text.len())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The line starting at `start`, without its line ending.
|
||||
fn line_at(text: &str, start: usize) -> &str {
|
||||
let rest = &text[start..];
|
||||
let line = rest.split('\n').next().unwrap_or(rest);
|
||||
line.strip_suffix('\r').unwrap_or(line)
|
||||
}
|
||||
|
||||
fn is_method_definition(line: &str, name: &str) -> bool {
|
||||
let Some(item) = line.strip_prefix(" ") else {
|
||||
return false;
|
||||
};
|
||||
if item.starts_with(' ') {
|
||||
return false;
|
||||
}
|
||||
let item = ["pub(crate) ", "pub(super) ", "pub "]
|
||||
.iter()
|
||||
.find_map(|visibility| item.strip_prefix(visibility))
|
||||
.unwrap_or(item);
|
||||
item.strip_prefix("async fn ")
|
||||
.and_then(|rest| rest.strip_prefix(name))
|
||||
.is_some_and(|rest| rest.starts_with(['(', '<']))
|
||||
}
|
||||
|
||||
fn is_test_only_path(path: &str) -> bool {
|
||||
let components: Vec<&str> = path.split('/').collect();
|
||||
let Some((file, directories)) = components.split_last() else {
|
||||
return false;
|
||||
};
|
||||
let stem = file.strip_suffix(".rs").unwrap_or(*file);
|
||||
directories.contains(&"tests")
|
||||
|| stem == "tests"
|
||||
|| stem == "test_support"
|
||||
|| stem.ends_with("_test")
|
||||
|| stem.ends_with("_tests")
|
||||
}
|
||||
|
||||
/// The directory that holds the child modules of the module in `path`.
|
||||
fn child_module_dir(path: &str) -> String {
|
||||
match path.strip_suffix("/mod.rs") {
|
||||
Some(dir) => dir.to_owned(),
|
||||
None => path.strip_suffix(".rs").unwrap_or(path).to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn indentation(line: &str) -> usize {
|
||||
line.len() - line.trim_start_matches(' ').len()
|
||||
}
|
||||
|
||||
fn is_test_cfg_attribute(trimmed: &str) -> bool {
|
||||
trimmed == "#[cfg(test)]" || (trimmed.starts_with("#[cfg(all(test,") && trimmed.ends_with(")]"))
|
||||
}
|
||||
|
||||
/// `text` without its `#[cfg(test)]` items, plus the names of the `#[cfg(test)] mod name;`
|
||||
/// declarations it removed; `None` when the whole file is `#![cfg(test)]`.
|
||||
fn strip_test_items(path: &str, text: &str) -> Option<(String, Vec<String>)> {
|
||||
let lines: Vec<&str> = text.split_inclusive('\n').collect();
|
||||
let mut kept = String::with_capacity(text.len());
|
||||
let mut test_modules = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < lines.len() {
|
||||
let trimmed = lines[index].trim();
|
||||
if trimmed == "#![cfg(test)]" {
|
||||
return None;
|
||||
}
|
||||
if !is_test_cfg_attribute(trimmed) {
|
||||
assert!(
|
||||
!(trimmed.starts_with("#[cfg(test)]") || trimmed.starts_with("#[cfg(all(test,")),
|
||||
"{path}:{}: a test-only item on its attribute line is not a shape the guard stripper reads",
|
||||
index + 1
|
||||
);
|
||||
kept.push_str(lines[index]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let indent = indentation(lines[index]);
|
||||
let mut item = index + 1;
|
||||
while item < lines.len() && is_attribute_or_comment(lines[item].trim()) {
|
||||
item += 1;
|
||||
}
|
||||
assert!(item < lines.len(), "{path}:{}: #[cfg(test)] is not followed by an item", index + 1);
|
||||
assert_eq!(
|
||||
indentation(lines[item]),
|
||||
indent,
|
||||
"{path}:{}: the #[cfg(test)] item is not at its attribute's indentation",
|
||||
item + 1
|
||||
);
|
||||
if let Some(name) = declared_module(lines[item].trim()) {
|
||||
test_modules.push(name.to_owned());
|
||||
}
|
||||
index = test_item_end(path, &lines, item, indent) + 1;
|
||||
}
|
||||
Some((kept, test_modules))
|
||||
}
|
||||
|
||||
fn is_attribute_or_comment(trimmed: &str) -> bool {
|
||||
(trimmed.starts_with("#[") && trimmed.ends_with(']')) || trimmed.starts_with("//")
|
||||
}
|
||||
|
||||
fn declared_module(item: &str) -> Option<&str> {
|
||||
let item = ["pub(crate) ", "pub(super) ", "pub "]
|
||||
.iter()
|
||||
.find_map(|visibility| item.strip_prefix(visibility))
|
||||
.unwrap_or(item);
|
||||
let name = item.strip_prefix("mod ")?.strip_suffix(';')?;
|
||||
(!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')).then_some(name)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Lexeme {
|
||||
Code,
|
||||
Str,
|
||||
RawStr(usize),
|
||||
BlockComment,
|
||||
}
|
||||
|
||||
fn is_ident(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
/// Index of the last line of the item that starts at `lines[start]` with indentation `indent`.
|
||||
fn test_item_end(path: &str, lines: &[&str], start: usize, indent: usize) -> usize {
|
||||
let mut depth: i64 = 0;
|
||||
let mut state = Lexeme::Code;
|
||||
// Inside a `where` clause a trailing comma separates bounds instead of ending the item.
|
||||
let mut in_where_clause = false;
|
||||
for (offset, line) in lines[start..].iter().enumerate() {
|
||||
let number = start + offset + 1;
|
||||
if state == Lexeme::Code && depth == 0 && indentation(line) == indent && line.trim().starts_with("where") {
|
||||
in_where_clause = true;
|
||||
}
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let mut last_code = None;
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
let next = chars.get(i + 1).copied();
|
||||
match state {
|
||||
Lexeme::Code => match c {
|
||||
'/' if next == Some('/') => break,
|
||||
'/' if next == Some('*') => {
|
||||
state = Lexeme::BlockComment;
|
||||
i += 1;
|
||||
}
|
||||
'"' => state = Lexeme::Str,
|
||||
'r' if matches!(next, Some('"' | '#'))
|
||||
&& (i == 0
|
||||
|| !is_ident(chars[i - 1])
|
||||
|| (chars[i - 1] == 'b' && (i == 1 || !is_ident(chars[i - 2])))) =>
|
||||
{
|
||||
let mut quote = i + 1;
|
||||
while chars.get(quote) == Some(&'#') {
|
||||
quote += 1;
|
||||
}
|
||||
if chars.get(quote) == Some(&'"') {
|
||||
state = Lexeme::RawStr(quote - i - 1);
|
||||
i = quote;
|
||||
} else {
|
||||
last_code = Some(c);
|
||||
}
|
||||
}
|
||||
'\'' => {
|
||||
if next == Some('\\') {
|
||||
let close = chars
|
||||
.get(i + 3..)
|
||||
.and_then(|rest| rest.iter().position(|&candidate| candidate == '\''))
|
||||
.unwrap_or_else(|| panic!("{path}:{number}: unterminated character literal"));
|
||||
i += 3 + close;
|
||||
last_code = Some('\'');
|
||||
} else if chars.get(i + 2) == Some(&'\'') {
|
||||
i += 2;
|
||||
last_code = Some('\'');
|
||||
} else {
|
||||
last_code = Some(c);
|
||||
}
|
||||
}
|
||||
'{' | '(' | '[' => {
|
||||
depth += 1;
|
||||
last_code = Some(c);
|
||||
}
|
||||
'}' | ')' | ']' => {
|
||||
depth -= 1;
|
||||
assert!(depth >= 0, "{path}:{number}: unbalanced `{c}` in a #[cfg(test)] item");
|
||||
last_code = Some(c);
|
||||
}
|
||||
c if !c.is_whitespace() => last_code = Some(c),
|
||||
_ => {}
|
||||
},
|
||||
Lexeme::Str => match c {
|
||||
'\\' => i += 1,
|
||||
'"' => {
|
||||
state = Lexeme::Code;
|
||||
last_code = Some('"');
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Lexeme::RawStr(hashes) => {
|
||||
if c == '"' && chars.len() > i + hashes && chars[i + 1..=i + hashes].iter().all(|&h| h == '#') {
|
||||
i += hashes;
|
||||
state = Lexeme::Code;
|
||||
last_code = Some('"');
|
||||
}
|
||||
}
|
||||
Lexeme::BlockComment => {
|
||||
if c == '*' && next == Some('/') {
|
||||
state = Lexeme::Code;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if state != Lexeme::Code || depth != 0 {
|
||||
continue;
|
||||
}
|
||||
let line_indent = indentation(line);
|
||||
assert!(
|
||||
offset == 0 || line_indent >= indent,
|
||||
"{path}:{number}: a #[cfg(test)] item continues below its own indentation"
|
||||
);
|
||||
match last_code {
|
||||
Some(';') => return start + offset,
|
||||
Some(',') if !in_where_clause => return start + offset,
|
||||
Some('}') => {
|
||||
assert!(
|
||||
offset == 0 || line_indent == indent,
|
||||
"{path}:{number}: a #[cfg(test)] item block closes at indentation {line_indent}, expected {indent}"
|
||||
);
|
||||
return start + offset;
|
||||
}
|
||||
_ => {
|
||||
let following = lines
|
||||
.get(start + offset + 1)
|
||||
.unwrap_or_else(|| panic!("{path}:{number}: a #[cfg(test)] item never ends"));
|
||||
let following_trimmed = following.trim();
|
||||
assert!(
|
||||
indentation(following) > indent
|
||||
|| (indentation(following) == indent
|
||||
&& ["where", "{", "->", ".", ")", "]"]
|
||||
.iter()
|
||||
.any(|prefix| following_trimmed.starts_with(prefix))),
|
||||
"{path}:{}: the line after an unterminated #[cfg(test)] item line does not continue it",
|
||||
number + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("{path}:{}: a #[cfg(test)] item never ends", start + 1);
|
||||
}
|
||||
|
||||
fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let entries =
|
||||
std::fs::read_dir(dir).unwrap_or_else(|err| panic!("guard split directory {} must be readable: {err}", dir.display()));
|
||||
for entry in entries {
|
||||
let entry = entry.unwrap_or_else(|err| panic!("guard split directory {} must be listable: {err}", dir.display()));
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_rust_files(&path, out);
|
||||
} else if path.extension().is_some_and(|extension| extension == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `files` under a fresh temporary package root, for guard fixtures.
|
||||
pub(crate) fn fixture_root(files: &[(&str, &str)]) -> tempfile::TempDir {
|
||||
let root = tempfile::tempdir().expect("fixture root");
|
||||
for (relative, text) in files {
|
||||
let path = root.path().join(relative);
|
||||
std::fs::create_dir_all(path.parent().expect("fixture path has a parent")).expect("create fixture dir");
|
||||
std::fs::write(path, text).expect("write fixture");
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
/// Runs `run`, which must panic, and returns its panic message.
|
||||
pub(crate) fn panic_message(run: impl FnOnce() + std::panic::UnwindSafe) -> String {
|
||||
let payload = std::panic::catch_unwind(run).expect_err("the guard input must be rejected");
|
||||
payload
|
||||
.downcast_ref::<String>()
|
||||
.cloned()
|
||||
.or_else(|| payload.downcast_ref::<&str>().map(|message| (*message).to_owned()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SourceSet, fixture_root, panic_message};
|
||||
|
||||
const MAIN: &str = "src/app/usecase.rs";
|
||||
const SPLIT: &str = "src/app/usecase";
|
||||
|
||||
#[test]
|
||||
fn absent_split_directory_reads_only_the_required_file() {
|
||||
let root = fixture_root(&[(MAIN, "guarded();\n")]);
|
||||
|
||||
let set = SourceSet::read_under(root.path(), MAIN, SPLIT);
|
||||
|
||||
assert_eq!(set.describe(), MAIN);
|
||||
assert!(set.contains("guarded();"));
|
||||
assert_eq!(set.count("guarded();"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_directory_files_follow_the_required_file_in_path_order() {
|
||||
let root = fixture_root(&[
|
||||
(MAIN, "guarded();\n"),
|
||||
("src/app/usecase/zeta.rs", "guarded();\n"),
|
||||
("src/app/usecase/alpha.rs", "guarded();\nguarded();\n"),
|
||||
("src/app/usecase/tests/nested.rs", "guarded();\n"),
|
||||
("src/app/usecase/notes.txt", "guarded();\n"),
|
||||
]);
|
||||
|
||||
let set = SourceSet::read_under(root.path(), MAIN, SPLIT);
|
||||
|
||||
assert_eq!(
|
||||
set.describe(),
|
||||
"src/app/usecase.rs, src/app/usecase/alpha.rs, src/app/usecase/tests/nested.rs, src/app/usecase/zeta.rs"
|
||||
);
|
||||
assert_eq!(set.count("guarded();"), 5, "non-.rs files are not source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_file_fails_even_when_the_split_directory_exists() {
|
||||
let root = fixture_root(&[("src/app/usecase/moved.rs", "guarded();\n")]);
|
||||
let root_path = root.path().to_path_buf();
|
||||
|
||||
let message = panic_message(move || {
|
||||
SourceSet::production_under(&root_path, MAIN, SPLIT);
|
||||
});
|
||||
|
||||
assert!(message.contains(MAIN), "panic must name the missing file: {message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_path_that_is_a_file_fails() {
|
||||
let root = fixture_root(&[(MAIN, "guarded();\n"), (SPLIT, "not a directory\n")]);
|
||||
let root_path = root.path().to_path_buf();
|
||||
|
||||
let message = panic_message(move || {
|
||||
SourceSet::production_under(&root_path, MAIN, SPLIT);
|
||||
});
|
||||
|
||||
assert!(message.contains("must be a directory"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_does_not_join_a_needle_across_two_files() {
|
||||
let root = fixture_root(&[(MAIN, "guard"), ("src/app/usecase/tail.rs", "ed();\n")]);
|
||||
|
||||
let set = SourceSet::production_under(root.path(), MAIN, SPLIT);
|
||||
|
||||
assert_eq!(set.count("guarded();"), 0);
|
||||
assert!(!set.contains("guarded();"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_text_keeps_production_items_and_drops_every_test_only_one() {
|
||||
let main = "\
|
||||
use crate::kept;
|
||||
#[cfg(test)]
|
||||
use crate::test_only_import;
|
||||
#[cfg(any(test, feature = \"x\"))]
|
||||
fn kept_in_some_production_build() { guarded(); }
|
||||
|
||||
impl Usecase {
|
||||
#[cfg(test)]
|
||||
field_for_tests: Option<u8>,
|
||||
#[cfg(test)]
|
||||
pub fn without_context() -> Self {
|
||||
guarded();
|
||||
}
|
||||
fn run(&self) {
|
||||
#[cfg(test)]
|
||||
let value = injected()
|
||||
.map_or(value, |err| guarded());
|
||||
#[cfg(test)]
|
||||
if let Some(hook) = self.hook {
|
||||
guarded();
|
||||
}
|
||||
guarded();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn helper<F>(f: F) -> u8
|
||||
where
|
||||
F: Fn(),
|
||||
{
|
||||
guarded();
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
const CANARY: &str = \"guarded();\";
|
||||
|
||||
#[cfg(test)]
|
||||
mod declared;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
const BRACES: &str = \"}}} {{\";
|
||||
const RAW: &[u8] = br#\"{\"a\": \"}\"}\"#;
|
||||
fn t() { let c = '}'; let d = '\\''; guarded(); }
|
||||
}
|
||||
";
|
||||
let root = fixture_root(&[
|
||||
(MAIN, main),
|
||||
("src/app/usecase/declared.rs", "guarded();\n"),
|
||||
("src/app/usecase/declared/deeper.rs", "guarded();\n"),
|
||||
("src/app/usecase/ops.rs", "fn op() { guarded(); }\n"),
|
||||
("src/app/usecase/tests/op_tests.rs", "guarded();\n"),
|
||||
("src/app/usecase/ops_test.rs", "guarded();\n"),
|
||||
("src/app/usecase/test_support.rs", "guarded();\n"),
|
||||
("src/app/usecase/gated.rs", "#![cfg(test)]\nguarded();\n"),
|
||||
]);
|
||||
|
||||
let set = SourceSet::production_under(root.path(), MAIN, SPLIT);
|
||||
|
||||
assert_eq!(set.describe(), "src/app/usecase.rs, src/app/usecase/ops.rs");
|
||||
assert_eq!(set.count("guarded();"), 3, "production calls: kept_in_some_production_build, run, op");
|
||||
assert!(set.contains("use crate::kept;") && set.contains("impl Usecase {") && set.contains("fn run(&self) {"));
|
||||
assert!(!set.contains("test_only_import") && !set.contains("without_context") && !set.contains("mod tests"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_test_only_item_on_its_attribute_line_fails_loudly() {
|
||||
let root = fixture_root(&[(MAIN, "#[cfg(test)] use crate::hidden;\nguarded();\n")]);
|
||||
let root_path = root.path().to_path_buf();
|
||||
|
||||
let message = panic_message(move || {
|
||||
SourceSet::production_under(&root_path, MAIN, SPLIT);
|
||||
});
|
||||
|
||||
assert!(message.contains("src/app/usecase.rs:1"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_test_only_item_that_never_closes_fails_loudly() {
|
||||
let root = fixture_root(&[(MAIN, "#[cfg(test)]\nmod tests {\n fn t() {}\n")]);
|
||||
let root_path = root.path().to_path_buf();
|
||||
|
||||
let message = panic_message(move || {
|
||||
SourceSet::production_under(&root_path, MAIN, SPLIT);
|
||||
});
|
||||
|
||||
assert!(message.contains("never ends"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_test_only_block_closing_at_another_indentation_fails_loudly() {
|
||||
let root = fixture_root(&[(MAIN, "impl X {\n #[cfg(test)]\n fn t() {\n}\n fn kept() {}\n}\n")]);
|
||||
let root_path = root.path().to_path_buf();
|
||||
|
||||
let message = panic_message(move || {
|
||||
SourceSet::production_under(&root_path, MAIN, SPLIT);
|
||||
});
|
||||
|
||||
assert!(message.contains("src/app/usecase.rs:4"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unterminated_test_only_line_followed_by_production_fails_loudly() {
|
||||
let root = fixture_root(&[(MAIN, "fn f() {\n #[cfg(test)]\n let hook = injected()\n guarded();\n}\n")]);
|
||||
let root_path = root.path().to_path_buf();
|
||||
|
||||
let message = panic_message(move || {
|
||||
SourceSet::production_under(&root_path, MAIN, SPLIT);
|
||||
});
|
||||
|
||||
assert!(message.contains("does not continue it"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_test_only_required_file_is_rejected() {
|
||||
let root = fixture_root(&[("src/app/usecase_test.rs", "guarded();\n")]);
|
||||
let root_path = root.path().to_path_buf();
|
||||
|
||||
let message = panic_message(move || {
|
||||
SourceSet::production_under(&root_path, "src/app/usecase_test.rs", "src/app/usecase_test");
|
||||
});
|
||||
|
||||
assert!(message.contains("test-only"), "{message}");
|
||||
}
|
||||
|
||||
const METHODS: &str = "\
|
||||
impl Usecase {
|
||||
pub async fn guarded(&self) {
|
||||
first();
|
||||
}
|
||||
|
||||
pub(crate) async fn neighbour(&self) {
|
||||
needle();
|
||||
}
|
||||
|
||||
pub(super) async fn other(&self) {
|
||||
needle();
|
||||
}
|
||||
}
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn method_body_stops_at_its_own_closing_line_before_any_neighbour() {
|
||||
let root = fixture_root(&[
|
||||
(MAIN, METHODS),
|
||||
("src/app/usecase/later.rs", "impl Usecase {\n fn x() { needle(); }\n}\n"),
|
||||
]);
|
||||
|
||||
let set = SourceSet::production_under(root.path(), MAIN, SPLIT);
|
||||
let body = set.method_body("guarded");
|
||||
|
||||
assert_eq!(body, " pub async fn guarded(&self) {\n first();\n }");
|
||||
assert!(!body.contains("needle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_body_of_the_last_method_stops_before_the_test_module() {
|
||||
let text = "impl Usecase {\n async fn last(&self) {\n first();\n }\n}\n\n#[cfg(test)]\nmod tests {\n fn t() { needle(); }\n}\n";
|
||||
let root = fixture_root(&[("src/app/usecase/moved.rs", text), (MAIN, "\n")]);
|
||||
|
||||
let set = SourceSet::read_under(root.path(), MAIN, SPLIT);
|
||||
|
||||
assert!(!set.method_body("last").contains("needle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_body_does_not_match_a_longer_name_or_a_nested_definition() {
|
||||
let text = "impl Usecase {\n pub async fn guarded_more(&self) {}\n async fn guarded(&self) {}\n}\n";
|
||||
let root = fixture_root(&[(MAIN, text)]);
|
||||
let set = SourceSet::production_under(root.path(), MAIN, SPLIT);
|
||||
|
||||
let message = panic_message(move || {
|
||||
set.method_body("guarded");
|
||||
});
|
||||
|
||||
assert!(message.contains("exactly once"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_body_defined_in_two_files_fails() {
|
||||
let root = fixture_root(&[(MAIN, METHODS), ("src/app/usecase/copy.rs", METHODS)]);
|
||||
let set = SourceSet::production_under(root.path(), MAIN, SPLIT);
|
||||
|
||||
let message = panic_message(move || {
|
||||
set.method_body("guarded");
|
||||
});
|
||||
|
||||
assert!(message.contains("src/app/usecase/copy.rs"), "panic must name the set: {message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_body_without_a_closing_line_fails() {
|
||||
let root = fixture_root(&[(MAIN, "impl Usecase {\n async fn guarded(&self) {\n first();\n }\n}\n")]);
|
||||
let set = SourceSet::production_under(root.path(), MAIN, SPLIT);
|
||||
|
||||
let message = panic_message(move || {
|
||||
set.method_body("guarded");
|
||||
});
|
||||
|
||||
assert!(message.contains("no closing"), "{message}");
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,8 @@ pub mod diagnose;
|
||||
pub mod embedded;
|
||||
pub mod error;
|
||||
pub mod foreground_observation;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod guard_sources;
|
||||
pub mod init;
|
||||
pub mod inspect;
|
||||
pub(crate) mod kms_deletion_gate;
|
||||
|
||||
@@ -1334,12 +1334,16 @@ fn test_delete_bucket_broadcast_fences_target_membership_through_delivery() {
|
||||
"a failed destructive delivery must remain visible for operator repair"
|
||||
);
|
||||
|
||||
let usecase = include_str!("../app/bucket_usecase.rs");
|
||||
let delete = usecase
|
||||
.split("async fn execute_delete_bucket_inner")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("pub async fn execute_head_bucket").next())
|
||||
.expect("delete bucket usecase");
|
||||
assert_delete_bucket_reserves_before_local_delete(&crate::guard_sources::SourceSet::production(
|
||||
"src/app/bucket_usecase.rs",
|
||||
"src/app/bucket_usecase",
|
||||
));
|
||||
}
|
||||
|
||||
/// The reservation and the local delete must both be inside `execute_delete_bucket_inner`, in
|
||||
/// that order; the slice ends at that method's own closing line.
|
||||
fn assert_delete_bucket_reserves_before_local_delete(usecase: &crate::guard_sources::SourceSet) {
|
||||
let delete = usecase.method_body("execute_delete_bucket_inner");
|
||||
assert!(
|
||||
delete
|
||||
.find("prepare_site_replication_delete_bucket")
|
||||
@@ -1349,6 +1353,67 @@ fn test_delete_bucket_broadcast_fences_target_membership_through_delivery() {
|
||||
);
|
||||
}
|
||||
|
||||
const DELETE_BUCKET_INNER_FIXTURE: &str = "\
|
||||
impl DefaultBucketUsecase {
|
||||
async fn execute_delete_bucket_inner(&self) {
|
||||
let intent = prepare_site_replication_delete_bucket(&bucket, force).await?;
|
||||
LOCAL_DELETE
|
||||
}
|
||||
}
|
||||
";
|
||||
|
||||
fn delete_bucket_fixture(core: &str) -> crate::guard_sources::SourceSet {
|
||||
let root = crate::guard_sources::fixture_root(&[
|
||||
("src/app/bucket_usecase.rs", "pub struct DefaultBucketUsecase;\n"),
|
||||
("src/app/bucket_usecase/core.rs", core),
|
||||
]);
|
||||
crate::guard_sources::SourceSet::production_under(root.path(), "src/app/bucket_usecase.rs", "src/app/bucket_usecase")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_bucket_order_guard_passes_after_the_method_moves_into_the_split_directory() {
|
||||
let core = DELETE_BUCKET_INNER_FIXTURE.replace("LOCAL_DELETE", "store.delete_bucket(&bucket).await;");
|
||||
assert_delete_bucket_reserves_before_local_delete(&delete_bucket_fixture(&core));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_bucket_order_guard_fails_when_the_local_delete_leaves_the_method() {
|
||||
let core = format!(
|
||||
"{}\nimpl DefaultBucketUsecase {{\n pub(crate) async fn delete_helper(&self) {{\n store.delete_bucket(&bucket).await;\n }}\n}}\n",
|
||||
DELETE_BUCKET_INNER_FIXTURE.replace("LOCAL_DELETE", "")
|
||||
);
|
||||
let usecase = delete_bucket_fixture(&core);
|
||||
|
||||
let message = crate::guard_sources::panic_message(move || assert_delete_bucket_reserves_before_local_delete(&usecase));
|
||||
|
||||
assert!(message.contains("local delete"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_bucket_order_guard_fails_when_the_delete_precedes_the_reservation() {
|
||||
let core = DELETE_BUCKET_INNER_FIXTURE.replace(
|
||||
" let intent = prepare_site_replication_delete_bucket(&bucket, force).await?;\n LOCAL_DELETE",
|
||||
" store.delete_bucket(&bucket).await;\n let intent = prepare_site_replication_delete_bucket(&bucket, force).await?;",
|
||||
);
|
||||
let usecase = delete_bucket_fixture(&core);
|
||||
|
||||
let message = crate::guard_sources::panic_message(move || assert_delete_bucket_reserves_before_local_delete(&usecase));
|
||||
|
||||
assert!(message.contains("destructive peer liabilities"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_bucket_order_guard_fails_when_the_method_has_no_closing_line() {
|
||||
let core = DELETE_BUCKET_INNER_FIXTURE
|
||||
.replace("LOCAL_DELETE", "store.delete_bucket(&bucket).await;")
|
||||
.replace(" }\n}\n", "");
|
||||
let usecase = delete_bucket_fixture(&core);
|
||||
|
||||
let message = crate::guard_sources::panic_message(move || assert_delete_bucket_reserves_before_local_delete(&usecase));
|
||||
|
||||
assert!(message.contains("no closing"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bucket_retry_settlement_preserves_a_newer_same_path_failure() {
|
||||
let peer = peer("remote", "https://remote.example.com");
|
||||
|
||||
@@ -6900,23 +6900,27 @@ mod tests {
|
||||
));
|
||||
let usecase = crate::app::object_usecase::DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
|
||||
assert_eq!(
|
||||
usecase.execute_get_object(get).await.expect_err("bad GET part number").code(),
|
||||
crate::app::test_seams::get_object(&usecase, get)
|
||||
.await
|
||||
.expect_err("bad GET part number")
|
||||
.code(),
|
||||
&S3ErrorCode::InvalidArgument
|
||||
);
|
||||
assert_eq!(
|
||||
usecase
|
||||
.execute_head_object(head)
|
||||
crate::app::test_seams::head_object(&usecase, head)
|
||||
.await
|
||||
.expect_err("range and part number conflict")
|
||||
.code(),
|
||||
&S3ErrorCode::InvalidArgument
|
||||
);
|
||||
assert_eq!(
|
||||
crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context)))
|
||||
.execute_list_objects_v2(list)
|
||||
.await
|
||||
.expect_err("negative max keys")
|
||||
.code(),
|
||||
crate::app::test_seams::list_objects_v2(
|
||||
&crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context))),
|
||||
list
|
||||
)
|
||||
.await
|
||||
.expect_err("negative max keys")
|
||||
.code(),
|
||||
&S3ErrorCode::InvalidArgument
|
||||
);
|
||||
}
|
||||
@@ -7004,78 +7008,77 @@ mod tests {
|
||||
let incarnation = store.bucket_incarnation_id(&bucket).await.expect("created identity");
|
||||
sys.apply_for_incarnation(&bucket, incarnation, Some(&config)).await;
|
||||
let usecase = crate::app::object_usecase::DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
|
||||
usecase
|
||||
.execute_get_object(get.clone())
|
||||
crate::app::test_seams::get_object(&usecase, get.clone())
|
||||
.await
|
||||
.expect("a local GET hit ignores source capture failure");
|
||||
usecase
|
||||
.execute_head_object(head.clone())
|
||||
crate::app::test_seams::head_object(&usecase, head.clone())
|
||||
.await
|
||||
.expect("a local HEAD hit ignores source capture failure");
|
||||
get.input.key = "missing".into();
|
||||
head.input.key = "missing".into();
|
||||
assert_eq!(
|
||||
usecase
|
||||
.execute_get_object(get.clone())
|
||||
crate::app::test_seams::get_object(&usecase, get.clone())
|
||||
.await
|
||||
.expect_err("failed capture cannot rebind on GET miss")
|
||||
.code(),
|
||||
&S3ErrorCode::NoSuchBucket
|
||||
);
|
||||
assert_eq!(
|
||||
usecase
|
||||
.execute_head_object(head.clone())
|
||||
crate::app::test_seams::head_object(&usecase, head.clone())
|
||||
.await
|
||||
.expect_err("failed capture cannot rebind on HEAD miss")
|
||||
.code(),
|
||||
&S3ErrorCode::NoSuchBucket
|
||||
);
|
||||
assert_eq!(
|
||||
crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context)))
|
||||
.execute_list_objects_v2(list.clone())
|
||||
.await
|
||||
.expect_err("failed capture cannot rebind on LIST")
|
||||
.code(),
|
||||
crate::app::test_seams::list_objects_v2(
|
||||
&crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context))),
|
||||
list.clone()
|
||||
)
|
||||
.await
|
||||
.expect_err("failed capture cannot rebind on LIST")
|
||||
.code(),
|
||||
&S3ErrorCode::NoSuchBucket
|
||||
);
|
||||
config.filter.prefix = Some("remote/".into());
|
||||
sys.apply_for_incarnation(&bucket, incarnation, Some(&config)).await;
|
||||
assert_eq!(
|
||||
usecase
|
||||
.execute_get_object(get.clone())
|
||||
crate::app::test_seams::get_object(&usecase, get.clone())
|
||||
.await
|
||||
.expect_err("filtered GET remains local")
|
||||
.code(),
|
||||
&S3ErrorCode::NoSuchKey
|
||||
);
|
||||
assert_eq!(
|
||||
usecase
|
||||
.execute_head_object(head.clone())
|
||||
crate::app::test_seams::head_object(&usecase, head.clone())
|
||||
.await
|
||||
.expect_err("filtered HEAD remains local")
|
||||
.code(),
|
||||
&S3ErrorCode::NoSuchKey
|
||||
);
|
||||
list.input.prefix = Some("local/".into());
|
||||
let listing = crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context)))
|
||||
.execute_list_objects_v2(list.clone())
|
||||
.await
|
||||
.expect("disjoint prefix needs no source identity");
|
||||
let listing = crate::app::test_seams::list_objects_v2(
|
||||
&crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context))),
|
||||
list.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("disjoint prefix needs no source identity");
|
||||
assert_eq!(listing.output.key_count, Some(0));
|
||||
list.input.prefix = Some("remote/".into());
|
||||
list.input.max_keys = Some(0);
|
||||
let listing = crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context)))
|
||||
.execute_list_objects_v2(list)
|
||||
.await
|
||||
.expect("an empty page needs no source identity");
|
||||
let listing = crate::app::test_seams::list_objects_v2(
|
||||
&crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context))),
|
||||
list,
|
||||
)
|
||||
.await
|
||||
.expect("an empty page needs no source identity");
|
||||
assert_eq!(listing.output.key_count, Some(0));
|
||||
|
||||
config.filter.prefix = None;
|
||||
config.policy.head = crate::on_demand_migration::HeadPolicy::LocalOnly;
|
||||
sys.apply_for_incarnation(&bucket, incarnation, Some(&config)).await;
|
||||
assert_eq!(
|
||||
usecase
|
||||
.execute_head_object(head.clone())
|
||||
crate::app::test_seams::head_object(&usecase, head.clone())
|
||||
.await
|
||||
.expect_err("local-only HEAD needs no source identity")
|
||||
.code(),
|
||||
@@ -7095,16 +7098,14 @@ mod tests {
|
||||
.await
|
||||
.expect("create local delete marker");
|
||||
assert_eq!(
|
||||
usecase
|
||||
.execute_get_object(get)
|
||||
crate::app::test_seams::get_object(&usecase, get)
|
||||
.await
|
||||
.expect_err("GET respects the delete marker before capture failure")
|
||||
.code(),
|
||||
&S3ErrorCode::NoSuchKey
|
||||
);
|
||||
assert_eq!(
|
||||
usecase
|
||||
.execute_head_object(head)
|
||||
crate::app::test_seams::head_object(&usecase, head)
|
||||
.await
|
||||
.expect_err("HEAD respects the delete marker before capture failure")
|
||||
.code(),
|
||||
|
||||
@@ -2276,13 +2276,13 @@ impl S3 for FS {
|
||||
|
||||
/// Every mutating S3 operation that has no bucket notification event reaches the audit
|
||||
/// stream only through `audit_only_operation`; a new such operation, or a dropped
|
||||
/// wrapper, must fail here rather than silently go unaudited.
|
||||
/// wrapper, must fail here rather than silently go unaudited. The wrappers are looked
|
||||
/// up in this file and in `legacy_edge/`, where a split of this file moves them.
|
||||
#[cfg(test)]
|
||||
mod audit_coverage_tests {
|
||||
use crate::guard_sources::SourceSet;
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
|
||||
const SOURCE: &str = include_str!("ecfs.rs");
|
||||
|
||||
fn is_read_only(op: S3Operation) -> bool {
|
||||
let name = op.as_str().trim_start_matches("s3:");
|
||||
["Get", "Head", "List", "Select"]
|
||||
@@ -2300,9 +2300,15 @@ mod audit_coverage_tests {
|
||||
expected.push(S3Operation::AbortMultipartUpload);
|
||||
assert_eq!(expected.len(), 22, "an operation was added or removed: {expected:?}");
|
||||
|
||||
let source = SourceSet::production("src/storage/ecfs.rs", "src/storage/legacy_edge");
|
||||
for op in expected {
|
||||
let wrapper = format!("audit_only_operation(req, S3Operation::{op:?},");
|
||||
assert!(SOURCE.contains(&wrapper), "{} is not run through audit_only_operation", op.as_str());
|
||||
assert!(
|
||||
source.contains(&wrapper),
|
||||
"{} is not run through audit_only_operation in {}",
|
||||
op.as_str(),
|
||||
source.describe()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14476,34 +14476,165 @@ fn object_mutation_entrypoints_call_reserved_prefix_guard() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_mutation_entrypoints_call_reserved_prefix_guard() {
|
||||
let source = include_str!("../app/multipart_usecase.rs");
|
||||
const MULTIPART_KEY_GUARD: &str = "validate_table_catalog_object_mutation(&bucket, &key).await?;";
|
||||
const OBJECT_METADATA_OBJECT_GUARD: &str = "validate_table_catalog_object_mutation(&bucket, &object).await?;";
|
||||
const OBJECT_METADATA_KEY_GUARD: &str = MULTIPART_KEY_GUARD;
|
||||
|
||||
fn assert_multipart_reserved_prefix_guard_calls(source: &crate::guard_sources::SourceSet) {
|
||||
assert_eq!(
|
||||
source
|
||||
.matches("validate_table_catalog_object_mutation(&bucket, &key).await?;")
|
||||
.count(),
|
||||
4
|
||||
source.count(MULTIPART_KEY_GUARD),
|
||||
4,
|
||||
"reserved-prefix guard calls across {}",
|
||||
source.describe()
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_object_metadata_reserved_prefix_guard_calls(source: &crate::guard_sources::SourceSet) {
|
||||
assert_eq!(
|
||||
source.count(OBJECT_METADATA_OBJECT_GUARD),
|
||||
2,
|
||||
"reserved-prefix guard calls across {}",
|
||||
source.describe()
|
||||
);
|
||||
assert_eq!(
|
||||
source.count(OBJECT_METADATA_KEY_GUARD),
|
||||
2,
|
||||
"reserved-prefix guard calls across {}",
|
||||
source.describe()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_metadata_mutation_entrypoints_call_reserved_prefix_guard() {
|
||||
let source = include_str!("../storage/ecfs.rs");
|
||||
fn multipart_mutation_entrypoints_call_reserved_prefix_guard() {
|
||||
assert_multipart_reserved_prefix_guard_calls(&crate::guard_sources::SourceSet::production(
|
||||
"src/app/multipart_usecase.rs",
|
||||
"src/app/multipart_usecase",
|
||||
));
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
source
|
||||
.matches("validate_table_catalog_object_mutation(&bucket, &object).await?;")
|
||||
.count(),
|
||||
2
|
||||
#[test]
|
||||
fn object_metadata_mutation_entrypoints_call_reserved_prefix_guard() {
|
||||
assert_object_metadata_reserved_prefix_guard_calls(&crate::guard_sources::SourceSet::production(
|
||||
"src/storage/ecfs.rs",
|
||||
"src/storage/legacy_edge",
|
||||
));
|
||||
}
|
||||
|
||||
/// `count` production calls of `guard`, one per method, for count-guard fixtures.
|
||||
fn guarded_methods(guard: &str, count: usize) -> String {
|
||||
let methods: String = (0..count)
|
||||
.map(|index| format!(" async fn guarded_{index}(&self) {{\n {guard}\n }}\n"))
|
||||
.collect();
|
||||
format!("impl Usecase {{\n{methods}}}\n")
|
||||
}
|
||||
|
||||
fn guard_fixture(files: &[(&str, &str)], file: &str, split_dir: &str) -> crate::guard_sources::SourceSet {
|
||||
let root = crate::guard_sources::fixture_root(files);
|
||||
crate::guard_sources::SourceSet::production_under(root.path(), file, split_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_count_guard_fails_when_a_dropped_production_call_is_replaced_by_test_mentions() {
|
||||
let parent = format!(
|
||||
"{}\n#[cfg(test)]\nmod tests {{\n fn mentions() {{\n {MULTIPART_KEY_GUARD}\n }}\n}}\n",
|
||||
guarded_methods(MULTIPART_KEY_GUARD, 2)
|
||||
);
|
||||
let source = guard_fixture(
|
||||
&[
|
||||
("src/app/multipart_usecase.rs", parent.as_str()),
|
||||
("src/app/multipart_usecase/complete.rs", guarded_methods(MULTIPART_KEY_GUARD, 1).as_str()),
|
||||
],
|
||||
"src/app/multipart_usecase.rs",
|
||||
"src/app/multipart_usecase",
|
||||
);
|
||||
assert_eq!(
|
||||
source
|
||||
.matches("validate_table_catalog_object_mutation(&bucket, &key).await?;")
|
||||
.count(),
|
||||
2
|
||||
source.count(MULTIPART_KEY_GUARD),
|
||||
3,
|
||||
"fixture: three production calls and one test mention"
|
||||
);
|
||||
|
||||
let message = crate::guard_sources::panic_message(move || assert_multipart_reserved_prefix_guard_calls(&source));
|
||||
|
||||
assert!(message.contains("reserved-prefix guard calls"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_count_guard_passes_when_calls_move_into_the_split_directory() {
|
||||
let source = guard_fixture(
|
||||
&[
|
||||
("src/app/multipart_usecase.rs", guarded_methods(MULTIPART_KEY_GUARD, 1).as_str()),
|
||||
("src/app/multipart_usecase/complete.rs", guarded_methods(MULTIPART_KEY_GUARD, 3).as_str()),
|
||||
],
|
||||
"src/app/multipart_usecase.rs",
|
||||
"src/app/multipart_usecase",
|
||||
);
|
||||
|
||||
assert_multipart_reserved_prefix_guard_calls(&source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_metadata_key_count_guard_fails_when_a_moved_call_is_broken() {
|
||||
let moved_and_broken = guarded_methods(MULTIPART_KEY_GUARD, 1).replace("&key)", "key.as_str())");
|
||||
let parent = format!(
|
||||
"{}{}",
|
||||
guarded_methods(OBJECT_METADATA_OBJECT_GUARD, 2),
|
||||
guarded_methods(OBJECT_METADATA_KEY_GUARD, 1)
|
||||
);
|
||||
let source = guard_fixture(
|
||||
&[
|
||||
("src/storage/ecfs.rs", parent.as_str()),
|
||||
("src/storage/legacy_edge/object_write.rs", moved_and_broken.as_str()),
|
||||
],
|
||||
"src/storage/ecfs.rs",
|
||||
"src/storage/legacy_edge",
|
||||
);
|
||||
|
||||
let message = crate::guard_sources::panic_message(move || assert_object_metadata_reserved_prefix_guard_calls(&source));
|
||||
|
||||
assert!(message.contains("src/storage/legacy_edge/object_write.rs"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_metadata_key_count_guard_passes_when_a_call_moves_intact() {
|
||||
let parent = format!(
|
||||
"{}{}",
|
||||
guarded_methods(OBJECT_METADATA_OBJECT_GUARD, 2),
|
||||
guarded_methods(OBJECT_METADATA_KEY_GUARD, 1)
|
||||
);
|
||||
let source = guard_fixture(
|
||||
&[
|
||||
("src/storage/ecfs.rs", parent.as_str()),
|
||||
(
|
||||
"src/storage/legacy_edge/object_write.rs",
|
||||
guarded_methods(OBJECT_METADATA_KEY_GUARD, 1).as_str(),
|
||||
),
|
||||
],
|
||||
"src/storage/ecfs.rs",
|
||||
"src/storage/legacy_edge",
|
||||
);
|
||||
|
||||
assert_object_metadata_reserved_prefix_guard_calls(&source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_metadata_count_guard_fails_when_a_legacy_edge_test_replaces_a_production_call() {
|
||||
let parent = format!(
|
||||
"{}{}",
|
||||
guarded_methods(OBJECT_METADATA_OBJECT_GUARD, 1),
|
||||
guarded_methods(OBJECT_METADATA_KEY_GUARD, 2)
|
||||
);
|
||||
let source = guard_fixture(
|
||||
&[
|
||||
("src/storage/ecfs.rs", parent.as_str()),
|
||||
("src/storage/legacy_edge/tests/object_meta.rs", OBJECT_METADATA_OBJECT_GUARD),
|
||||
],
|
||||
"src/storage/ecfs.rs",
|
||||
"src/storage/legacy_edge",
|
||||
);
|
||||
|
||||
let message = crate::guard_sources::panic_message(move || assert_object_metadata_reserved_prefix_guard_calls(&source));
|
||||
|
||||
assert!(message.contains("reserved-prefix guard calls"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -38,6 +38,7 @@ their issue closes.
|
||||
| `check_no_tokio_io_uring.sh` | ci-gate | Keeps tokio's io-uring backend disabled | ci.yml Quick Checks |
|
||||
| `check_s3s_footprint.sh` | ci-gate | Exact, lower-only baseline on the direct s3s surface ahead of the gateway migration (reads `.config/s3s-footprint-baseline.txt`; `s3_error_lines` leaves out lines whose file imports the macro from `rustfs-s3-types`, rule in the script header); `--mode allowlist` fails on any Rust file referencing s3s outside `.config/s3s-edge-allowlist.txt`, `--dry-run` lists them | ci.yml Quick Checks; `make pre-commit`; `make script-tests` (`--self-test`) |
|
||||
| `check_test_count_ratchet.sh` | ci-gate | Per-crate test-count floor and `#[ignore]` ceiling against `.config/test-count-baseline.txt`; no crate may fall below its tests row or rise above its ignored row (extra tests and new crates pass with a note) | ci.yml Quick Checks; `make pre-commit` |
|
||||
| `guard_source_set.sh` | ci-gate (library) | Sourced helpers for text guards that must survive a mechanical file split (rustfs/backlog#2765): `guard_source_set` resolves a required file plus the split directory (a missing required file fails), `module_relative_rg` rewrites a `super::` rule for each file's module depth | Sourced by `check_architecture_migration_rules.sh` and `check_logging_guardrails.sh`; contract tests `test_guard_source_set.sh`; end-to-end split probe `test_split_guard_rules.sh` (both `make script-tests`) |
|
||||
| `check_unsafe_code_allowances.sh` | ci-gate | Unsafe-code allowance ledger guard | ci.yml Quick Checks |
|
||||
| `layer-dependency-baseline.txt` | ci-gate (data) | Committed baseline consumed by `check_layer_dependencies.sh` | [Architecture guard troubleshooting](../docs/operations/architecture-guard-troubleshooting.md) |
|
||||
| `static.sh` | ci-gate | Static-build helper executed inside image builds | `Dockerfile.source`, `Dockerfile.decommission-local` |
|
||||
|
||||
@@ -118,6 +118,45 @@ require_source_contains "docs/architecture/ecstore-api-facade-inventory.md" "Do
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
# Rules about one of the large files below read that file plus the sibling
|
||||
# directory a mechanical split moves its code into (rustfs/backlog#2765), so a
|
||||
# split neither breaks nor silently blinds them. The *_SPLIT_SOURCES arrays hold
|
||||
# only the directory part, for rules that select the file itself by glob.
|
||||
# shellcheck source=scripts/guard_source_set.sh
|
||||
source "${ROOT_DIR}/scripts/guard_source_set.sh"
|
||||
guard_source_set "$ROOT_DIR" rustfs/src/storage/ecfs.rs rustfs/src/storage/legacy_edge ||
|
||||
report_failure "ECFS guard source set is unreadable"
|
||||
ECFS_GUARD_SOURCES=("${GUARD_SOURCES[@]}")
|
||||
guard_source_set "$ROOT_DIR" rustfs/src/app/bucket_usecase.rs rustfs/src/app/bucket_usecase ||
|
||||
report_failure "bucket usecase guard source set is unreadable"
|
||||
BUCKET_USECASE_GUARD_SOURCES=("${GUARD_SOURCES[@]}")
|
||||
BUCKET_USECASE_SPLIT_SOURCES=("${GUARD_SOURCES[@]:1}")
|
||||
guard_source_set "$ROOT_DIR" rustfs/src/app/multipart_usecase.rs rustfs/src/app/multipart_usecase ||
|
||||
report_failure "multipart usecase guard source set is unreadable"
|
||||
MULTIPART_USECASE_GUARD_SOURCES=("${GUARD_SOURCES[@]}")
|
||||
MULTIPART_USECASE_SPLIT_SOURCES=("${GUARD_SOURCES[@]:1}")
|
||||
guard_source_set "$ROOT_DIR" rustfs/src/app/lifecycle_transition_api_test.rs rustfs/src/app/lifecycle_transition_api_test ||
|
||||
report_failure "lifecycle transition API test guard source set is unreadable"
|
||||
LIFECYCLE_TRANSITION_TEST_GUARD_SOURCES=("${GUARD_SOURCES[@]}")
|
||||
LIFECYCLE_TRANSITION_TEST_SPLIT_SOURCES=("${GUARD_SOURCES[@]:1}")
|
||||
guard_source_set "$ROOT_DIR" rustfs/src/storage/ecfs_test.rs rustfs/src/storage/legacy_edge/tests ||
|
||||
report_failure "ECFS test guard source set is unreadable"
|
||||
ECFS_TEST_GUARD_SOURCES=("${GUARD_SOURCES[@]}")
|
||||
# Every .rs file under rustfs/src/app, for depth-aware rules over the whole app tree.
|
||||
APP_RUNTIME_RULE_SOURCES=()
|
||||
while IFS= read -r app_source; do
|
||||
APP_RUNTIME_RULE_SOURCES+=("$app_source")
|
||||
done < <(cd "$ROOT_DIR" && rg --files rustfs/src/app --glob '*.rs' --glob '!rustfs/src/app/storage_api.rs' | LC_ALL=C sort)
|
||||
APP_OBJECT_SOURCES=()
|
||||
while IFS= read -r app_source; do
|
||||
APP_OBJECT_SOURCES+=("$app_source")
|
||||
done < <(cd "$ROOT_DIR" && rg --files rustfs/src/app/object --glob '*.rs' | LC_ALL=C sort)
|
||||
APP_USECASE_RULE_SOURCES=("${BUCKET_USECASE_GUARD_SOURCES[@]}" rustfs/src/app/object_usecase.rs "${APP_OBJECT_SOURCES[@]}"
|
||||
"${MULTIPART_USECASE_GUARD_SOURCES[@]}" "${LIFECYCLE_TRANSITION_TEST_GUARD_SOURCES[@]}")
|
||||
if [[ ${#APP_RUNTIME_RULE_SOURCES[@]} -eq 0 || ${#APP_OBJECT_SOURCES[@]} -eq 0 ]]; then
|
||||
report_failure "app source listing for depth-aware import rules is empty"
|
||||
fi
|
||||
|
||||
PR_TYPES_FILE="${TMP_DIR}/pr_types.txt"
|
||||
PR_TYPE_HITS_FILE="${TMP_DIR}/pr_type_hits.txt"
|
||||
SOURCE_MARKERS_FILE="${TMP_DIR}/source_markers.txt"
|
||||
@@ -2157,7 +2196,7 @@ fi
|
||||
cd "$ROOT_DIR"
|
||||
rg -n --with-filename 'crate::app::context::(?:\{[^}]*\b(?:resolve_object_store_handle|resolve_buffer_config|resolve_internode_metrics|resolve_local_node_name|resolve_action_credentials|resolve_notify_interface|resolve_performance_metrics|resolve_encryption_service|resolve_region|resolve_ready_iam_handle)\b|(?:resolve_object_store_handle|resolve_buffer_config|resolve_internode_metrics|resolve_local_node_name|resolve_action_credentials|resolve_notify_interface|resolve_performance_metrics|resolve_encryption_service|resolve_region|resolve_ready_iam_handle)\b)' \
|
||||
rustfs/src/storage/access.rs \
|
||||
rustfs/src/storage/ecfs.rs \
|
||||
"${ECFS_GUARD_SOURCES[@]}" \
|
||||
rustfs/src/storage/ecfs_extend.rs \
|
||||
rustfs/src/storage/helper.rs \
|
||||
rustfs/src/storage/concurrency/manager.rs \
|
||||
@@ -2175,7 +2214,7 @@ fi
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
rg -n --with-filename 'crate::app::(?:bucket_usecase|multipart_usecase|object_usecase|object\b)|Default(?:Bucket|Multipart|Object)Usecase::from_global\(\)' \
|
||||
rustfs/src/storage/ecfs.rs \
|
||||
"${ECFS_GUARD_SOURCES[@]}" \
|
||||
--glob '*.rs' || true
|
||||
) >"$RUSTFS_STORAGE_ECFS_USECASE_BYPASS_HITS_FILE"
|
||||
|
||||
@@ -2228,7 +2267,10 @@ fi
|
||||
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
rg -n --with-filename 'use crate::storage::\*;' rustfs/src/app --glob '*_usecase.rs' --glob 'object/*.rs' || true
|
||||
# A glob containing a slash is anchored at the search root, so object files
|
||||
# need the leading **/ to match under rustfs/src/app.
|
||||
rg -n --with-filename 'use crate::storage::\*;' rustfs/src/app --glob '*_usecase.rs' --glob '**/object/*.rs' \
|
||||
${BUCKET_USECASE_SPLIT_SOURCES[@]+"${BUCKET_USECASE_SPLIT_SOURCES[@]}"} ${MULTIPART_USECASE_SPLIT_SOURCES[@]+"${MULTIPART_USECASE_SPLIT_SOURCES[@]}"} || true
|
||||
) >"$RUSTFS_APP_USECASE_STORAGE_WILDCARD_HITS_FILE"
|
||||
|
||||
if [[ -s "$RUSTFS_APP_USECASE_STORAGE_WILDCARD_HITS_FILE" ]]; then
|
||||
@@ -2246,7 +2288,8 @@ fi
|
||||
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
rg -n --with-filename 'crate::storage::s3_api::|use crate::storage::s3_api|super::s3_api::|use super::s3_api' rustfs/src/app --glob '*_usecase.rs' --glob 'object/*.rs' || true
|
||||
rg -n --with-filename 'crate::storage::s3_api::|use crate::storage::s3_api|super::s3_api::|use super::s3_api' rustfs/src/app --glob '*_usecase.rs' --glob '**/object/*.rs' \
|
||||
${BUCKET_USECASE_SPLIT_SOURCES[@]+"${BUCKET_USECASE_SPLIT_SOURCES[@]}"} ${MULTIPART_USECASE_SPLIT_SOURCES[@]+"${MULTIPART_USECASE_SPLIT_SOURCES[@]}"} || true
|
||||
) >"$RUSTFS_APP_USECASE_S3_API_BYPASS_HITS_FILE"
|
||||
|
||||
if [[ -s "$RUSTFS_APP_USECASE_S3_API_BYPASS_HITS_FILE" ]]; then
|
||||
@@ -2258,13 +2301,19 @@ fi
|
||||
{
|
||||
rg -n --with-filename \
|
||||
'(use crate::storage::(access|helper|options|request_context|sse|timeout_wrapper|head_prefix|concurrency|ecfs)|crate::storage::sse::EncryptionKeyKind|use crate::storage::\{|use crate::storage::[A-Z])' \
|
||||
rustfs/src/app/select_object.rs rustfs/src/app/*_usecase.rs rustfs/src/app/object || true
|
||||
rg -n --with-filename \
|
||||
'use super::(?:\{[^}]*\b(?:DynReader|HashReader|WriteEncryption|WritePlan|DecryptReader|EncryptReader|HardLimitReader|boxed_reader|wrap_reader|compression_metadata_value|is_disk_compressible|MIN_DISK_COMPRESSIBLE_SIZE|get_lock_acquire_timeout|is_valid_storage_class|StorageError|DiskError|is_all_buckets_not_found|is_err_bucket_not_found|is_err_object_not_found|is_err_version_not_found)\b|(?:object_api_utils::to_s3s_etag|storageclass|StorageError|DiskError|DynReader|HashReader|WriteEncryption|WritePlan|DecryptReader|EncryptReader|HardLimitReader|boxed_reader|wrap_reader|compression_metadata_value|is_disk_compressible|MIN_DISK_COMPRESSIBLE_SIZE|get_lock_acquire_timeout|is_valid_storage_class|is_all_buckets_not_found|is_err_bucket_not_found|is_err_object_not_found|is_err_version_not_found)\b)' \
|
||||
rustfs/src/app/bucket_usecase.rs rustfs/src/app/object_usecase.rs rustfs/src/app/object rustfs/src/app/multipart_usecase.rs rustfs/src/app/lifecycle_transition_api_test.rs || true
|
||||
rg -n --with-filename \
|
||||
'use super::(?:\{[^}]*\b(?:AppObjectLockConfigExt|AppReplicationConfigExt|AppVersioningConfigExt|predict_lifecycle_expiration|validate_restore_request|bucket_target_sys|lifecycle|metadata|metadata_sys|object_lock|policy_sys|quota|replication|tagging|target|utils|versioning_sys|transition_api|ObjectInfo|ObjectOptions)\b|(?:AppObjectLockConfigExt|AppReplicationConfigExt|AppVersioningConfigExt|predict_lifecycle_expiration|validate_restore_request|bucket_target_sys|lifecycle|metadata|metadata_sys|object_lock|policy_sys|quota|replication|tagging|target|utils|versioning_sys|transition_api|ObjectInfo|ObjectOptions)\b)|super::(?:lifecycle|metadata_sys|object_lock|quota|replication|tagging|target|utils|versioning_sys|transition_api)::|super::super::(?:metadata_sys|lifecycle|target)::' \
|
||||
rustfs/src/app/bucket_usecase.rs rustfs/src/app/object_usecase.rs rustfs/src/app/object rustfs/src/app/multipart_usecase.rs rustfs/src/app/lifecycle_transition_api_test.rs rustfs/src/app/capacity_dirty_scope_test.rs rustfs/src/app/context.rs rustfs/src/app/context/handles.rs rustfs/src/app/context/interfaces.rs rustfs/src/app/context/runtime_sources.rs || true
|
||||
rustfs/src/app/select_object.rs rustfs/src/app/*_usecase.rs rustfs/src/app/object \
|
||||
${BUCKET_USECASE_SPLIT_SOURCES[@]+"${BUCKET_USECASE_SPLIT_SOURCES[@]}"} ${MULTIPART_USECASE_SPLIT_SOURCES[@]+"${MULTIPART_USECASE_SPLIT_SOURCES[@]}"} || true
|
||||
# The super:: chain that names crate::app depends on each file's depth, so
|
||||
# these rules are rewritten per depth (scripts/guard_source_set.sh).
|
||||
module_relative_rg rustfs/src/app \
|
||||
'(?:use @UP@|use crate::app::)(?:\{[^}]*\b(?:DynReader|HashReader|WriteEncryption|WritePlan|DecryptReader|EncryptReader|HardLimitReader|boxed_reader|wrap_reader|compression_metadata_value|is_disk_compressible|MIN_DISK_COMPRESSIBLE_SIZE|get_lock_acquire_timeout|is_valid_storage_class|StorageError|DiskError|is_all_buckets_not_found|is_err_bucket_not_found|is_err_object_not_found|is_err_version_not_found)\b|(?:object_api_utils::to_s3s_etag|storageclass|StorageError|DiskError|DynReader|HashReader|WriteEncryption|WritePlan|DecryptReader|EncryptReader|HardLimitReader|boxed_reader|wrap_reader|compression_metadata_value|is_disk_compressible|MIN_DISK_COMPRESSIBLE_SIZE|get_lock_acquire_timeout|is_valid_storage_class|is_all_buckets_not_found|is_err_bucket_not_found|is_err_object_not_found|is_err_version_not_found)\b)' \
|
||||
-n --with-filename -- "${APP_USECASE_RULE_SOURCES[@]}" ||
|
||||
echo "storage helper import rule could not search its sources"
|
||||
module_relative_rg rustfs/src/app \
|
||||
'(?:use @UP@|use crate::app::)(?:\{[^}]*\b(?:AppObjectLockConfigExt|AppReplicationConfigExt|AppVersioningConfigExt|predict_lifecycle_expiration|validate_restore_request|bucket_target_sys|lifecycle|metadata|metadata_sys|object_lock|policy_sys|quota|replication|tagging|target|utils|versioning_sys|transition_api|ObjectInfo|ObjectOptions)\b|(?:AppObjectLockConfigExt|AppReplicationConfigExt|AppVersioningConfigExt|predict_lifecycle_expiration|validate_restore_request|bucket_target_sys|lifecycle|metadata|metadata_sys|object_lock|policy_sys|quota|replication|tagging|target|utils|versioning_sys|transition_api|ObjectInfo|ObjectOptions)\b)|(?:@UP@|crate::app::)(?:lifecycle|metadata_sys|object_lock|quota|replication|tagging|target|utils|versioning_sys|transition_api)::|@UP@super::(?:metadata_sys|lifecycle|target)::' \
|
||||
-n --with-filename -- "${APP_USECASE_RULE_SOURCES[@]}" rustfs/src/app/capacity_dirty_scope_test.rs \
|
||||
rustfs/src/app/context.rs rustfs/src/app/context/handles.rs rustfs/src/app/context/interfaces.rs rustfs/src/app/context/runtime_sources.rs ||
|
||||
echo "storage owner import rule could not search its sources"
|
||||
}
|
||||
) >"$RUSTFS_APP_USECASE_STORAGE_API_BYPASS_HITS_FILE"
|
||||
|
||||
@@ -2277,6 +2326,7 @@ fi
|
||||
rg -n --with-filename \
|
||||
'(use crate::storage::(access|request_context|ecfs)|use crate::storage::\{[^}]*Storage(ObjectInfo|ObjectOptions|PutObjReader)|crate::storage::StorageObjectOptions)' \
|
||||
rustfs/src/app/*_test.rs \
|
||||
${LIFECYCLE_TRANSITION_TEST_SPLIT_SOURCES[@]+"${LIFECYCLE_TRANSITION_TEST_SPLIT_SOURCES[@]}"} \
|
||||
rustfs/src/admin/router.rs \
|
||||
rustfs/src/admin/console.rs \
|
||||
rustfs/src/admin/handlers/heal.rs \
|
||||
@@ -2291,16 +2341,14 @@ fi
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
{
|
||||
rg -n --with-filename \
|
||||
'use super::(?:\{[^}]*\b(?:ECStore|EndpointServerPools|Endpoint|Endpoints|PoolEndpoints|StorageClassConfig|TierConfigMgr|DailyAllTierStats|ScannerMetricsReport|ReplicationStats|DynReplicationPool|NotificationSys|BucketBandwidthMonitor|ExpiryState|AppWarmBackend|WarmBackendGetOpts|TierConfig|TierType|PoolStatus|PoolDecommissionInfo|RebalStatus)\b|(?:ECStore|EndpointServerPools|Endpoint|Endpoints|PoolEndpoints|StorageClassConfig|TierConfigMgr|DailyAllTierStats|ScannerMetricsReport|ReplicationStats|DynReplicationPool|NotificationSys|BucketBandwidthMonitor|ExpiryState|AppWarmBackend|WarmBackendGetOpts|TierConfig|TierType|PoolStatus|PoolDecommissionInfo|RebalStatus)\b)' \
|
||||
rustfs/src/app \
|
||||
--glob '*.rs' \
|
||||
--glob '!rustfs/src/app/storage_api.rs' || true
|
||||
rg -n --with-filename \
|
||||
'super::(?:get_server_info|get_total_usable_capacity|get_total_usable_capacity_free|apply_bucket_usage_memory_overlay|load_data_usage_from_backend|record_bucket_object_delete_memory|record_bucket_object_write_memory|remove_bucket_usage_from_backend|get_global_|global_rustfs_port|new_object_layer_fn|set_object_store_resolver|set_global_storage_class|collect_scanner_metrics_report|init_local_disks)|super::super::(?:get_server_info|get_total_usable_capacity|get_total_usable_capacity_free|apply_bucket_usage_memory_overlay|load_data_usage_from_backend|record_bucket_object_delete_memory|record_bucket_object_write_memory|remove_bucket_usage_from_backend|get_global_|global_rustfs_port|new_object_layer_fn|set_object_store_resolver|set_global_storage_class|collect_scanner_metrics_report|init_local_disks)' \
|
||||
rustfs/src/app \
|
||||
--glob '*.rs' \
|
||||
--glob '!rustfs/src/app/storage_api.rs' || true
|
||||
module_relative_rg rustfs/src/app \
|
||||
'use @UP@(?:\{[^}]*\b(?:ECStore|EndpointServerPools|Endpoint|Endpoints|PoolEndpoints|StorageClassConfig|TierConfigMgr|DailyAllTierStats|ScannerMetricsReport|ReplicationStats|DynReplicationPool|NotificationSys|BucketBandwidthMonitor|ExpiryState|AppWarmBackend|WarmBackendGetOpts|TierConfig|TierType|PoolStatus|PoolDecommissionInfo|RebalStatus)\b|(?:ECStore|EndpointServerPools|Endpoint|Endpoints|PoolEndpoints|StorageClassConfig|TierConfigMgr|DailyAllTierStats|ScannerMetricsReport|ReplicationStats|DynReplicationPool|NotificationSys|BucketBandwidthMonitor|ExpiryState|AppWarmBackend|WarmBackendGetOpts|TierConfig|TierType|PoolStatus|PoolDecommissionInfo|RebalStatus)\b)' \
|
||||
-n --with-filename -- "${APP_RUNTIME_RULE_SOURCES[@]}" ||
|
||||
echo "runtime facade import rule could not search its sources"
|
||||
module_relative_rg rustfs/src/app \
|
||||
'@UP@(?:get_server_info|get_total_usable_capacity|get_total_usable_capacity_free|apply_bucket_usage_memory_overlay|load_data_usage_from_backend|record_bucket_object_delete_memory|record_bucket_object_write_memory|remove_bucket_usage_from_backend|get_global_|global_rustfs_port|new_object_layer_fn|set_object_store_resolver|set_global_storage_class|collect_scanner_metrics_report|init_local_disks)|@UP@super::(?:get_server_info|get_total_usable_capacity|get_total_usable_capacity_free|apply_bucket_usage_memory_overlay|load_data_usage_from_backend|record_bucket_object_delete_memory|record_bucket_object_write_memory|remove_bucket_usage_from_backend|get_global_|global_rustfs_port|new_object_layer_fn|set_object_store_resolver|set_global_storage_class|collect_scanner_metrics_report|init_local_disks)' \
|
||||
-n --with-filename -- "${APP_RUNTIME_RULE_SOURCES[@]}" ||
|
||||
echo "runtime facade path rule could not search its sources"
|
||||
rg -n --with-filename \
|
||||
'crate::app::(?:ECStore|EndpointServerPools|Endpoint|Endpoints|PoolEndpoints|StorageClassConfig|TierConfigMgr|DailyAllTierStats|ScannerMetricsReport|ReplicationStats|DynReplicationPool|NotificationSys|BucketBandwidthMonitor|ExpiryState|get_global_|set_object_store_resolver)' \
|
||||
rustfs/src --glob '*.rs' || true
|
||||
@@ -2524,9 +2572,22 @@ fi
|
||||
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
rg -n -U --with-filename \
|
||||
'use\s+(?:super::super|crate::storage)::\{[^}]*?(?:BucketMetadata|DEFAULT_READ_BUFFER_SIZE|bucket_metadata_sys_initialized|get_global_bucket_metadata_sys|set_bucket_metadata|apply_cors_headers|apply_default_lock_retention_metadata|check_preconditions|decode_tags_to_map|get_adaptive_buffer_size_with_profile|get_buffer_size_opt_in|is_etag_equal|matches_origin_pattern|parse_etag|parse_object_lock_legal_hold|parse_object_lock_retention|process_lambda_configurations|process_queue_configurations|process_topic_configurations|remove_object_lock_metadata_for_copy|remove_object_lock_retention_metadata|validate_bucket_object_lock_enabled|validate_list_object_unordered_with_delimiter)' \
|
||||
rustfs/src/storage/ecfs_test.rs || true
|
||||
# ecfs_test.rs holds its tests in an inline `mod tests`, so the storage root
|
||||
# is one super:: past the depth of the file itself. legacy_edge/tests/ is
|
||||
# where a split of ecfs.rs moves them, with or without that inline module,
|
||||
# so both depths are forbidden there.
|
||||
{
|
||||
module_relative_rg rustfs/src/storage \
|
||||
'use\s+(?:@UP@super::|crate::storage::)\{[^}]*?(?:BucketMetadata|DEFAULT_READ_BUFFER_SIZE|bucket_metadata_sys_initialized|get_global_bucket_metadata_sys|set_bucket_metadata|apply_cors_headers|apply_default_lock_retention_metadata|check_preconditions|decode_tags_to_map|get_adaptive_buffer_size_with_profile|get_buffer_size_opt_in|is_etag_equal|matches_origin_pattern|parse_etag|parse_object_lock_legal_hold|parse_object_lock_retention|process_lambda_configurations|process_queue_configurations|process_topic_configurations|remove_object_lock_metadata_for_copy|remove_object_lock_retention_metadata|validate_bucket_object_lock_enabled|validate_list_object_unordered_with_delimiter)' \
|
||||
-n -U --with-filename -- rustfs/src/storage/ecfs_test.rs ||
|
||||
echo "storage-owner test import rule could not search rustfs/src/storage/ecfs_test.rs"
|
||||
if [[ ${#ECFS_TEST_GUARD_SOURCES[@]} -gt 1 ]]; then
|
||||
module_relative_rg rustfs/src/storage \
|
||||
'use\s+(?:@UP@(?:super::)?|crate::storage::)\{[^}]*?(?:BucketMetadata|DEFAULT_READ_BUFFER_SIZE|bucket_metadata_sys_initialized|get_global_bucket_metadata_sys|set_bucket_metadata|apply_cors_headers|apply_default_lock_retention_metadata|check_preconditions|decode_tags_to_map|get_adaptive_buffer_size_with_profile|get_buffer_size_opt_in|is_etag_equal|matches_origin_pattern|parse_etag|parse_object_lock_legal_hold|parse_object_lock_retention|process_lambda_configurations|process_queue_configurations|process_topic_configurations|remove_object_lock_metadata_for_copy|remove_object_lock_retention_metadata|validate_bucket_object_lock_enabled|validate_list_object_unordered_with_delimiter)' \
|
||||
-n -U --with-filename -- "${ECFS_TEST_GUARD_SOURCES[@]:1}" ||
|
||||
echo "storage-owner test import rule could not search rustfs/src/storage/legacy_edge/tests"
|
||||
fi
|
||||
}
|
||||
) >"$RUSTFS_STORAGE_OWNER_TEST_ROOT_CONSUMER_HITS_FILE"
|
||||
|
||||
if [[ -s "$RUSTFS_STORAGE_OWNER_TEST_ROOT_CONSUMER_HITS_FILE" ]]; then
|
||||
@@ -4561,7 +4622,7 @@ fi
|
||||
cd "$ROOT_DIR"
|
||||
rg -n --with-filename 'crate::storage::core_storage_compat' \
|
||||
rustfs/src/storage/mod.rs \
|
||||
rustfs/src/storage/ecfs.rs \
|
||||
"${ECFS_GUARD_SOURCES[@]}" \
|
||||
rustfs/src/storage/ecfs_extend.rs \
|
||||
rustfs/src/storage/access.rs \
|
||||
rustfs/src/storage/head_prefix.rs \
|
||||
@@ -4577,11 +4638,12 @@ fi
|
||||
cd "$ROOT_DIR"
|
||||
{
|
||||
rg -n --with-filename 'crate::app::usecase_storage_compat' \
|
||||
rustfs/src/app/*.rs || true
|
||||
rustfs/src/app/*.rs ${BUCKET_USECASE_SPLIT_SOURCES[@]+"${BUCKET_USECASE_SPLIT_SOURCES[@]}"} ${MULTIPART_USECASE_SPLIT_SOURCES[@]+"${MULTIPART_USECASE_SPLIT_SOURCES[@]}"} \
|
||||
${LIFECYCLE_TRANSITION_TEST_SPLIT_SOURCES[@]+"${LIFECYCLE_TRANSITION_TEST_SPLIT_SOURCES[@]}"} || true
|
||||
rg -n --with-filename 'crate::admin::router_storage_compat' \
|
||||
rustfs/src/admin/router.rs || true
|
||||
rg -n --with-filename 'crate::storage::core_storage_compat' \
|
||||
rustfs/src/storage/ecfs_test.rs || true
|
||||
"${ECFS_TEST_GUARD_SOURCES[@]}" || true
|
||||
}
|
||||
) >"$RUSTFS_LOCAL_COMPAT_RELATIVE_CONSUMER_HITS_FILE"
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ classify_source_layer() {
|
||||
printf 'composition'
|
||||
elif [[ "$file" == rustfs/src/app/* ]]; then
|
||||
printf 'app'
|
||||
elif [[ "$file" == rustfs/src/admin/* ]] || [[ "$file" == rustfs/src/storage/ecfs.rs ]] || [[ "$file" == rustfs/src/storage/s3_api/* ]] ||
|
||||
elif [[ "$file" == rustfs/src/admin/* ]] || [[ "$file" == rustfs/src/storage/ecfs.rs ]] ||
|
||||
[[ "$file" == rustfs/src/storage/legacy_edge/* ]] || [[ "$file" == rustfs/src/storage/s3_api/* ]] ||
|
||||
[[ "$file" == rustfs/src/storage/s3_compat/* ]]; then
|
||||
printf 'interface'
|
||||
elif [[ "$file" == rustfs/src/* ]]; then
|
||||
@@ -55,6 +56,7 @@ classify_target_layer() {
|
||||
storage)
|
||||
storage_path="${path#storage::}"
|
||||
if [[ "$storage_path" == "ecfs" ]] || [[ "$storage_path" == ecfs::* ]] ||
|
||||
[[ "$storage_path" == "legacy_edge" ]] || [[ "$storage_path" == legacy_edge::* ]] ||
|
||||
[[ "$storage_path" == "s3_api" ]] || [[ "$storage_path" == s3_api::* ]] ||
|
||||
[[ "$storage_path" == "s3_compat" ]] || [[ "$storage_path" == s3_compat::* ]]; then
|
||||
printf 'interface'
|
||||
@@ -137,6 +139,60 @@ run_layer_model_self_tests() {
|
||||
assert_dependency_direction 'allowed' "$s3_compat_source" "$app_target"
|
||||
assert_dependency_direction 'reverse' "$app_source" "$s3_compat_target"
|
||||
assert_dependency_direction 'reverse' "$storage_source" "$s3_compat_target"
|
||||
|
||||
# A mechanical split moves ecfs.rs code into storage/legacy_edge/ and the
|
||||
# usecase files' code into same-named directories; each keeps its layer.
|
||||
assert_source_layer 'interface' rustfs/src/storage/ecfs.rs
|
||||
assert_source_layer 'interface' rustfs/src/storage/legacy_edge/bucket_core.rs
|
||||
assert_source_layer 'interface' rustfs/src/storage/legacy_edge/tests/object_meta.rs
|
||||
assert_source_layer 'infra' rustfs/src/storage/legacy_edge.rs
|
||||
assert_source_layer 'infra' rustfs/src/storage/legacy_edge_helper.rs
|
||||
assert_source_layer 'app' rustfs/src/app/bucket_usecase/cors.rs
|
||||
assert_source_layer 'app' rustfs/src/app/multipart_usecase/complete.rs
|
||||
assert_target_layer 'interface' storage::legacy_edge
|
||||
assert_target_layer 'interface' storage::legacy_edge::object_meta
|
||||
assert_target_layer 'infra' storage::legacy_edge_helper
|
||||
assert_dependency_direction 'allowed' "$(classify_source_layer rustfs/src/storage/legacy_edge/bucket_core.rs)" "$app_target"
|
||||
assert_dependency_direction 'reverse' "$app_source" "$(classify_target_layer storage::legacy_edge::bucket_core)"
|
||||
assert_dependency_direction 'reverse' "$storage_source" "$(classify_target_layer storage::legacy_edge)"
|
||||
assert_dedicated_test_file 'yes' rustfs/src/app/lifecycle_transition_api_test.rs
|
||||
assert_dedicated_test_file 'yes' rustfs/src/app/lifecycle_transition_api_test/put.rs
|
||||
assert_dedicated_test_file 'no' rustfs/src/app/lifecycle_transition_api_test_support.rs
|
||||
assert_dedicated_test_file 'no' rustfs/src/app/lifecycle_transition_api.rs
|
||||
assert_dedicated_test_file 'no' rustfs/src/app/bucket_usecase/cors.rs
|
||||
assert_dedicated_test_file 'no' rustfs/src/storage/legacy_edge/bucket_core.rs
|
||||
}
|
||||
|
||||
assert_source_layer() {
|
||||
local expected="$1" file="$2" actual
|
||||
|
||||
actual="$(classify_source_layer "$file")"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
printf 'Layer dependency guard self-test failed: %s classified as %s (expected %s)\n' "$file" "$actual" "$expected" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_target_layer() {
|
||||
local expected="$1" path="$2" actual
|
||||
|
||||
actual="$(classify_target_layer "$path")"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
printf 'Layer dependency guard self-test failed: crate::%s classified as %s (expected %s)\n' "$path" "$actual" "$expected" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_dedicated_test_file() {
|
||||
local expected="$1" file="$2" actual='no'
|
||||
|
||||
if is_dedicated_test_file "$file"; then
|
||||
actual='yes'
|
||||
fi
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
printf 'Layer dependency guard self-test failed: %s dedicated test file = %s (expected %s)\n' "$file" "$actual" "$expected" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
normalize_import_group_item() {
|
||||
@@ -251,11 +307,20 @@ normalize_import_path() {
|
||||
printf '%s\n' "$path"
|
||||
}
|
||||
|
||||
# The guard is file-scoped: dedicated test modules are excluded, while inline
|
||||
# #[cfg(test)] imports remain subject to the source file's layer. The
|
||||
# lifecycle_transition_api_test/ directory is where a mechanical split of
|
||||
# lifecycle_transition_api_test.rs moves its tests (rustfs/backlog#2765).
|
||||
is_dedicated_test_file() {
|
||||
local file="$1"
|
||||
|
||||
[[ "$file" == *_test.rs ]] || [[ "$file" == */tests/* ]] ||
|
||||
[[ "$file" == rustfs/src/app/lifecycle_transition_api_test/* ]]
|
||||
}
|
||||
|
||||
emit_crate_use_statements() {
|
||||
(cd "$ROOT_DIR" && rg --files -g '*.rs' rustfs/src | while IFS= read -r file; do
|
||||
# The guard is file-scoped: dedicated test modules are excluded, while
|
||||
# inline #[cfg(test)] imports remain subject to the source file's layer.
|
||||
if [[ "$file" == *_test.rs ]] || [[ "$file" == */tests/* ]]; then
|
||||
if is_dedicated_test_file "$file"; then
|
||||
continue
|
||||
fi
|
||||
perl -0777 -ne '
|
||||
|
||||
@@ -1094,11 +1094,29 @@ trace_hot_spans=(
|
||||
"rustfs/src/app/object:execute_get_object"
|
||||
)
|
||||
|
||||
# A hot span in a file that is due for a mechanical split (rustfs/backlog#2765)
|
||||
# is also looked up in the directory the split moves its functions into; the
|
||||
# named file itself stays required.
|
||||
# shellcheck source=scripts/guard_source_set.sh
|
||||
source "${repo_root}/scripts/guard_source_set.sh"
|
||||
|
||||
for hot_span in "${trace_hot_spans[@]}"; do
|
||||
file="${hot_span%%:*}"
|
||||
function="${hot_span##*:}"
|
||||
hot_span_sources=("$file")
|
||||
split_dir=""
|
||||
case "$file" in
|
||||
rustfs/src/app/bucket_usecase.rs) split_dir="rustfs/src/app/bucket_usecase" ;;
|
||||
esac
|
||||
if [[ -n "$split_dir" ]]; then
|
||||
if ! guard_source_set "$repo_root" "$file" "$split_dir"; then
|
||||
echo "❌ logging guardrail violation: $file::$function guard source is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
hot_span_sources=("${GUARD_SOURCES[@]}")
|
||||
fi
|
||||
trace_span_pattern="#\\[(tracing::)?instrument\\([^]]*level = \\\"trace\\\"[^]]*\\)\\]([[:space:]]+#\\[[^]]+\\])*[[:space:]]+(pub(\\([^)]*\\))?[[:space:]]+)?(super[[:space:]]+)?async fn ${function}\\b"
|
||||
if ! rg -U "$trace_span_pattern" "$file" >/dev/null; then
|
||||
if ! rg -U "$trace_span_pattern" "${hot_span_sources[@]}" >/dev/null; then
|
||||
echo "❌ logging guardrail violation: $file::$function must remain TRACE-only" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Executable
+442
@@ -0,0 +1,442 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Guard: tests outside an operation's owning family call its use-case only through the test seam.
|
||||
|
||||
Preparatory refactor P4 of the s3s-to-gateway migration. Each migration batch
|
||||
changes the use-case signatures of its own operations. Tests owned by another
|
||||
family that call those entry points as an oracle would break and pull a second
|
||||
batch into the same file, so they call the `#[cfg(test)]` seams in
|
||||
rustfs/src/app/test_seams/ instead; the owning batch then edits one seam body.
|
||||
|
||||
For every entry point in SEAMS this guard fails when
|
||||
|
||||
* a direct method call `.execute_<op>(` appears in test code (a module or
|
||||
item under `#[cfg(test)]`, a file whose module declaration or an ancestor's
|
||||
is under `#[cfg(test)]`, or any file under rustfs/tests/ or rustfs/benches/)
|
||||
in a file that is neither the seam nor one of the operation's owner files;
|
||||
* the seam file is missing, or no longer calls the entry point exactly once;
|
||||
* an owner file listed below does not exist (a stale table must not widen
|
||||
what passes);
|
||||
* a file holding a direct call cannot be placed in the module tree;
|
||||
* no Rust file is found at all.
|
||||
|
||||
Production calls (the legacy S3 trait, admin and metadata routes) are not test
|
||||
code and are not judged here. Comments and string literals are ignored.
|
||||
|
||||
Usage:
|
||||
scripts/check_test_seams.py
|
||||
scripts/check_test_seams.py --self-test
|
||||
|
||||
Exit status: 0 clean, 1 violations or missing inputs, 2 usage error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
SCAN_ROOT = "rustfs"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Seam:
|
||||
batch: str
|
||||
seam_file: str
|
||||
owners: tuple[str, ...] # files that own the operation; each must exist
|
||||
owner_dirs: tuple[str, ...] = () # directories a split of an owner file may create
|
||||
|
||||
|
||||
SEAMS = {
|
||||
"execute_get_object": Seam(
|
||||
batch="C",
|
||||
seam_file="rustfs/src/app/test_seams/get_object.rs",
|
||||
owners=(
|
||||
"rustfs/src/app/object/get.rs",
|
||||
"rustfs/src/app/object/head.rs",
|
||||
"rustfs/src/app/object/conditional_read_bench.rs",
|
||||
"rustfs/src/app/object/on_demand_migration_put.rs",
|
||||
),
|
||||
),
|
||||
"execute_head_object": Seam(
|
||||
batch="C",
|
||||
seam_file="rustfs/src/app/test_seams/head_object.rs",
|
||||
owners=(
|
||||
"rustfs/src/app/object/get.rs",
|
||||
"rustfs/src/app/object/head.rs",
|
||||
),
|
||||
),
|
||||
"execute_list_objects_v2": Seam(
|
||||
batch="B2",
|
||||
seam_file="rustfs/src/app/test_seams/list_objects_v2.rs",
|
||||
owners=("rustfs/src/app/bucket_usecase.rs",),
|
||||
# P2 splits bucket_usecase.rs into bucket_usecase/*.rs; narrow this to the listing file once it exists.
|
||||
owner_dirs=("rustfs/src/app/bucket_usecase/",),
|
||||
),
|
||||
"execute_put_object": Seam(
|
||||
batch="D",
|
||||
seam_file="rustfs/src/app/test_seams/put_object.rs",
|
||||
owners=(
|
||||
"rustfs/src/app/object/put.rs",
|
||||
"rustfs/src/app/lifecycle_transition_api_test/put.rs",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
CFG_TEST = re.compile(r"#!?\[\s*cfg\s*\(\s*(?:all\s*\(\s*)?test\b")
|
||||
TEST_ATTR = re.compile(r"#\[\s*(?:tokio::)?test\b")
|
||||
CALL = re.compile(r"\.\s*(execute_get_object|execute_head_object|execute_list_objects_v2|execute_put_object)\s*\(")
|
||||
CHAR_LITERAL = re.compile(r"'(?:\\(?:[nrt\\0'\"]|x[0-9a-fA-F]{2}|u\{[0-9a-fA-F_]{1,6}\})|[^\\'\n])'")
|
||||
RAW_STRING = re.compile(r'b?r(#*)"')
|
||||
|
||||
|
||||
class InputError(Exception):
|
||||
"""A required input is missing or cannot be interpreted."""
|
||||
|
||||
|
||||
def code_mask(text: str) -> bytearray:
|
||||
"""1 for every character that is Rust code, 0 inside comments and literals."""
|
||||
mask = bytearray(len(text))
|
||||
i, n = 0, len(text)
|
||||
while i < n:
|
||||
c = text[i]
|
||||
if text.startswith("//", i):
|
||||
j = text.find("\n", i)
|
||||
i = n if j < 0 else j
|
||||
continue
|
||||
if text.startswith("/*", i):
|
||||
depth, j = 1, i + 2
|
||||
while j < n and depth:
|
||||
if text.startswith("/*", j):
|
||||
depth, j = depth + 1, j + 2
|
||||
elif text.startswith("*/", j):
|
||||
depth, j = depth - 1, j + 2
|
||||
else:
|
||||
j += 1
|
||||
i = j
|
||||
continue
|
||||
raw = RAW_STRING.match(text, i)
|
||||
if raw and (i == 0 or not (text[i - 1].isalnum() or text[i - 1] == "_")):
|
||||
end = text.find('"' + raw.group(1), raw.end())
|
||||
i = n if end < 0 else end + 1 + len(raw.group(1))
|
||||
continue
|
||||
if c == '"' or (text.startswith('b"', i) and (i == 0 or not text[i - 1].isalnum())):
|
||||
j = i + (2 if c == "b" else 1)
|
||||
while j < n and text[j] != '"':
|
||||
j += 2 if text[j] == "\\" else 1
|
||||
i = j + 1
|
||||
continue
|
||||
if c == "'":
|
||||
char = CHAR_LITERAL.match(text, i)
|
||||
if char:
|
||||
i = char.end()
|
||||
continue
|
||||
mask[i] = 1
|
||||
i += 1
|
||||
return mask
|
||||
|
||||
|
||||
def test_regions(text: str, mask: bytearray) -> list[tuple[int, int]]:
|
||||
"""Spans of items under `#[cfg(test)]` or `#[test]`/`#[tokio::test]` (the whole file for `#![cfg(test)]`)."""
|
||||
regions = []
|
||||
for match in [*CFG_TEST.finditer(text), *TEST_ATTR.finditer(text)]:
|
||||
if not mask[match.start()]:
|
||||
continue
|
||||
if text[match.start() + 1] == "!":
|
||||
return [(0, len(text))]
|
||||
regions.append((match.start(), _item_end(text, mask, match.start())))
|
||||
return regions
|
||||
|
||||
|
||||
def _item_end(text: str, mask: bytearray, attr_start: int) -> int:
|
||||
"""End offset of the item an attribute at attr_start applies to."""
|
||||
depth, i, n = 0, attr_start, len(text)
|
||||
in_attr = True
|
||||
while i < n:
|
||||
if mask[i]:
|
||||
ch = text[i]
|
||||
if ch in "([{":
|
||||
if ch == "{" and depth == 0 and not in_attr:
|
||||
return _matching(text, mask, i) + 1
|
||||
depth += 1
|
||||
elif ch in ")]}":
|
||||
depth -= 1
|
||||
if depth == 0 and ch == "]":
|
||||
in_attr = False
|
||||
elif ch == ";" and depth == 0 and not in_attr:
|
||||
return i + 1
|
||||
elif ch == "#" and depth == 0:
|
||||
in_attr = True
|
||||
i += 1
|
||||
raise InputError(f"unterminated item after attribute at offset {attr_start}")
|
||||
|
||||
|
||||
def _matching(text: str, mask: bytearray, open_pos: int) -> int:
|
||||
depth = 0
|
||||
for i in range(open_pos, len(text)):
|
||||
if mask[i]:
|
||||
depth += text[i] in "([{"
|
||||
depth -= text[i] in ")]}"
|
||||
if depth == 0:
|
||||
return i
|
||||
raise InputError(f"unbalanced brace at offset {open_pos}")
|
||||
|
||||
|
||||
def module_declaration(root: Path, path: str) -> tuple[list[str], str] | None:
|
||||
"""(candidate parent files, module name) for a file below src/, or None for a crate root."""
|
||||
parts = Path(path)
|
||||
if parts.name in ("lib.rs", "main.rs") and parts.parent.name == "src":
|
||||
return None
|
||||
if parts.name == "mod.rs":
|
||||
name, directory = parts.parent.name, parts.parent.parent
|
||||
else:
|
||||
name, directory = parts.stem, parts.parent
|
||||
if directory.name == "bin" and parts.name != "mod.rs":
|
||||
return None
|
||||
candidates = [directory.with_suffix(".rs"), directory / "mod.rs"]
|
||||
if directory.name == "src":
|
||||
candidates = [directory / "lib.rs", directory / "main.rs"]
|
||||
return [str(parent) for parent in candidates if (root / parent).is_file()], name
|
||||
|
||||
|
||||
def is_test_only_file(root: Path, path: str, cache: dict[str, bool]) -> bool:
|
||||
if path in cache:
|
||||
return cache[path]
|
||||
parts = Path(path).parts
|
||||
if len(parts) > 1 and parts[1] in ("tests", "benches"):
|
||||
cache[path] = True
|
||||
return True
|
||||
text = (root / path).read_text()
|
||||
mask = code_mask(text)
|
||||
if any(text[m.start() + 1] == "!" for m in CFG_TEST.finditer(text) if mask[m.start()]):
|
||||
cache[path] = True
|
||||
return True
|
||||
declaration = module_declaration(root, path)
|
||||
if declaration is None:
|
||||
if Path(path).name in ("lib.rs", "main.rs") or Path(path).parent.name == "bin":
|
||||
cache[path] = False
|
||||
return False
|
||||
raise InputError(f"{path}: cannot find the file that declares this module")
|
||||
parents, name = declaration
|
||||
declarations = []
|
||||
for parent in parents:
|
||||
parent_text = (root / parent).read_text()
|
||||
parent_mask = code_mask(parent_text)
|
||||
pattern = rf"(?m)^[ \t]*(?:pub(?:\([^)\n]*\))?[ \t]+)?mod[ \t]+{re.escape(name)}[ \t]*;"
|
||||
for m in re.finditer(pattern, parent_text):
|
||||
if parent_mask[m.start() + len(m.group()) - len(m.group().lstrip())]:
|
||||
declarations.append((parent, parent_text, parent_mask, m.start()))
|
||||
if len(declarations) != 1:
|
||||
raise InputError(f"{path}: expected one `mod {name};` in {' or '.join(parents) or 'a parent file'}, found {len(declarations)}")
|
||||
parent, parent_text, parent_mask, offset = declarations[0]
|
||||
cfg_test = any(start <= offset < end for start, end in test_regions(parent_text, parent_mask))
|
||||
result = cfg_test or is_test_only_file(root, parent, cache)
|
||||
cache[path] = result
|
||||
return result
|
||||
|
||||
|
||||
def rust_files(root: Path) -> list[str]:
|
||||
listed = subprocess.run(
|
||||
["git", "ls-files", "--cached", "--others", "--exclude-standard", "--", f"{SCAN_ROOT}/*.rs"],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if listed.returncode != 0:
|
||||
raise InputError(f"git ls-files failed: {listed.stderr.strip()}")
|
||||
files = sorted(line for line in listed.stdout.splitlines() if line and (root / line).is_file())
|
||||
if not files:
|
||||
raise InputError(f"no Rust files found under {SCAN_ROOT}/")
|
||||
return files
|
||||
|
||||
|
||||
def check(root: Path, files: list[str] | None = None) -> list[str]:
|
||||
errors: list[str] = []
|
||||
for method, seam in SEAMS.items():
|
||||
seam_path = root / seam.seam_file
|
||||
if not seam_path.is_file():
|
||||
errors.append(f"{seam.seam_file}: seam for {method} is missing")
|
||||
else:
|
||||
text = seam_path.read_text()
|
||||
mask = code_mask(text)
|
||||
calls = [m for m in CALL.finditer(text) if mask[m.start()] and m.group(1) == method]
|
||||
if len(calls) != 1:
|
||||
errors.append(f"{seam.seam_file}: must call {method} exactly once, found {len(calls)}")
|
||||
for owner in seam.owners:
|
||||
if not (root / owner).is_file():
|
||||
errors.append(f"{owner}: owner file of {method} is missing; update SEAMS in {Path(__file__).name}")
|
||||
files = rust_files(root) if files is None else files
|
||||
cache: dict[str, bool] = {}
|
||||
for path in files:
|
||||
text = (root / path).read_text()
|
||||
mask = code_mask(text)
|
||||
calls = [m for m in CALL.finditer(text) if mask[m.start()]]
|
||||
if not calls:
|
||||
continue
|
||||
whole_file = is_test_only_file(root, path, cache)
|
||||
regions = test_regions(text, mask)
|
||||
for match in calls:
|
||||
method = match.group(1)
|
||||
seam = SEAMS[method]
|
||||
if path == seam.seam_file or path in seam.owners or path.startswith(seam.owner_dirs or ("\0",)):
|
||||
continue
|
||||
if whole_file or any(start <= match.start() < end for start, end in regions):
|
||||
line = text.count("\n", 0, match.start()) + 1
|
||||
errors.append(
|
||||
f"{path}:{line}: test calls {method} directly; batch {seam.batch} owns it, "
|
||||
f"call crate::app::test_seams::{method.removeprefix('execute_')} instead"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if argv == ["--self-test"]:
|
||||
suite = unittest.defaultTestLoader.loadTestsFromTestCase(SelfTest)
|
||||
return 0 if unittest.TextTestRunner(verbosity=1).run(suite).wasSuccessful() else 1
|
||||
if argv:
|
||||
print(__doc__.split("Usage:", 1)[1].split("\n\n", 1)[0], file=sys.stderr)
|
||||
return 2
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
try:
|
||||
errors = check(root)
|
||||
except InputError as error:
|
||||
print(f"test seam guard: {error}", file=sys.stderr)
|
||||
return 1
|
||||
for error in errors:
|
||||
print(error, file=sys.stderr)
|
||||
if errors:
|
||||
print(f"test seam guard failed: {len(errors)} finding(s)", file=sys.stderr)
|
||||
return 1
|
||||
print("test seam guard passed")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Self-test
|
||||
|
||||
|
||||
SEAM_BODY = {
|
||||
"execute_get_object": "pub(crate) fn get_object(u: &U, r: R) -> F { u.execute_get_object(r) }\n",
|
||||
"execute_head_object": "pub(crate) fn head_object(u: &U, r: R) -> F { u.execute_head_object(r) }\n",
|
||||
"execute_list_objects_v2": "pub(crate) fn list_objects_v2(u: &U, r: R) -> F { u.execute_list_objects_v2(r) }\n",
|
||||
"execute_put_object": "pub(crate) fn put_object(u: &U, f: &Fs, r: R) -> F { u.execute_put_object(f, r) }\n",
|
||||
}
|
||||
|
||||
|
||||
class SelfTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.tmp.name)
|
||||
self.files: dict[str, str] = {}
|
||||
self.write("rustfs/src/main.rs", "mod app;\n")
|
||||
self.write(
|
||||
"rustfs/src/app/mod.rs",
|
||||
"mod object;\nmod bucket_usecase;\nmod lifecycle_transition_api_test;\nmod storage;\n"
|
||||
"#[cfg(test)]\nmod test_seams;\n#[cfg(test)]\nmod gating_test;\n",
|
||||
)
|
||||
self.write("rustfs/src/app/object/mod.rs", "mod get;\nmod head;\nmod put;\n#[cfg(test)]\nmod conditional_read_bench;\nmod on_demand_migration_put;\n")
|
||||
self.write("rustfs/src/app/test_seams/mod.rs", "mod get_object;\nmod head_object;\nmod list_objects_v2;\nmod put_object;\n")
|
||||
for method, seam in SEAMS.items():
|
||||
self.write(seam.seam_file, SEAM_BODY[method])
|
||||
for owner in seam.owners:
|
||||
if owner not in self.files:
|
||||
self.write(owner, "fn owner() {}\n")
|
||||
self.write("rustfs/src/app/lifecycle_transition_api_test/mod.rs", "mod put;\nmod delete;\n")
|
||||
self.write("rustfs/src/app/storage.rs", "fn prod(u: &U) { u.execute_get_object(r); }\n")
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def write(self, path: str, text: str):
|
||||
target = self.root / path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(text)
|
||||
self.files[path] = text
|
||||
|
||||
def run_check(self) -> list[str]:
|
||||
return check(self.root, sorted(p for p in self.files if p.endswith(".rs")))
|
||||
|
||||
# -- negative cases: each must fail --
|
||||
|
||||
def test_direct_call_in_cfg_test_module_of_non_owner_fails(self):
|
||||
self.write("rustfs/src/app/storage.rs", "fn prod() {}\n#[cfg(test)]\nmod tests {\n fn t() { u.execute_get_object(r); }\n}\n")
|
||||
self.assertEqual(len(self.run_check()), 1)
|
||||
|
||||
def test_direct_call_split_across_lines_fails(self):
|
||||
self.write("rustfs/src/app/storage.rs", "#[cfg(test)]\nmod tests {\n fn t() { usecase\n .execute_head_object(r); }\n}\n")
|
||||
self.assertRegex(self.run_check()[0], r"storage\.rs:4: test calls execute_head_object directly")
|
||||
|
||||
def test_direct_call_in_cfg_test_helper_fn_fails(self):
|
||||
self.write("rustfs/src/app/storage.rs", "#[cfg(test)]\n#[allow(dead_code)]\nfn helper() { u.execute_list_objects_v2(r); }\n")
|
||||
self.assertEqual(len(self.run_check()), 1)
|
||||
|
||||
def test_file_declared_under_cfg_test_fails(self):
|
||||
self.write("rustfs/src/app/gating_test.rs", "fn t() { u.execute_put_object(&fs, r); }\n")
|
||||
self.assertEqual(len(self.run_check()), 1)
|
||||
|
||||
def test_file_whose_ancestor_is_cfg_test_fails(self):
|
||||
self.write("rustfs/src/app/test_seams/extra.rs", "fn t() { u.execute_get_object(r); }\n")
|
||||
self.write("rustfs/src/app/test_seams/mod.rs", self.files["rustfs/src/app/test_seams/mod.rs"] + "mod extra;\n")
|
||||
self.assertEqual(len(self.run_check()), 1)
|
||||
|
||||
def test_put_from_non_owner_lifecycle_file_fails(self):
|
||||
self.write("rustfs/src/app/lifecycle_transition_api_test/delete.rs", "#[test]\nfn t() { u.execute_put_object(&fs, r); }\n")
|
||||
self.write("rustfs/src/app/mod.rs", self.files["rustfs/src/app/mod.rs"].replace("mod lifecycle", "#[cfg(test)]\nmod lifecycle"))
|
||||
self.assertEqual(len(self.run_check()), 1)
|
||||
|
||||
def test_direct_call_in_test_fn_without_cfg_test_fails(self):
|
||||
self.write("rustfs/src/app/storage.rs", "#[tokio::test]\nasync fn t() { u.execute_get_object(r).await; }\n")
|
||||
self.assertEqual(len(self.run_check()), 1)
|
||||
|
||||
def test_integration_test_file_fails(self):
|
||||
self.write("rustfs/tests/oracle.rs", "fn t() { u.execute_get_object(r); }\n")
|
||||
self.assertEqual(len(self.run_check()), 1)
|
||||
|
||||
def test_missing_seam_fails(self):
|
||||
(self.root / SEAMS["execute_get_object"].seam_file).unlink()
|
||||
del self.files[SEAMS["execute_get_object"].seam_file]
|
||||
self.assertRegex(" ".join(self.run_check()), "seam for execute_get_object is missing")
|
||||
|
||||
def test_seam_that_stopped_wrapping_fails(self):
|
||||
self.write(SEAMS["execute_put_object"].seam_file, "pub(crate) fn put_object() { todo!() }\n")
|
||||
self.assertRegex(" ".join(self.run_check()), "must call execute_put_object exactly once, found 0")
|
||||
|
||||
def test_missing_owner_file_fails(self):
|
||||
owner = SEAMS["execute_put_object"].owners[0]
|
||||
(self.root / owner).unlink()
|
||||
del self.files[owner]
|
||||
self.assertRegex(" ".join(self.run_check()), "owner file of execute_put_object is missing")
|
||||
|
||||
def test_undeclared_file_with_direct_call_fails_loudly(self):
|
||||
self.write("rustfs/src/app/orphan.rs", "fn t() { u.execute_get_object(r); }\n")
|
||||
with self.assertRaisesRegex(InputError, "expected one `mod orphan;`"):
|
||||
self.run_check()
|
||||
|
||||
def test_no_rust_files_fails(self):
|
||||
empty = Path(self.tmp.name) / "empty"
|
||||
empty.mkdir()
|
||||
subprocess.run(["git", "init", "-q"], cwd=empty, check=True)
|
||||
with self.assertRaisesRegex(InputError, "no Rust files found"):
|
||||
rust_files(empty)
|
||||
|
||||
# -- positive cases: each must pass --
|
||||
|
||||
def test_seam_call_in_non_owner_test_passes(self):
|
||||
self.write("rustfs/src/app/storage.rs", "#[cfg(test)]\nmod tests {\n fn t() { crate::app::test_seams::get_object(&u, r); }\n}\n")
|
||||
self.assertEqual(self.run_check(), [])
|
||||
|
||||
def test_owner_test_and_production_calls_pass(self):
|
||||
self.write("rustfs/src/app/object/put.rs", "fn prod() {}\n#[cfg(test)]\nmod tests {\n fn t() { u.execute_put_object(&fs, r); }\n}\n")
|
||||
self.assertEqual(self.run_check(), [])
|
||||
|
||||
def test_calls_in_comments_and_strings_are_ignored(self):
|
||||
self.write("rustfs/src/app/storage.rs", '#[cfg(test)]\nmod tests {\n // u.execute_get_object(r)\n const S: &str = "u.execute_get_object(r)";\n}\n')
|
||||
self.assertEqual(self.run_check(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
+624
@@ -0,0 +1,624 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Split rustfs/src/app/lifecycle_transition_api_test.rs into one file per operation owner.
|
||||
|
||||
Preparatory refactor P4 for the s3s-to-gateway migration. Nine batches each
|
||||
migrate one family of S3 operations and change that family's use-case
|
||||
signatures. While every lifecycle integration test lives in one 3,700-line
|
||||
file, two batches edit the same file. This codemod moves every top-level item
|
||||
of that file, verbatim with its attributes and comments, into one of the
|
||||
module files below, chosen by the explicit ASSIGNMENT table:
|
||||
|
||||
lifecycle_transition_api_test.rs module root: license header, the
|
||||
original `use` block, module list
|
||||
lifecycle_transition_api_test/
|
||||
common.rs shared fixtures (no owner; frozen during batches)
|
||||
transition.rs ILM transition-engine tests that call no S3 use-case
|
||||
lifecycle_config.rs PutBucketLifecycleConfiguration (batch A3)
|
||||
object_lock.rs PutObjectRetention / PutObjectLegalHold (batch B1)
|
||||
put.rs PutObject (batch D)
|
||||
copy.rs CopyObject, including tests that mix put and copy (batch E)
|
||||
delete.rs DeleteObject / DeleteObjects (batch E)
|
||||
restore.rs RestoreObject (batch E)
|
||||
multipart.rs CompleteMultipartUpload (batch E)
|
||||
|
||||
Items are moved verbatim. The only rewrites, applied to moved items and
|
||||
nowhere else, are the ones a move out of the parent module requires:
|
||||
|
||||
* `super::` becomes `super::super::` (the items now live one module deeper);
|
||||
* `s3s::S3ErrorCode` becomes `S3ErrorCode`, imported once in the module root
|
||||
from `crate::app::storage_api::s3`, and a function-local `use s3s::S3;`
|
||||
becomes `use crate::app::storage_api::s3::S3;`, so no new file names s3s
|
||||
(scripts/check_s3s_footprint.sh counts files);
|
||||
* items placed in common.rs that a sibling file uses gain `pub(super)`;
|
||||
* a call to a test seam's use-case entry point (`execute_get_object`,
|
||||
`execute_head_object`, `execute_list_objects_v2`, `execute_put_object`) in
|
||||
a file that does not own that operation becomes a call to the seam in
|
||||
`crate::app::test_seams` with the same arguments (scripts/check_test_seams.py).
|
||||
|
||||
Every non-blank line of the input lands in exactly one output file. The run
|
||||
fails (exit 1) and writes nothing when an item is missing from ASSIGNMENT, an
|
||||
ASSIGNMENT entry names no item, an item would be moved twice, a moved file
|
||||
still names s3s, or a cross-owner seam call cannot be rewritten.
|
||||
|
||||
Usage:
|
||||
scripts/codemods/split_lifecycle_transition_api_test.py [--no-fmt]
|
||||
scripts/codemods/split_lifecycle_transition_api_test.py --self-test
|
||||
|
||||
The input must be the unsplit file. A second run over the split tree finds the
|
||||
module root instead and exits 1 without writing; to regenerate, restore the
|
||||
unsplit file (for example `git show <rev>:rustfs/src/app/lifecycle_transition_api_test.rs`),
|
||||
re-run, and the output is byte-identical to the committed split. Unless
|
||||
--no-fmt is given, `rustfmt --edition 2024` formats the written files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path("rustfs/src/app/lifecycle_transition_api_test.rs")
|
||||
TARGET_DIR = Path("rustfs/src/app/lifecycle_transition_api_test")
|
||||
|
||||
# Module files in the order the root declares them, with a one-line owner note.
|
||||
MODULES = {
|
||||
"common": "Shared fixtures for the lifecycle integration tests; owned by no operation batch.",
|
||||
"copy": "CopyObject tests, including tests that also PUT (batch E owns CopyObject).",
|
||||
"delete": "DeleteObject and DeleteObjects tests (batch E owns both).",
|
||||
"lifecycle_config": "PutBucketLifecycleConfiguration tests (batch A3 owns the lifecycle operations).",
|
||||
"multipart": "CompleteMultipartUpload tests (batch E owns CompleteMultipartUpload).",
|
||||
"object_lock": "PutObjectRetention and PutObjectLegalHold tests (batch B1 owns both).",
|
||||
"put": "PutObject tests (batch D owns PutObject).",
|
||||
"restore": "RestoreObject tests (batch E owns RestoreObject).",
|
||||
"transition": "ILM transition-engine tests that call no S3 use-case; owned by no operation batch.",
|
||||
}
|
||||
|
||||
# Every top-level item that is not a `use` declaration, by name.
|
||||
ASSIGNMENT = {
|
||||
# Fixtures. A fixture used by one owner's tests moves with them; common.rs keeps the ones several owners share.
|
||||
"GLOBAL_ENV": "common",
|
||||
"TRANSITION_WAIT_TIMEOUT": "common",
|
||||
"RESTORE_SUSPENDED_WAIT_TIMEOUT": "restore",
|
||||
"ENV_GET_CODEC_STREAMING_ENABLE": "transition",
|
||||
"ENV_GET_CODEC_STREAMING_ROLLOUT": "transition",
|
||||
"ENV_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED": "transition",
|
||||
"ENV_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED": "transition",
|
||||
"ENV_GET_CODEC_STREAMING_MIN_SIZE": "transition",
|
||||
"RUSTFS_EXPECTED_CURRENT_VERSION_ID": "common",
|
||||
"init_tracing": "common",
|
||||
"setup_test_env": "common",
|
||||
"create_test_bucket": "common",
|
||||
"suspend_test_bucket": "restore",
|
||||
"upload_test_object": "common",
|
||||
"transition_uploaded_object_directly": "restore",
|
||||
"set_bucket_lifecycle_transition_with_tier": "common",
|
||||
"set_bucket_object_lock_enabled": "common",
|
||||
"expiration_lifecycle_configuration": "lifecycle_config",
|
||||
"expired_object_delete_marker_lifecycle_configuration": "lifecycle_config",
|
||||
"del_marker_expiration_lifecycle_configuration": "lifecycle_config",
|
||||
"register_mock_tier": "common",
|
||||
"wait_for_transition": "common",
|
||||
"wait_for_restore_completion": "restore",
|
||||
"with_forced_immediate_enqueue_timeout": "common",
|
||||
"with_get_codec_streaming_remote_probe_env": "transition",
|
||||
"wait_for_object_absence": "delete",
|
||||
"wait_for_delete_marker": "common",
|
||||
"build_request": "common",
|
||||
"streaming_blob_from_bytes": "common",
|
||||
"read_object_bytes": "common",
|
||||
"live_object_version_count": "common",
|
||||
"install_malformed_versioning_config": "delete",
|
||||
"install_malformed_replication_config": "delete",
|
||||
"install_delete_replication_config": "common",
|
||||
# ILM transition engine; no S3 use-case entry point.
|
||||
"transition_wait_timeout_reports_metadata_state_and_read_failure": "transition",
|
||||
"get_transitioned_object_uses_remote_codec_fallback_path": "transition",
|
||||
"duplicate_transition_task_skips_already_transitioned_version": "transition",
|
||||
"transition_rejects_stale_etag_before_remote_upload": "transition",
|
||||
"transition_commit_rejects_replaced_source": "transition",
|
||||
"cancelled_transition_waiting_for_prepared_reader_cleans_remote": "transition",
|
||||
"lifecycle_transition_marks_dirty_disks_for_capacity_manager": "transition",
|
||||
"immediate_transition_timeout_eventually_completes_via_compensation": "transition",
|
||||
# PutBucketLifecycleConfiguration (A3).
|
||||
"compensation_driven_delete_marker_still_honors_lifecycle_cleanup": "lifecycle_config",
|
||||
"put_bucket_lifecycle_configuration_expires_existing_objects": "lifecycle_config",
|
||||
"put_bucket_lifecycle_configuration_allows_expired_delete_marker_on_object_lock_bucket": "lifecycle_config",
|
||||
"put_bucket_lifecycle_configuration_rejects_del_marker_expiration_on_object_lock_bucket": "lifecycle_config",
|
||||
"put_bucket_lifecycle_configuration_rejects_zero_day_del_marker_expiration_on_object_lock_bucket": "lifecycle_config",
|
||||
"put_bucket_lifecycle_configuration_rejects_zero_day_expiration": "lifecycle_config",
|
||||
# PutObjectRetention / PutObjectLegalHold (B1).
|
||||
"object_lock_handlers_schedule_replication": "object_lock",
|
||||
# PutObject (D).
|
||||
"put_object_if_none_match_existing_object_returns_precondition_failed": "put",
|
||||
"put_object_computes_replication_decision_exactly_once": "put",
|
||||
"put_object_extract_computes_replication_decision_per_entry": "put",
|
||||
# CopyObject (E); mixed put+copy tests go here.
|
||||
"copy_object_if_none_match_existing_destination_returns_precondition_failed": "copy",
|
||||
"undo_copy_requires_the_observed_current_version": "copy",
|
||||
"undo_copy_serializes_a_concurrent_put_until_after_commit": "copy",
|
||||
"copy_object_allows_new_version_for_locked_destination_but_blocks_explicit_overwrite": "copy",
|
||||
"concurrent_reverse_copy_object_does_not_deadlock_with_reader_locks": "copy",
|
||||
"put_and_copy_object_transition_immediately_via_usecases": "copy",
|
||||
"compensation_driven_copy_still_completes_transition": "copy",
|
||||
"copy_object_computes_replication_decision_and_strips_stale_status": "copy",
|
||||
# CompleteMultipartUpload (E).
|
||||
"complete_multipart_upload_transitions_immediately_via_usecase": "multipart",
|
||||
"compensation_driven_complete_multipart_upload_still_transitions": "multipart",
|
||||
"compensation_driven_complete_multipart_upload_still_transitions_inner": "multipart",
|
||||
# DeleteObject / DeleteObjects (E).
|
||||
"undo_delete_only_removes_the_current_matching_delete_marker": "delete",
|
||||
"delete_transitioned_object_removes_remote_tier_copy_via_usecase": "delete",
|
||||
"compensation_driven_transition_still_cleans_remote_tier_on_delete": "delete",
|
||||
"compensation_driven_versioned_delete_still_creates_delete_marker": "delete",
|
||||
"delete_objects_resolves_bucket_versioning_once_per_request": "delete",
|
||||
"delete_objects_treats_empty_directory_version_as_absent": "delete",
|
||||
"delete_objects_preserves_explicit_null_directory_version": "delete",
|
||||
"delete_object_versioning_config_failure_leaves_latest_object_intact": "delete",
|
||||
"delete_object_replication_config_failure_leaves_latest_object_intact": "delete",
|
||||
"delete_object_uses_one_replication_config_generation": "delete",
|
||||
"replica_delete_uses_authorized_source_version_and_its_tags": "delete",
|
||||
"force_delete_replication_config_failure_leaves_prefix_intact": "delete",
|
||||
"replica_delete_replication_config_failure_leaves_object_intact": "delete",
|
||||
"delete_objects_replication_config_failure_does_not_call_storage_delete": "delete",
|
||||
"nonexistent_bucket_fails_before_versioning_lookup_on_get_head_delete": "delete",
|
||||
# RestoreObject (E).
|
||||
"restore_object_usecase_reports_ongoing_conflict": "restore",
|
||||
"restore_object_usecase_recovers_cancelled_post_commit_generation": "restore",
|
||||
"restore_object_usecase_recovers_cancelled_post_commit_generation_inner": "restore",
|
||||
"restore_object_usecase_completes_suspended_null_version_in_place": "restore",
|
||||
"restore_object_usecase_completes_suspended_null_version_in_place_inner": "restore",
|
||||
"restore_object_usecase_accepts_exactly_one_of_two_concurrent_restores": "restore",
|
||||
}
|
||||
|
||||
# Seam entry points: method name -> (seam function, module that owns the operation here).
|
||||
# A call in any other module is rewritten to `test_seams::<seam>(&receiver, args...)`.
|
||||
SEAMS = {
|
||||
"execute_get_object": ("get_object", None),
|
||||
"execute_head_object": ("head_object", None),
|
||||
"execute_list_objects_v2": ("list_objects_v2", None),
|
||||
"execute_put_object": ("put_object", "put"),
|
||||
}
|
||||
|
||||
ROOT_EXTRA_USE = "use crate::app::storage_api::s3::S3ErrorCode;\n"
|
||||
S3S_PATH = re.compile(r'(^|[^"A-Za-z0-9_])s3s::')
|
||||
SUPER_PATH = re.compile(r"(?<![A-Za-z0-9_:])super::")
|
||||
ITEM_NAME = re.compile(
|
||||
r"^(?:pub(?:\([^)]*\))?\s+)?(?:(?:async|unsafe|const|extern)\s+)*"
|
||||
r"(?P<kind>fn|static|const|struct|enum|type|trait|mod|impl|macro_rules!|use)\s*(?P<name>[A-Za-z_][A-Za-z0-9_]*)?"
|
||||
)
|
||||
VISIBILITY_TARGET = re.compile(r"^(?P<indent>[ \t]*)(?P<head>(?:async\s+|unsafe\s+)*(?:fn|static|const)\s)", re.M)
|
||||
|
||||
|
||||
class SplitError(Exception):
|
||||
"""The input cannot be split exactly as the tables describe: exit 1."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lexing: positions of code characters, skipping comments and literals.
|
||||
|
||||
|
||||
def code_mask(text: str) -> bytearray:
|
||||
"""1 for every character that is Rust code, 0 inside comments and literals."""
|
||||
mask = bytearray(len(text))
|
||||
i, n = 0, len(text)
|
||||
while i < n:
|
||||
c = text[i]
|
||||
if text.startswith("//", i):
|
||||
j = text.find("\n", i)
|
||||
i = n if j < 0 else j
|
||||
continue
|
||||
if text.startswith("/*", i):
|
||||
depth, j = 1, i + 2
|
||||
while j < n and depth:
|
||||
if text.startswith("/*", j):
|
||||
depth, j = depth + 1, j + 2
|
||||
elif text.startswith("*/", j):
|
||||
depth, j = depth - 1, j + 2
|
||||
else:
|
||||
j += 1
|
||||
i = j
|
||||
continue
|
||||
raw = re.compile(r'b?r(#*)"').match(text, i)
|
||||
if raw and (i == 0 or not (text[i - 1].isalnum() or text[i - 1] == "_")):
|
||||
end = text.find('"' + raw.group(1), raw.end())
|
||||
if end < 0:
|
||||
raise SplitError("unterminated raw string")
|
||||
i = end + 1 + len(raw.group(1))
|
||||
continue
|
||||
if c == '"' or (c == "b" and text.startswith('b"', i) and (i == 0 or not text[i - 1].isalnum())):
|
||||
j = i + (2 if c == "b" else 1)
|
||||
while j < n and text[j] != '"':
|
||||
j += 2 if text[j] == "\\" else 1
|
||||
i = j + 1
|
||||
continue
|
||||
if c == "'":
|
||||
char = re.compile(r"'(?:\\(?:[nrt\\0'\"]|x[0-9a-fA-F]{2}|u\{[0-9a-fA-F_]{1,6}\})|[^\\'\n])'").match(text, i)
|
||||
if char:
|
||||
i = char.end()
|
||||
continue
|
||||
mask[i] = 1
|
||||
i += 1
|
||||
return mask
|
||||
|
||||
|
||||
@dataclass
|
||||
class Item:
|
||||
name: str
|
||||
kind: str
|
||||
text: str # verbatim, from its first comment/attribute line through its last line, with newline
|
||||
|
||||
|
||||
def split_items(text: str) -> tuple[str, list[Item]]:
|
||||
"""Return (license header, top-level items) and prove every non-blank line is covered once."""
|
||||
mask = code_mask(text)
|
||||
ends: list[int] = []
|
||||
depth = 0
|
||||
for i, ch in enumerate(text):
|
||||
if not mask[i]:
|
||||
continue
|
||||
if ch in "([{":
|
||||
depth += 1
|
||||
elif ch in ")]}":
|
||||
depth -= 1
|
||||
if depth < 0:
|
||||
raise SplitError(f"unbalanced closing '{ch}' at offset {i}")
|
||||
if depth == 0 and ch == "}":
|
||||
nxt = next((k for k in range(i + 1, len(text)) if mask[k] and not text[k].isspace()), None)
|
||||
if nxt is None or text[nxt] != ";":
|
||||
ends.append(i)
|
||||
elif ch == ";" and depth == 0:
|
||||
ends.append(i)
|
||||
if depth:
|
||||
raise SplitError("unbalanced braces at end of file")
|
||||
first_code = next(i for i in range(len(text)) if mask[i] and not text[i].isspace())
|
||||
header_end = text.rfind("\n", 0, first_code) + 1
|
||||
header = text[:header_end]
|
||||
# The header is the leading comment block; anything else before the first item belongs to that item.
|
||||
header_lines = header.splitlines(keepends=True)
|
||||
while header_lines and header_lines[-1].strip():
|
||||
header_lines.pop()
|
||||
header = "".join(header_lines)
|
||||
if not header.strip():
|
||||
raise SplitError("the file does not start with a license header")
|
||||
items: list[Item] = []
|
||||
start = len(header)
|
||||
for end in ends:
|
||||
line_end = text.find("\n", end)
|
||||
line_end = len(text) if line_end < 0 else line_end + 1
|
||||
chunk = text[start:line_end]
|
||||
stripped = chunk.lstrip("\n")
|
||||
if not stripped.strip():
|
||||
raise SplitError(f"empty item before offset {end}")
|
||||
items.append(classify(stripped))
|
||||
start = line_end
|
||||
if text[start:].strip():
|
||||
raise SplitError("text after the last item")
|
||||
covered = [line for item in items for line in item.text.splitlines() if line.strip()]
|
||||
covered = [line for line in header.splitlines() if line.strip()] + covered
|
||||
expected = [line for line in text.splitlines() if line.strip()]
|
||||
if covered != expected:
|
||||
raise SplitError("item split does not cover every non-blank input line exactly once")
|
||||
return header, items
|
||||
|
||||
|
||||
def classify(chunk: str) -> Item:
|
||||
mask = code_mask(chunk)
|
||||
depth = 0
|
||||
for line_start, line in _lines_with_offsets(chunk):
|
||||
body = "".join(ch if mask[line_start + k] else " " for k, ch in enumerate(line)).strip()
|
||||
if not body:
|
||||
continue
|
||||
if depth == 0 and not body.startswith("#"):
|
||||
match = ITEM_NAME.match(body)
|
||||
if not match:
|
||||
raise SplitError(f"unrecognised item head: {body[:80]!r}")
|
||||
kind = match.group("kind")
|
||||
return Item(name=match.group("name") or "", kind=kind, text=chunk)
|
||||
depth += body.count("[") - body.count("]")
|
||||
raise SplitError(f"item without a head: {chunk[:80]!r}")
|
||||
|
||||
|
||||
def _lines_with_offsets(text: str):
|
||||
offset = 0
|
||||
for line in text.splitlines(keepends=True):
|
||||
yield offset, line.rstrip("\n")
|
||||
offset += len(line)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rewrites applied to moved items.
|
||||
|
||||
|
||||
def rewrite_paths(text: str) -> str:
|
||||
mask = code_mask(text)
|
||||
out = []
|
||||
last = 0
|
||||
for match in SUPER_PATH.finditer(text):
|
||||
if mask[match.start()]:
|
||||
out.append(text[last : match.start()])
|
||||
out.append("super::super::")
|
||||
last = match.end()
|
||||
out.append(text[last:])
|
||||
text = "".join(out)
|
||||
text = text.replace("use s3s::S3;", "use crate::app::storage_api::s3::S3;")
|
||||
text = re.sub(r"(?<![A-Za-z0-9_:])s3s::S3ErrorCode\b", "S3ErrorCode", text)
|
||||
return text
|
||||
|
||||
|
||||
def make_shared(text: str) -> str:
|
||||
"""Give the item head `pub(super)`; attributes and comments before it are untouched."""
|
||||
mask = code_mask(text)
|
||||
for match in VISIBILITY_TARGET.finditer(text):
|
||||
if mask[match.start("head")] and _depth_at(text, mask, match.start("head")) == 0:
|
||||
pos = match.start("head")
|
||||
return text[:pos] + "pub(super) " + text[pos:]
|
||||
raise SplitError(f"cannot find the head of shared item {text[:80]!r}")
|
||||
|
||||
|
||||
def _depth_at(text: str, mask: bytearray, pos: int) -> int:
|
||||
depth = 0
|
||||
for i in range(pos):
|
||||
if mask[i]:
|
||||
depth += text[i] in "([{"
|
||||
depth -= text[i] in ")]}"
|
||||
return depth
|
||||
|
||||
|
||||
def rewrite_seam_calls(text: str, module: str) -> tuple[str, int]:
|
||||
"""Rewrite `<receiver>.execute_<op>(args)` to `test_seams::<seam>(&<receiver>, args)` outside the owner."""
|
||||
count = 0
|
||||
for method, (seam, owner) in SEAMS.items():
|
||||
if owner == module:
|
||||
continue
|
||||
while True:
|
||||
mask = code_mask(text)
|
||||
match = next((m for m in re.finditer(r"\.\s*" + method + r"\(", text) if mask[m.start()]), None)
|
||||
if match is None:
|
||||
break
|
||||
recv_start = _receiver_start(text, match.start())
|
||||
receiver = text[recv_start : match.start()].rstrip()
|
||||
if not receiver:
|
||||
raise SplitError(f"{module}: call to {method} without a receiver")
|
||||
args_start = match.end()
|
||||
args_end = _matching(text, mask, match.end() - 1)
|
||||
args = text[args_start:args_end]
|
||||
text = text[:recv_start] + f"test_seams::{seam}(&{receiver}, {args})" + text[args_end + 1 :]
|
||||
count += 1
|
||||
return text, count
|
||||
|
||||
|
||||
def _receiver_start(text: str, dot: int) -> int:
|
||||
i = dot
|
||||
while i > 0 and text[i - 1].isspace():
|
||||
i -= 1
|
||||
end = i
|
||||
if end and text[end - 1] == ")":
|
||||
depth = 0
|
||||
while i > 0:
|
||||
i -= 1
|
||||
depth += text[i] == ")"
|
||||
depth -= text[i] == "("
|
||||
if depth == 0:
|
||||
break
|
||||
while i > 0 and (text[i - 1].isalnum() or text[i - 1] in "_:"):
|
||||
i -= 1
|
||||
if i == end or (i > 0 and text[i - 1] == "."):
|
||||
raise SplitError(f"unsupported receiver before offset {dot}: only a name or a path call is rewritten")
|
||||
return i
|
||||
|
||||
|
||||
def _matching(text: str, mask: bytearray, open_pos: int) -> int:
|
||||
depth = 0
|
||||
for i in range(open_pos, len(text)):
|
||||
if not mask[i]:
|
||||
continue
|
||||
depth += text[i] in "([{"
|
||||
depth -= text[i] in ")]}"
|
||||
if depth == 0:
|
||||
return i
|
||||
raise SplitError(f"unbalanced call at offset {open_pos}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assembly.
|
||||
|
||||
|
||||
def build(text: str) -> dict[Path, str]:
|
||||
"""Return the full content of every output file, keyed by repository-relative path."""
|
||||
header, items = split_items(text)
|
||||
license_block = header.rstrip("\n") + "\n"
|
||||
uses = [item for item in items if item.kind == "use"]
|
||||
moved = [item for item in items if item.kind != "use"]
|
||||
if items[: len(uses)] != uses:
|
||||
raise SplitError("a `use` declaration follows another item; the root keeps only the leading use block")
|
||||
names = [item.name for item in moved]
|
||||
duplicates = sorted({name for name in names if names.count(name) > 1})
|
||||
if duplicates:
|
||||
raise SplitError(f"items defined twice: {', '.join(duplicates)}")
|
||||
unassigned = [name for name in names if name not in ASSIGNMENT]
|
||||
stale = sorted(set(ASSIGNMENT) - set(names))
|
||||
if unassigned or stale:
|
||||
raise SplitError(
|
||||
"ASSIGNMENT does not match the input:"
|
||||
+ "".join(f"\n unassigned item: {name}" for name in unassigned)
|
||||
+ "".join(f"\n no such item: {name}" for name in stale)
|
||||
)
|
||||
unknown = sorted({module for module in ASSIGNMENT.values() if module not in MODULES})
|
||||
if unknown:
|
||||
raise SplitError(f"ASSIGNMENT names undeclared modules: {', '.join(unknown)}")
|
||||
|
||||
bodies: dict[str, list[str]] = {module: [] for module in MODULES}
|
||||
for item in moved:
|
||||
module = ASSIGNMENT[item.name]
|
||||
body, _ = rewrite_seam_calls(rewrite_paths(item.text), module)
|
||||
bodies[module].append(body)
|
||||
|
||||
# A common item gains `pub(super)` only when a sibling uses it; the rest stay private to common.rs.
|
||||
sibling_code = _code_only("\n".join(body for module, items in bodies.items() if module != "common" for body in items))
|
||||
common_items = [item for item in moved if ASSIGNMENT[item.name] == "common"]
|
||||
common_names = {item.name for item in common_items if re.search(rf"\b{re.escape(item.name)}\b", sibling_code)}
|
||||
bodies["common"] = [
|
||||
make_shared(body) if item.name in common_names else body for item, body in zip(common_items, bodies["common"])
|
||||
]
|
||||
outputs: dict[Path, str] = {}
|
||||
root_uses = "".join(item.text if item.text.endswith("\n") else item.text + "\n" for item in uses)
|
||||
root = (
|
||||
license_block
|
||||
+ "\n"
|
||||
+ "//! Lifecycle, transition and object-version integration tests, split by the batch that owns each operation.\n"
|
||||
+ "//!\n"
|
||||
+ "//! Generated by scripts/codemods/split_lifecycle_transition_api_test.py; each module file names its owner.\n"
|
||||
+ "\n"
|
||||
+ root_uses
|
||||
+ ROOT_EXTRA_USE
|
||||
+ "\n"
|
||||
+ "".join(f"mod {module};\n" for module in MODULES)
|
||||
)
|
||||
outputs[SOURCE] = root
|
||||
for module, doc in MODULES.items():
|
||||
content = bodies[module]
|
||||
if not content:
|
||||
raise SplitError(f"module {module} receives no item")
|
||||
joined = "\n".join(content)
|
||||
preamble = ["use super::*;\n"]
|
||||
if module != "common" and any(re.search(rf"\b{re.escape(name)}\b", joined) for name in common_names):
|
||||
preamble.append("use super::common::*;\n")
|
||||
if "test_seams::" in joined:
|
||||
preamble.append("use crate::app::test_seams;\n")
|
||||
text_out = license_block + "\n" + f"//! {doc}\n" + "\n" + "".join(preamble) + "\n" + joined
|
||||
leftover = [line for line in text_out.splitlines() if S3S_PATH.search(line)]
|
||||
if leftover:
|
||||
raise SplitError(f"{module}.rs still names s3s: {leftover[0].strip()}")
|
||||
for method, (_, owner) in SEAMS.items():
|
||||
if owner != module and re.search(r"\." + method + r"\(", _code_only(text_out)):
|
||||
raise SplitError(f"{module}.rs still calls {method} directly")
|
||||
outputs[TARGET_DIR / f"{module}.rs"] = text_out
|
||||
return outputs
|
||||
|
||||
|
||||
def _code_only(text: str) -> str:
|
||||
mask = code_mask(text)
|
||||
return "".join(ch if mask[i] or ch == "\n" else " " for i, ch in enumerate(text))
|
||||
|
||||
|
||||
def run(root: Path, fmt: bool) -> int:
|
||||
source = root / SOURCE
|
||||
try:
|
||||
text = source.read_text()
|
||||
except OSError as error:
|
||||
print(f"error: cannot read {SOURCE}: {error}", file=sys.stderr)
|
||||
return 1
|
||||
if re.search(r"(?m)^mod common;$", text):
|
||||
print(f"error: {SOURCE} is already split; restore the unsplit file first", file=sys.stderr)
|
||||
return 1
|
||||
try:
|
||||
outputs = build(text)
|
||||
except SplitError as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
stray = sorted(p.name for p in (root / TARGET_DIR).glob("*.rs") if TARGET_DIR / p.name not in outputs) if (
|
||||
root / TARGET_DIR
|
||||
).is_dir() else []
|
||||
if stray:
|
||||
print(f"error: {TARGET_DIR} holds files this codemod does not write: {', '.join(stray)}", file=sys.stderr)
|
||||
return 1
|
||||
(root / TARGET_DIR).mkdir(parents=True, exist_ok=True)
|
||||
for path, content in outputs.items():
|
||||
(root / path).write_text(content)
|
||||
if fmt:
|
||||
paths = [str(root / path) for path in outputs]
|
||||
result = subprocess.run(["rustfmt", "--edition", "2024", *paths], cwd=root, check=False)
|
||||
if result.returncode != 0:
|
||||
print("error: rustfmt failed on the written files", file=sys.stderr)
|
||||
return 1
|
||||
for path in outputs:
|
||||
print(f"wrote {path} ({len((root / path).read_text().splitlines())} lines)")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if argv == ["--self-test"]:
|
||||
suite = unittest.defaultTestLoader.loadTestsFromTestCase(SelfTest)
|
||||
return 0 if unittest.TextTestRunner(verbosity=1).run(suite).wasSuccessful() else 1
|
||||
if argv not in ([], ["--no-fmt"]):
|
||||
print(__doc__.split("Usage:", 1)[1].split("\n\n", 1)[0], file=sys.stderr)
|
||||
return 2
|
||||
top = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False)
|
||||
if top.returncode != 0:
|
||||
print("error: not inside a git checkout", file=sys.stderr)
|
||||
return 2
|
||||
return run(Path(top.stdout.strip()), fmt=argv != ["--no-fmt"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Self-test
|
||||
|
||||
|
||||
class SelfTest(unittest.TestCase):
|
||||
HEADER = "// Copyright\n// License\n\n"
|
||||
|
||||
def items(self, body: str) -> list[Item]:
|
||||
return split_items(self.HEADER + body)[1]
|
||||
|
||||
def test_items_keep_attributes_comments_and_braces_in_literals(self):
|
||||
body = 'use a::{b, c};\n\n// note\n#[test]\nfn one() {\n let s = "}";\n let c = \'}\';\n}\n\nstatic X: u8 = 1;\n'
|
||||
items = self.items(body)
|
||||
self.assertEqual([(i.kind, i.name) for i in items], [("use", "a"), ("fn", "one"), ("static", "X")])
|
||||
self.assertTrue(items[1].text.startswith("// note\n#[test]\n"))
|
||||
|
||||
def test_raw_strings_and_lifetimes_do_not_confuse_depth(self):
|
||||
items = self.items("fn a<'x>(v: &'x str) {\n let r = r#\"{\"#;\n}\nfn b() {}\n")
|
||||
self.assertEqual([i.name for i in items], ["a", "b"])
|
||||
|
||||
def test_unbalanced_input_is_rejected(self):
|
||||
with self.assertRaises(SplitError):
|
||||
split_items(self.HEADER + "fn a() {\n")
|
||||
|
||||
def test_unassigned_item_is_rejected(self):
|
||||
with self.assertRaisesRegex(SplitError, "unassigned item: not_in_table"):
|
||||
build(self.HEADER + "use x::y;\nfn not_in_table() {}\n")
|
||||
|
||||
def test_super_paths_move_one_level_down_but_not_in_strings(self):
|
||||
source = 'fn a() {\n use super::x::Y;\n let s = "super::z";\n}\n'
|
||||
self.assertEqual(rewrite_paths(source), 'fn a() {\n use super::super::x::Y;\n let s = "super::z";\n}\n')
|
||||
|
||||
def test_s3s_error_code_and_trait_paths_are_rewritten(self):
|
||||
source = "fn a() {\n use s3s::S3;\n f(&s3s::S3ErrorCode::NoSuchKey);\n}\n"
|
||||
self.assertEqual(
|
||||
rewrite_paths(source),
|
||||
"fn a() {\n use crate::app::storage_api::s3::S3;\n f(&S3ErrorCode::NoSuchKey);\n}\n",
|
||||
)
|
||||
|
||||
def test_shared_items_gain_pub_super_after_attributes(self):
|
||||
self.assertEqual(make_shared("#[allow(x)]\nasync fn a() {}\n"), "#[allow(x)]\npub(super) async fn a() {}\n")
|
||||
self.assertEqual(make_shared("static A: u8 = 1;\n"), "pub(super) static A: u8 = 1;\n")
|
||||
|
||||
def test_cross_owner_calls_use_the_seam(self):
|
||||
source = "fn t() {\n Box::pin(usecase\n .execute_put_object(&fs, req(x)))\n}\n"
|
||||
rewritten, count = rewrite_seam_calls(source, "copy")
|
||||
self.assertEqual(count, 1)
|
||||
self.assertEqual(rewritten, "fn t() {\n Box::pin(test_seams::put_object(&usecase, &fs, req(x)))\n}\n")
|
||||
|
||||
def test_owner_calls_stay_direct(self):
|
||||
source = "fn t() {\n usecase.execute_put_object(&fs, req)\n}\n"
|
||||
self.assertEqual(rewrite_seam_calls(source, "put"), (source, 0))
|
||||
|
||||
def test_call_expression_receivers_are_kept_whole(self):
|
||||
source = "fn t() {\n Usecase::with(Some(a)).execute_get_object(r)\n}\n"
|
||||
rewritten, _ = rewrite_seam_calls(source, "delete")
|
||||
self.assertEqual(rewritten, "fn t() {\n test_seams::get_object(&Usecase::with(Some(a)), r)\n}\n")
|
||||
|
||||
def test_already_split_input_is_refused(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / SOURCE
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(self.HEADER + "mod common;\n")
|
||||
self.assertEqual(run(Path(tmp), fmt=False), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Source-set helper for text guards that must survive a mechanical file split.
|
||||
# Sourced by check_architecture_migration_rules.sh and check_logging_guardrails.sh;
|
||||
# contract tests: scripts/test_guard_source_set.sh.
|
||||
#
|
||||
# A guard that greps one named file stops seeing the code it guards once that
|
||||
# file is split into a sibling directory. guard_source_set resolves the set of
|
||||
# files such a guard is about instead:
|
||||
#
|
||||
# guard_source_set <root> <required-file> [<split-dir>...]
|
||||
#
|
||||
# On success it sets the global array GUARD_SOURCES to <required-file> followed
|
||||
# by every `.rs` file under each <split-dir> that exists, in byte order of path
|
||||
# (LC_ALL=C), all relative to <root>. An absent <split-dir> contributes nothing.
|
||||
# It returns 1 with a message on stderr when <required-file> is not a regular
|
||||
# file (a guard whose input is missing must fail, not pass) or when a
|
||||
# <split-dir> exists but is not a directory. On failure GUARD_SOURCES still
|
||||
# holds <required-file>, so a caller that records the failure and continues
|
||||
# hands the missing path to its search tool rather than nothing.
|
||||
|
||||
guard_source_set() {
|
||||
local root="$1" required="$2" split_dir path listing
|
||||
shift 2
|
||||
GUARD_SOURCES=("$required")
|
||||
|
||||
if [[ ! -f "${root}/${required}" ]]; then
|
||||
printf 'guard source %s is missing\n' "$required" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
for split_dir in "$@"; do
|
||||
if [[ ! -e "${root}/${split_dir}" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ ! -d "${root}/${split_dir}" ]]; then
|
||||
printf 'guard split path %s is not a directory\n' "$split_dir" >&2
|
||||
return 1
|
||||
fi
|
||||
if ! listing="$(set -o pipefail; cd "$root" && find "$split_dir" -type f -name '*.rs' | LC_ALL=C sort)"; then
|
||||
printf 'guard split directory %s cannot be listed\n' "$split_dir" >&2
|
||||
return 1
|
||||
fi
|
||||
while IFS= read -r path; do
|
||||
if [[ -n "$path" ]]; then
|
||||
GUARD_SOURCES+=("$path")
|
||||
fi
|
||||
done <<<"$listing"
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# A rule that forbids reaching an owner module through `super::` is only right
|
||||
# at the depth it was written for: in rustfs/src/app/bucket_usecase.rs `super`
|
||||
# is `crate::app`, but in rustfs/src/app/bucket_usecase/cors.rs `super` is the
|
||||
# bucket usecase itself and `crate::app` is `super::super`. module_relative_rg
|
||||
# rewrites a pattern for each file's depth below the owner module directory:
|
||||
#
|
||||
# module_relative_rg <module-dir> <template> [<rg-option>...] -- <file>...
|
||||
#
|
||||
# Every `@UP@` in <template> becomes the `super::` chain that names the owner
|
||||
# module from that file's own scope: one `super::` for <module-dir>/x.rs and
|
||||
# <module-dir>/x/mod.rs, two for <module-dir>/x/y.rs, and so on. A file at
|
||||
# depth 0 (<module-dir>/mod.rs) keeps one `super::`, the form the rule had
|
||||
# before it was depth-aware. Files are searched in groups of equal depth, so
|
||||
# rg's output stays `path:line:text`; rg's own errors (exit status 2) fail the
|
||||
# call, and "no match" (status 1) does not.
|
||||
module_relative_depth() {
|
||||
local module_dir="$1" file="$2" relative slashes depth
|
||||
relative="${file#"$module_dir"/}"
|
||||
slashes="${relative//[!\/]/}"
|
||||
depth=$((${#slashes} + 1))
|
||||
if [[ "${relative##*/}" == mod.rs ]]; then
|
||||
depth=$((depth - 1))
|
||||
fi
|
||||
printf '%s\n' "$depth"
|
||||
}
|
||||
|
||||
module_relative_rg() {
|
||||
local module_dir="$1" template="$2" file depth max_depth=0 up pattern status index count
|
||||
shift 2
|
||||
local -a options=() files=() depths=() group=()
|
||||
while [[ $# -gt 0 && "$1" != "--" ]]; do
|
||||
options+=("$1")
|
||||
shift
|
||||
done
|
||||
if [[ $# -eq 0 ]]; then
|
||||
printf 'module_relative_rg: missing -- before the file list\n' >&2
|
||||
return 2
|
||||
fi
|
||||
shift
|
||||
for file in "$@"; do
|
||||
if [[ "$file" != "$module_dir"/* ]]; then
|
||||
printf 'module_relative_rg: %s is not under %s\n' "$file" "$module_dir" >&2
|
||||
return 2
|
||||
fi
|
||||
depth="$(module_relative_depth "$module_dir" "$file")"
|
||||
files+=("$file")
|
||||
depths+=("$depth")
|
||||
if ((depth > max_depth)); then
|
||||
max_depth=$depth
|
||||
fi
|
||||
done
|
||||
count=${#files[@]}
|
||||
for ((depth = 0; depth <= max_depth; depth++)); do
|
||||
group=()
|
||||
for ((index = 0; index < count; index++)); do
|
||||
if [[ "${depths[index]}" == "$depth" ]]; then
|
||||
group+=("${files[index]}")
|
||||
fi
|
||||
done
|
||||
if [[ ${#group[@]} -eq 0 ]]; then
|
||||
continue
|
||||
fi
|
||||
up="super::"
|
||||
for ((index = 1; index < depth; index++)); do
|
||||
up+="super::"
|
||||
done
|
||||
pattern="${template//@UP@/$up}"
|
||||
status=0
|
||||
rg ${options[@]+"${options[@]}"} "$pattern" "${group[@]}" || status=$?
|
||||
if [[ $status -gt 1 ]]; then
|
||||
return "$status"
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
@@ -509,7 +509,9 @@ pass/fail table in the job summary.
|
||||
incomplete parameterized cases.
|
||||
- `api_coverage.py` — quantifies S3 API surface coverage by comparing the
|
||||
s3s `S3` trait (at the revision pinned in Cargo.toml) against the methods
|
||||
RustFS overrides in `impl S3 for FS`:
|
||||
RustFS overrides in `impl S3 for FS`, found exactly once across
|
||||
`rustfs/src/storage/ecfs.rs` and `rustfs/src/storage/legacy_edge/`
|
||||
(contract tests: `test_api_coverage.py`, run by `make script-tests`):
|
||||
|
||||
```bash
|
||||
python3 scripts/s3-tests/api_coverage.py # summary + missing ops
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
|
||||
Compares the operations defined by the s3s `S3` trait (at the revision pinned
|
||||
in the workspace Cargo.toml) against the methods RustFS overrides in
|
||||
`impl S3 for FS` (rustfs/src/storage/ecfs.rs). Trait methods that are not
|
||||
`impl S3 for FS` (rustfs/src/storage/ecfs.rs, or the rustfs/src/storage/legacy_edge/
|
||||
directory a mechanical split of that file moves code into). Trait methods that are not
|
||||
overridden fall back to the s3s default implementation, which returns
|
||||
NotImplemented — so "overridden" is a faithful proxy for "implemented".
|
||||
|
||||
@@ -36,6 +37,8 @@ import sys
|
||||
import tempfile
|
||||
|
||||
ECFS_PATH = "rustfs/src/storage/ecfs.rs"
|
||||
ECFS_SPLIT_DIR = "rustfs/src/storage/legacy_edge"
|
||||
IMPL_MARKER = "impl S3 for FS {"
|
||||
TRAIT_RELPATH = "crates/s3s/src/s3_trait.rs"
|
||||
|
||||
|
||||
@@ -97,14 +100,28 @@ def trait_operations(trait_source: pathlib.Path) -> dict[str, str]:
|
||||
return ops
|
||||
|
||||
|
||||
def implemented_operations(ecfs_source: pathlib.Path) -> set[str]:
|
||||
src = ecfs_source.read_text(encoding="utf-8")
|
||||
marker = "impl S3 for FS {"
|
||||
start = src.find(marker)
|
||||
if start < 0:
|
||||
raise RuntimeError(f"`{marker}` not found in {ecfs_source}")
|
||||
block = extract_block(src, start)
|
||||
return set(re.findall(r"async fn (\w+)", block))
|
||||
def ecfs_sources(root: pathlib.Path) -> list[pathlib.Path]:
|
||||
"""Return ecfs.rs (required) followed by every .rs file under the split directory, in path order."""
|
||||
required = root / ECFS_PATH
|
||||
if not required.is_file():
|
||||
raise RuntimeError(f"{required} is missing")
|
||||
split_dir = root / ECFS_SPLIT_DIR
|
||||
if not split_dir.exists():
|
||||
return [required]
|
||||
if not split_dir.is_dir():
|
||||
raise RuntimeError(f"{split_dir} is not a directory")
|
||||
return [required, *sorted(split_dir.rglob("*.rs"), key=lambda path: path.relative_to(root).as_posix())]
|
||||
|
||||
|
||||
def implemented_operations(root: pathlib.Path) -> set[str]:
|
||||
"""Return the methods of the one `impl S3 for FS` block across the ECFS source set."""
|
||||
blocks = []
|
||||
for source in ecfs_sources(root):
|
||||
src = source.read_text(encoding="utf-8")
|
||||
blocks += [extract_block(src, match.start()) for match in re.finditer(re.escape(IMPL_MARKER), src)]
|
||||
if len(blocks) != 1:
|
||||
raise RuntimeError(f"`{IMPL_MARKER}` must occur exactly once under {ECFS_PATH} and {ECFS_SPLIT_DIR}, found {len(blocks)}")
|
||||
return set(re.findall(r"async fn (\w+)", blocks[0]))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -122,7 +139,7 @@ def main() -> int:
|
||||
trait_source = args.s3s_trait or locate_trait_source(git_url, rev)
|
||||
|
||||
all_ops = trait_operations(trait_source)
|
||||
impl_ops = implemented_operations(root / ECFS_PATH)
|
||||
impl_ops = implemented_operations(root)
|
||||
|
||||
stale = sorted(impl_ops - all_ops.keys())
|
||||
implemented = sorted(op for op in all_ops if op in impl_ops)
|
||||
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/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.
|
||||
|
||||
"""Contract tests for how api_coverage.py finds `impl S3 for FS` across the ECFS source set."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import api_coverage # noqa: E402
|
||||
|
||||
IMPL = "impl S3 for FS {\n async fn get_object(&self) {}\n async fn put_object(&self) {}\n}\n"
|
||||
|
||||
|
||||
class ImplementedOperationsTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self._tmp.name)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def write(self, relative: str, text: str) -> None:
|
||||
path = self.root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
def test_reads_the_impl_from_ecfs_when_the_split_directory_is_absent(self) -> None:
|
||||
self.write(api_coverage.ECFS_PATH, "struct FS;\n" + IMPL)
|
||||
|
||||
self.assertEqual(api_coverage.implemented_operations(self.root), {"get_object", "put_object"})
|
||||
|
||||
def test_finds_the_impl_after_it_moves_into_the_split_directory(self) -> None:
|
||||
self.write(api_coverage.ECFS_PATH, "struct FS;\n")
|
||||
self.write(f"{api_coverage.ECFS_SPLIT_DIR}/s3_impl.rs", IMPL)
|
||||
self.write(f"{api_coverage.ECFS_SPLIT_DIR}/bucket_core.rs", "impl FS {\n async fn helper(&self) {}\n}\n")
|
||||
|
||||
self.assertEqual(api_coverage.implemented_operations(self.root), {"get_object", "put_object"})
|
||||
|
||||
def test_missing_ecfs_fails_even_when_the_split_directory_holds_the_impl(self) -> None:
|
||||
self.write(f"{api_coverage.ECFS_SPLIT_DIR}/s3_impl.rs", IMPL)
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "ecfs.rs is missing"):
|
||||
api_coverage.implemented_operations(self.root)
|
||||
|
||||
def test_an_impl_in_both_ecfs_and_the_split_directory_fails(self) -> None:
|
||||
self.write(api_coverage.ECFS_PATH, IMPL)
|
||||
self.write(f"{api_coverage.ECFS_SPLIT_DIR}/s3_impl.rs", IMPL)
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "exactly once.*found 2"):
|
||||
api_coverage.implemented_operations(self.root)
|
||||
|
||||
def test_no_impl_anywhere_fails(self) -> None:
|
||||
self.write(api_coverage.ECFS_PATH, "struct FS;\n")
|
||||
self.write(f"{api_coverage.ECFS_SPLIT_DIR}/bucket_core.rs", "impl FS {}\n")
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "found 0"):
|
||||
api_coverage.implemented_operations(self.root)
|
||||
|
||||
def test_an_impl_in_a_non_rust_file_of_the_split_directory_is_not_source(self) -> None:
|
||||
self.write(api_coverage.ECFS_PATH, "struct FS;\n")
|
||||
self.write(f"{api_coverage.ECFS_SPLIT_DIR}/notes.md", IMPL)
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "found 0"):
|
||||
api_coverage.implemented_operations(self.root)
|
||||
|
||||
def test_a_split_path_that_is_a_file_fails(self) -> None:
|
||||
self.write(api_coverage.ECFS_PATH, IMPL)
|
||||
self.write(api_coverage.ECFS_SPLIT_DIR, "stray\n")
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "not a directory"):
|
||||
api_coverage.implemented_operations(self.root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+237
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
# Contract tests for scripts/guard_source_set.sh, the source-set helper that
|
||||
# lets text guards read a file plus the directory a mechanical split moves its
|
||||
# code into (rustfs/backlog#2765).
|
||||
#
|
||||
# Each case builds a throwaway tree; nothing in the real workspace is read.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=scripts/guard_source_set.sh
|
||||
source "${GUARD_SOURCE_SET_SCRIPT:-$SCRIPT_DIR/guard_source_set.sh}"
|
||||
TMP_ROOT="$(mktemp -d)"
|
||||
trap 'chmod -R u+rwx "$TMP_ROOT" 2>/dev/null || true; rm -rf "$TMP_ROOT"' EXIT
|
||||
|
||||
passed=0
|
||||
skipped=0
|
||||
case_no=0
|
||||
FX=""
|
||||
STATUS=0
|
||||
ERR=""
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
new_case() {
|
||||
case_no=$((case_no + 1))
|
||||
FX="$TMP_ROOT/case$case_no"
|
||||
mkdir -p "$FX"
|
||||
}
|
||||
|
||||
put() {
|
||||
mkdir -p "$(dirname "$FX/$1")"
|
||||
printf 'fn guarded() {}\n' >"$FX/$1"
|
||||
}
|
||||
|
||||
# run_set <args...>: call guard_source_set on the case tree, capturing status and stderr.
|
||||
run_set() {
|
||||
local err_file="$FX/.stderr"
|
||||
STATUS=0
|
||||
guard_source_set "$FX" "$@" 2>"$err_file" || STATUS=$?
|
||||
ERR="$(cat "$err_file")"
|
||||
}
|
||||
|
||||
sources_are() {
|
||||
local expected actual
|
||||
expected="$(printf '%s\n' "$@")"
|
||||
actual="$(printf '%s\n' "${GUARD_SOURCES[@]}")"
|
||||
[[ "$actual" == "$expected" ]] || fail "case $case_no: sources were [$actual], expected [$expected]"
|
||||
}
|
||||
|
||||
ok() {
|
||||
passed=$((passed + 1))
|
||||
}
|
||||
|
||||
# --- positive cases ---------------------------------------------------------
|
||||
|
||||
# An absent split directory contributes nothing.
|
||||
new_case
|
||||
put src/usecase.rs
|
||||
run_set src/usecase.rs src/usecase
|
||||
[[ "$STATUS" == 0 ]] || fail "absent split dir: status $STATUS ($ERR)"
|
||||
sources_are src/usecase.rs
|
||||
ok
|
||||
|
||||
# A present split directory adds its .rs files, recursively, in byte order,
|
||||
# after the required file; non-.rs files are not source.
|
||||
new_case
|
||||
put src/usecase.rs
|
||||
put src/usecase/zeta.rs
|
||||
put src/usecase/B.rs
|
||||
put src/usecase/a.rs
|
||||
put src/usecase/tests/nested.rs
|
||||
printf 'not rust\n' >"$FX/src/usecase/notes.md"
|
||||
run_set src/usecase.rs src/usecase
|
||||
[[ "$STATUS" == 0 ]] || fail "present split dir: status $STATUS ($ERR)"
|
||||
sources_are src/usecase.rs src/usecase/B.rs src/usecase/a.rs src/usecase/tests/nested.rs src/usecase/zeta.rs
|
||||
ok
|
||||
|
||||
# Several split directories are read in argument order; an empty one adds nothing.
|
||||
new_case
|
||||
put src/ecfs.rs
|
||||
put src/legacy_edge/b.rs
|
||||
mkdir -p "$FX/src/empty"
|
||||
put src/other/a.rs
|
||||
run_set src/ecfs.rs src/legacy_edge src/empty src/missing src/other
|
||||
[[ "$STATUS" == 0 ]] || fail "several split dirs: status $STATUS ($ERR)"
|
||||
sources_are src/ecfs.rs src/legacy_edge/b.rs src/other/a.rs
|
||||
ok
|
||||
|
||||
# --- negative cases ---------------------------------------------------------
|
||||
|
||||
# A missing required file fails and names the file.
|
||||
new_case
|
||||
run_set src/usecase.rs src/usecase
|
||||
[[ "$STATUS" == 1 ]] || fail "missing required file: status $STATUS"
|
||||
[[ "$ERR" == *"src/usecase.rs"* ]] || fail "missing required file: stderr does not name it: $ERR"
|
||||
sources_are src/usecase.rs
|
||||
ok
|
||||
|
||||
# A missing required file fails even when the split directory holds the code:
|
||||
# the split must keep the file, and a guard must not pass on half its input.
|
||||
new_case
|
||||
put src/usecase/moved.rs
|
||||
run_set src/usecase.rs src/usecase
|
||||
[[ "$STATUS" == 1 ]] || fail "missing required file with split dir: status $STATUS"
|
||||
[[ "$ERR" == *"is missing"* ]] || fail "missing required file with split dir: $ERR"
|
||||
ok
|
||||
|
||||
# A required path that is a directory is not a source file.
|
||||
new_case
|
||||
mkdir -p "$FX/src/usecase.rs"
|
||||
run_set src/usecase.rs src/usecase
|
||||
[[ "$STATUS" == 1 ]] || fail "required directory: status $STATUS"
|
||||
ok
|
||||
|
||||
# A split path that exists as a regular file fails rather than being skipped.
|
||||
new_case
|
||||
put src/usecase.rs
|
||||
printf 'stray\n' >"$FX/src/usecase"
|
||||
run_set src/usecase.rs src/usecase
|
||||
[[ "$STATUS" == 1 ]] || fail "split path is a file: status $STATUS"
|
||||
[[ "$ERR" == *"not a directory"* ]] || fail "split path is a file: $ERR"
|
||||
ok
|
||||
|
||||
# A later split path that is a file still fails after an earlier valid one.
|
||||
new_case
|
||||
put src/usecase.rs
|
||||
put src/usecase/a.rs
|
||||
printf 'stray\n' >"$FX/src/second"
|
||||
run_set src/usecase.rs src/usecase src/second
|
||||
[[ "$STATUS" == 1 ]] || fail "second split path is a file: status $STATUS"
|
||||
ok
|
||||
|
||||
# An unlistable split directory fails rather than contributing nothing.
|
||||
new_case
|
||||
put src/usecase.rs
|
||||
put src/usecase/hidden/a.rs
|
||||
chmod 000 "$FX/src/usecase/hidden"
|
||||
if [[ "$(id -u)" == 0 ]]; then
|
||||
echo "SKIP: unlistable split directory case needs a non-root user (root ignores directory permissions)"
|
||||
skipped=$((skipped + 1))
|
||||
else
|
||||
run_set src/usecase.rs src/usecase
|
||||
[[ "$STATUS" == 1 ]] || fail "unlistable split dir: status $STATUS"
|
||||
[[ "$ERR" == *"cannot be listed"* ]] || fail "unlistable split dir: $ERR"
|
||||
ok
|
||||
fi
|
||||
chmod 755 "$FX/src/usecase/hidden"
|
||||
|
||||
# --- module_relative_rg ------------------------------------------------------
|
||||
#
|
||||
# The templates are read from check_architecture_migration_rules.sh itself, so
|
||||
# these cases exercise the rules as shipped, not copies of them.
|
||||
ARCH_RULES="${ARCH_RULES_SCRIPT:-$SCRIPT_DIR/check_architecture_migration_rules.sh}"
|
||||
TEMPLATES=()
|
||||
while IFS= read -r template; do
|
||||
TEMPLATES+=("$template")
|
||||
done < <(awk '
|
||||
pending { sub(/^[[:space:]]*'"'"'/, ""); sub(/'"'"'[[:space:]]*\\$/, ""); print; pending = 0; next }
|
||||
/module_relative_rg rustfs\/src\/(app|storage) \\$/ { pending = 1 }
|
||||
' "$ARCH_RULES")
|
||||
[[ ${#TEMPLATES[@]} == 6 ]] || fail "expected 6 depth-aware rules in $ARCH_RULES, found ${#TEMPLATES[@]}"
|
||||
STORAGE_HELPER_RULE="${TEMPLATES[0]}"
|
||||
STORAGE_OWNER_RULE="${TEMPLATES[1]}"
|
||||
RUNTIME_IMPORT_RULE="${TEMPLATES[2]}"
|
||||
RUNTIME_PATH_RULE="${TEMPLATES[3]}"
|
||||
ECFS_TEST_RULE="${TEMPLATES[4]}"
|
||||
ECFS_TEST_SPLIT_RULE="${TEMPLATES[5]}"
|
||||
|
||||
# relative_hits <module-dir> <template> <relative-file> <line>: rg output for one file.
|
||||
relative_hits() {
|
||||
local module_dir="$1" template="$2" file="$3" line="$4"
|
||||
mkdir -p "$(dirname "$FX/$file")"
|
||||
printf '%s\n' "$line" >"$FX/$file"
|
||||
(cd "$FX" && module_relative_rg "$module_dir" "$template" -n -U --with-filename -- "$file")
|
||||
}
|
||||
|
||||
flagged() {
|
||||
[[ -n "$(relative_hits "$@")" ]] || fail "case $case_no: not flagged: $3: $4"
|
||||
ok
|
||||
}
|
||||
|
||||
not_flagged() {
|
||||
[[ -z "$(relative_hits "$@")" ]] || fail "case $case_no: flagged: $3: $4"
|
||||
ok
|
||||
}
|
||||
|
||||
new_case
|
||||
[[ "$(module_relative_depth rustfs/src/app rustfs/src/app/bucket_usecase.rs)" == 1 ]] || fail "depth of a module file"
|
||||
[[ "$(module_relative_depth rustfs/src/app rustfs/src/app/object/mod.rs)" == 1 ]] || fail "depth of a mod.rs"
|
||||
[[ "$(module_relative_depth rustfs/src/app rustfs/src/app/bucket_usecase/cors.rs)" == 2 ]] || fail "depth of a split file"
|
||||
[[ "$(module_relative_depth rustfs/src/app rustfs/src/app/multipart_usecase/tests/a.rs)" == 3 ]] || fail "depth of a nested split file"
|
||||
ok
|
||||
|
||||
# Forbidden forms: each spelling that names crate::app (or the storage root) is flagged.
|
||||
new_case
|
||||
flagged rustfs/src/app "$STORAGE_HELPER_RULE" rustfs/src/app/bucket_usecase.rs 'use super::StorageError;'
|
||||
flagged rustfs/src/app "$STORAGE_HELPER_RULE" rustfs/src/app/bucket_usecase/cors.rs 'use super::super::StorageError;'
|
||||
flagged rustfs/src/app "$STORAGE_HELPER_RULE" rustfs/src/app/bucket_usecase/cors.rs 'use crate::app::StorageError;'
|
||||
flagged rustfs/src/app "$STORAGE_HELPER_RULE" rustfs/src/app/object/mod.rs 'use super::{DiskError, StorageError};'
|
||||
flagged rustfs/src/app "$STORAGE_OWNER_RULE" rustfs/src/app/multipart_usecase/complete.rs 'use super::super::{quota, versioning_sys};'
|
||||
flagged rustfs/src/app "$STORAGE_OWNER_RULE" rustfs/src/app/bucket_usecase.rs 'super::tagging::helper();'
|
||||
flagged rustfs/src/app "$STORAGE_OWNER_RULE" rustfs/src/app/bucket_usecase/tagging.rs 'super::super::tagging::helper();'
|
||||
flagged rustfs/src/app "$STORAGE_OWNER_RULE" rustfs/src/app/bucket_usecase.rs 'super::super::metadata_sys::get();'
|
||||
flagged rustfs/src/app "$STORAGE_OWNER_RULE" rustfs/src/app/bucket_usecase/cors.rs 'super::super::super::metadata_sys::get();'
|
||||
flagged rustfs/src/app "$RUNTIME_IMPORT_RULE" rustfs/src/app/bucket_usecase/cors.rs 'use super::super::ECStore;'
|
||||
flagged rustfs/src/app "$RUNTIME_PATH_RULE" rustfs/src/app/lifecycle_transition_api_test/put.rs 'super::super::get_server_info();'
|
||||
flagged rustfs/src/storage "$ECFS_TEST_RULE" rustfs/src/storage/ecfs_test.rs ' use super::super::{BucketMetadata};'
|
||||
flagged rustfs/src/storage "$ECFS_TEST_SPLIT_RULE" rustfs/src/storage/legacy_edge/tests/object_meta.rs 'use super::super::super::{BucketMetadata};'
|
||||
flagged rustfs/src/storage "$ECFS_TEST_SPLIT_RULE" rustfs/src/storage/legacy_edge/tests/object_meta.rs ' use super::super::super::super::{check_preconditions};'
|
||||
|
||||
# Ordinary forms: a split file importing from its own parent module is not flagged.
|
||||
new_case
|
||||
not_flagged rustfs/src/app "$STORAGE_HELPER_RULE" rustfs/src/app/bucket_usecase/cors.rs 'use super::StorageError;'
|
||||
not_flagged rustfs/src/app "$STORAGE_OWNER_RULE" rustfs/src/app/bucket_usecase/cors.rs 'use super::{DefaultBucketUsecase, ObjectInfo, ObjectOptions};'
|
||||
not_flagged rustfs/src/app "$STORAGE_OWNER_RULE" rustfs/src/app/bucket_usecase/cors.rs 'super::tagging::helper();'
|
||||
not_flagged rustfs/src/app "$RUNTIME_IMPORT_RULE" rustfs/src/app/bucket_usecase/cors.rs 'use super::{ECStore, DefaultBucketUsecase};'
|
||||
not_flagged rustfs/src/app "$RUNTIME_PATH_RULE" rustfs/src/app/bucket_usecase/cors.rs 'super::get_server_info();'
|
||||
not_flagged rustfs/src/storage "$ECFS_TEST_SPLIT_RULE" rustfs/src/storage/legacy_edge/tests/object_meta.rs ' use super::{BucketMetadata};'
|
||||
|
||||
# A file outside the module directory, or a missing -- separator, is an error, not a silent pass.
|
||||
new_case
|
||||
status=0
|
||||
module_relative_rg rustfs/src/app 'x' -n -- rustfs/src/storage/ecfs.rs 2>/dev/null || status=$?
|
||||
[[ "$status" == 2 ]] || fail "file outside the module dir: status $status"
|
||||
status=0
|
||||
module_relative_rg rustfs/src/app 'x' -n rustfs/src/app/a.rs 2>/dev/null || status=$?
|
||||
[[ "$status" == 2 ]] || fail "missing --: status $status"
|
||||
status=0
|
||||
(cd "$FX" && module_relative_rg rustfs/src/app 'x' -n -- rustfs/src/app/missing.rs) 2>/dev/null || status=$?
|
||||
[[ "$status" == 2 ]] || fail "unreadable file: status $status"
|
||||
ok
|
||||
|
||||
echo "guard_source_set contract tests: $passed passed, $skipped skipped"
|
||||
Executable
+223
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-end probe for the guards that must survive the mechanical split of
|
||||
# ecfs.rs, bucket_usecase.rs, multipart_usecase.rs and
|
||||
# lifecycle_transition_api_test.rs (rustfs/backlog#2765).
|
||||
#
|
||||
# It copies the working tree, simulates the split (moves real code into
|
||||
# storage/legacy_edge/, app/bucket_usecase/, app/multipart_usecase/ and
|
||||
# app/lifecycle_transition_api_test/), plants one forbidden line per rule in a
|
||||
# split file, and runs the real guard scripts there. Each planted line must be
|
||||
# reported with its split-file path, and nothing in the files that hold only
|
||||
# moved real code or ordinary depth-relative imports may be reported: that is
|
||||
# the "moved and broken -> detected" and "moved -> still passes" proof in one
|
||||
# run per guard. The real workspace is never modified.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PYTHON="${RUSTFS_PYTHON_BIN:-python3}"
|
||||
FX="$(mktemp -d)"
|
||||
trap 'rm -rf "$FX"' EXIT
|
||||
|
||||
failures=0
|
||||
checked=0
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $1" >&2
|
||||
failures=$((failures + 1))
|
||||
}
|
||||
|
||||
# expect_reported <output-file> <path> <text> <description>
|
||||
expect_reported() {
|
||||
checked=$((checked + 1))
|
||||
if ! grep -F -- "$2" "$1" | grep -qF -- "$3"; then
|
||||
fail "$4: no report naming $2 with: $3"
|
||||
fi
|
||||
}
|
||||
|
||||
# expect_silent <output-file> <path> <description>
|
||||
expect_silent() {
|
||||
checked=$((checked + 1))
|
||||
if grep -qF -- "$2" "$1"; then
|
||||
fail "$3: $2 was reported: $(grep -F -- "$2" "$1" | head -3)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Copy the tracked and untracked-but-not-ignored files of the working tree.
|
||||
(cd "$ROOT_DIR" && git ls-files -z --cached --others --exclude-standard) |
|
||||
"$PYTHON" -c '
|
||||
import os, shutil, sys
|
||||
root, dest = sys.argv[1], sys.argv[2]
|
||||
for rel in sys.stdin.buffer.read().decode().split("\0"):
|
||||
if not rel:
|
||||
continue
|
||||
src = os.path.join(root, rel)
|
||||
if not os.path.lexists(src):
|
||||
continue
|
||||
out = os.path.join(dest, rel)
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
shutil.copy2(src, out, follow_symlinks=False)
|
||||
' "$ROOT_DIR" "$FX"
|
||||
|
||||
APP="$FX/rustfs/src/app"
|
||||
STORAGE="$FX/rustfs/src/storage"
|
||||
mkdir -p "$APP/bucket_usecase" "$APP/multipart_usecase" "$APP/lifecycle_transition_api_test" \
|
||||
"$STORAGE/legacy_edge/tests"
|
||||
|
||||
# Real code moves: the TRACE hot span leaves bucket_usecase.rs with its
|
||||
# attributes, and the real import blocks of ecfs.rs and the lifecycle
|
||||
# transition tests are copied into split files.
|
||||
"$PYTHON" - "$FX" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
fx = Path(sys.argv[1])
|
||||
bu = fx / "rustfs/src/app/bucket_usecase.rs"
|
||||
text = bu.read_text()
|
||||
marker = " pub async fn execute_list_objects_v2("
|
||||
assert text.count(marker) == 1, "execute_list_objects_v2 must be defined once"
|
||||
idx = text.index(marker)
|
||||
start = text.rindex("\n", 0, idx) + 1
|
||||
while True:
|
||||
prev = text.rindex("\n", 0, start - 1) + 1
|
||||
if text[prev:start].lstrip().startswith(("#[", "///")):
|
||||
start = prev
|
||||
else:
|
||||
break
|
||||
end = text.index("\n }\n", idx) + len("\n }\n")
|
||||
(fx / "rustfs/src/app/bucket_usecase/listing.rs").write_text(
|
||||
"impl DefaultBucketUsecase {\n" + text[start:end] + "}\n")
|
||||
bu.write_text(text[:start] + text[end:])
|
||||
|
||||
def imports(path):
|
||||
return "".join(l + "\n" for l in path.read_text().splitlines() if l.startswith("use crate::"))
|
||||
|
||||
(fx / "rustfs/src/storage/legacy_edge/edge_imports.rs").write_text(
|
||||
imports(fx / "rustfs/src/storage/ecfs.rs"))
|
||||
(fx / "rustfs/src/app/lifecycle_transition_api_test/imports.rs").write_text(
|
||||
imports(fx / "rustfs/src/app/lifecycle_transition_api_test.rs"))
|
||||
PY
|
||||
|
||||
# Ordinary depth-relative uses of the parent module in split files: none of
|
||||
# them reaches crate::app or the storage root, so no rule may report them.
|
||||
cat >"$APP/bucket_usecase/ordinary.rs" <<'EOF'
|
||||
use super::{DefaultBucketUsecase, ObjectInfo, ObjectOptions};
|
||||
use super::ECStore;
|
||||
use super::StorageError;
|
||||
fn ordinary() {
|
||||
super::tagging::helper();
|
||||
super::get_server_info();
|
||||
}
|
||||
EOF
|
||||
cp "$APP/bucket_usecase/ordinary.rs" "$APP/multipart_usecase/ordinary.rs"
|
||||
cp "$APP/bucket_usecase/ordinary.rs" "$APP/lifecycle_transition_api_test/ordinary.rs"
|
||||
cat >"$STORAGE/legacy_edge/tests/ordinary.rs" <<'EOF'
|
||||
mod tests {
|
||||
use super::{BucketMetadata, check_preconditions};
|
||||
}
|
||||
EOF
|
||||
|
||||
# One forbidden line per rule, each in a split file.
|
||||
cat >"$STORAGE/legacy_edge/bucket_configs.rs" <<'EOF'
|
||||
use crate::app::context::resolve_region;
|
||||
use crate::app::bucket_usecase::DefaultBucketUsecase;
|
||||
use crate::storage::core_storage_compat::Probe;
|
||||
use crate::startup_server::LayerProbe;
|
||||
EOF
|
||||
cat >"$APP/bucket_usecase/cors.rs" <<'EOF'
|
||||
use crate::storage::*;
|
||||
use crate::storage::access::ReqInfo;
|
||||
use super::super::StorageError;
|
||||
use super::super::ECStore;
|
||||
fn forbidden() {
|
||||
super::super::get_server_info();
|
||||
}
|
||||
EOF
|
||||
cat >"$APP/multipart_usecase/complete.rs" <<'EOF'
|
||||
use crate::storage::s3_api::multipart;
|
||||
use super::super::{quota, versioning_sys};
|
||||
use crate::app::usecase_storage_compat::Probe;
|
||||
EOF
|
||||
cat >"$APP/lifecycle_transition_api_test/split_probe.rs" <<'EOF'
|
||||
use crate::storage::request_context::RequestContext;
|
||||
fn forbidden() {
|
||||
super::super::tagging::probe();
|
||||
}
|
||||
EOF
|
||||
cat >"$STORAGE/legacy_edge/tests/object_meta.rs" <<'EOF'
|
||||
use super::super::super::{BucketMetadata};
|
||||
mod tests {
|
||||
use super::super::super::super::{check_preconditions};
|
||||
}
|
||||
use crate::storage::core_storage_compat::TestProbe;
|
||||
EOF
|
||||
|
||||
# --- architecture rules ------------------------------------------------------
|
||||
ARCH_OUT="$FX/.arch.out"
|
||||
if (cd "$FX" && ./scripts/check_architecture_migration_rules.sh) >"$ARCH_OUT" 2>&1; then
|
||||
fail "architecture rules passed over planted violations"
|
||||
fi
|
||||
E=rustfs/src/storage/legacy_edge/bucket_configs.rs
|
||||
B=rustfs/src/app/bucket_usecase/cors.rs
|
||||
M=rustfs/src/app/multipart_usecase/complete.rs
|
||||
L=rustfs/src/app/lifecycle_transition_api_test/split_probe.rs
|
||||
T=rustfs/src/storage/legacy_edge/tests/object_meta.rs
|
||||
expect_reported "$ARCH_OUT" "$E:1:" "storage runtime source reads must stay behind" "runtime-source bypass (ECFS set)"
|
||||
expect_reported "$ARCH_OUT" "$E:2:" "ECFS S3 routes must construct app usecases" "ECFS usecase bypass"
|
||||
expect_reported "$ARCH_OUT" "$E:3:" "must use relative core_storage_compat paths" "core_storage_compat (ECFS set)"
|
||||
expect_reported "$ARCH_OUT" "$B:1:" "instead of crate::storage::*" "storage wildcard (bucket split)"
|
||||
expect_reported "$ARCH_OUT" "$B:2:" "consume storage helper APIs through" "storage helper import (bucket split)"
|
||||
expect_reported "$ARCH_OUT" "$B:3:" "consume storage helper APIs through" "depth-aware super::super storage helper"
|
||||
expect_reported "$ARCH_OUT" "$B:4:" "runtime and data-usage facades must stay behind" "depth-aware super::super runtime facade import"
|
||||
expect_reported "$ARCH_OUT" "$B:6:" "runtime and data-usage facades must stay behind" "depth-aware super::super runtime facade path"
|
||||
expect_reported "$ARCH_OUT" "$M:1:" "consume S3 API helpers through" "s3_api bypass (multipart split)"
|
||||
expect_reported "$ARCH_OUT" "$M:2:" "consume storage helper APIs through" "depth-aware super::super owner import"
|
||||
expect_reported "$ARCH_OUT" "$M:3:" "local compatibility consumers must use relative" "usecase_storage_compat (multipart split)"
|
||||
expect_reported "$ARCH_OUT" "$L:1:" "completed storage helper consumers" "test storage helper (lifecycle split)"
|
||||
expect_reported "$ARCH_OUT" "$L:3:" "consume storage helper APIs through" "depth-aware super::super owner path"
|
||||
expect_reported "$ARCH_OUT" "$T:1:" "storage-owner tests must use storage_api test consumer" "ecfs_test import at file depth"
|
||||
expect_reported "$ARCH_OUT" "$T:3:" "storage-owner tests must use storage_api test consumer" "ecfs_test import in an inline tests module"
|
||||
expect_reported "$ARCH_OUT" "$T:5:" "local compatibility consumers must use relative" "ecfs_test core_storage_compat"
|
||||
for silent in rustfs/src/app/bucket_usecase/ordinary.rs rustfs/src/app/multipart_usecase/ordinary.rs \
|
||||
rustfs/src/app/lifecycle_transition_api_test/ordinary.rs rustfs/src/storage/legacy_edge/tests/ordinary.rs \
|
||||
rustfs/src/app/bucket_usecase/listing.rs rustfs/src/storage/legacy_edge/edge_imports.rs \
|
||||
rustfs/src/app/lifecycle_transition_api_test/imports.rs; do
|
||||
expect_silent "$ARCH_OUT" "$silent" "architecture rules over moved or ordinary code"
|
||||
done
|
||||
|
||||
# --- layer dependencies ------------------------------------------------------
|
||||
LAYER_OUT="$FX/.layer.out"
|
||||
if (cd "$FX" && ./scripts/check_layer_dependencies.sh) >"$LAYER_OUT" 2>&1; then
|
||||
fail "layer guard passed over a planted reverse dependency"
|
||||
fi
|
||||
expect_reported "$LAYER_OUT" "dep|$E|interface->composition|" "crate::startup_server::LayerProbe" "legacy_edge keeps the interface layer"
|
||||
expect_silent "$LAYER_OUT" "dep|rustfs/src/storage/legacy_edge/edge_imports.rs|" "moved ecfs imports stay legal"
|
||||
expect_silent "$LAYER_OUT" "dep|rustfs/src/app/lifecycle_transition_api_test/" "moved lifecycle transition tests stay excluded"
|
||||
expect_silent "$LAYER_OUT" "dep|rustfs/src/app/bucket_usecase/listing.rs|" "moved bucket usecase code keeps the app layer"
|
||||
|
||||
# --- logging hot span --------------------------------------------------------
|
||||
LOG_OUT="$FX/.logging.out"
|
||||
checked=$((checked + 1))
|
||||
if ! (cd "$FX" && ./scripts/check_logging_guardrails.sh) >"$LOG_OUT" 2>&1; then
|
||||
fail "logging guardrails failed after a clean move of the TRACE hot span: $(tail -3 "$LOG_OUT")"
|
||||
fi
|
||||
"$PYTHON" - "$APP/bucket_usecase/listing.rs" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
path = Path(sys.argv[1])
|
||||
text = path.read_text()
|
||||
marker = '#[instrument(level = "trace"'
|
||||
assert text.count(marker) == 1, "the moved hot span must carry one TRACE attribute"
|
||||
path.write_text(text.replace(marker, '#[instrument(level = "debug"'))
|
||||
PY
|
||||
checked=$((checked + 1))
|
||||
if (cd "$FX" && ./scripts/check_logging_guardrails.sh) >"$LOG_OUT" 2>&1; then
|
||||
fail "logging guardrails passed after the moved hot span lost TRACE"
|
||||
elif ! grep -qF "execute_list_objects_v2 must remain TRACE-only" "$LOG_OUT"; then
|
||||
fail "logging guardrails failed for another reason: $(tail -3 "$LOG_OUT")"
|
||||
fi
|
||||
|
||||
if [[ $failures -gt 0 ]]; then
|
||||
echo "split guard rules probe: $failures of $checked checks failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "split guard rules probe: $checked checks passed"
|
||||
Reference in New Issue
Block a user