Files
rustfs/crates/e2e_test/src/on_demand_migration/interaction_test.rs
T

824 lines
32 KiB
Rust

// 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.
//! How on-demand migration composes with the rest of the bucket surface
//! (rustfs/backlog#2158): default encryption, Object Lock, quota,
//! notifications, replication, versioning and delete markers, the disable
//! switch, and the admin view.
//!
//! A pulled object goes through the internal put path, so it must be
//! indistinguishable from a client PUT. Each case pins both the resulting
//! local object and what the source was asked for.
use super::common::{
AdminResponse, BoxError, OdmEnvOptions, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env,
start_configured_env_with,
};
use crate::common::{RustFSTestEnvironment, replication_fast_env, signed_request};
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation};
use crate::object_lock::common::put_object_lock_configuration;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{
BucketVersioningStatus, Event, FilterRule, FilterRuleName, NotificationConfiguration, NotificationConfigurationFilter,
ObjectLockRetentionMode, QueueConfiguration, S3KeyFilter, ServerSideEncryption, ServerSideEncryptionByDefault,
ServerSideEncryptionConfiguration, ServerSideEncryptionRule, VersioningConfiguration,
};
use bytes::Bytes;
use local_ip_address::local_ip;
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
use serde_json::Value;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::mpsc;
type TestResult = Result<(), BoxError>;
const SOURCE_BUCKET: &str = "odm-interaction-source";
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
/// `userIdentity.principalId` every write-back event carries.
const ODM_PRINCIPAL_ID: &str = "rustfs-on-demand-migration";
const SETTLE: Duration = Duration::from_secs(120);
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
async fn admin(
env: &RustFSTestEnvironment,
method: http::Method,
path: &str,
body: Option<Value>,
) -> Result<AdminResponse, BoxError> {
let url = format!("{}{path}", env.url);
let body = body.map(|value| serde_json::to_vec(&value)).transpose()?;
let content_type = body.is_some().then_some("application/json");
let response = signed_request(method, &url, &env.access_key, &env.secret_key, body, content_type).await?;
Ok(AdminResponse {
status: response.status().as_u16(),
body: response.text().await?,
})
}
async fn enable_versioning(env: &OdmTestEnv, bucket: &str) -> TestResult {
env.client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
Ok(())
}
/// Case 12: a bucket that encrypts by default stores the pulled object
/// encrypted, and it reads back as plaintext afterwards without touching the
/// source again.
#[tokio::test]
async fn test_odm_pulled_object_uses_bucket_default_encryption() -> TestResult {
let bucket = "odm-interaction-sse";
let env = start_configured_env_with(
OdmEnvOptions {
local_kms: true,
..OdmEnvOptions::default()
},
bucket,
SOURCE_BUCKET,
|_| {},
)
.await?;
env.client
.put_bucket_encryption()
.bucket(bucket)
.server_side_encryption_configuration(
ServerSideEncryptionConfiguration::builder()
.rules(
ServerSideEncryptionRule::builder()
.apply_server_side_encryption_by_default(
ServerSideEncryptionByDefault::builder()
.sse_algorithm(ServerSideEncryption::Aes256)
.build()?,
)
.build(),
)
.build()?,
)
.send()
.await?;
let key = "sse/report.bin";
let body = payload(128 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let first = env.raw_get(bucket, key).await?;
assert_eq!(first.status, 200, "{}", String::from_utf8_lossy(&first.body));
assert_eq!(first.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(first.body, body);
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
let second = env.raw_get(bucket, key).await?;
assert_eq!(second.status, 200, "{}", String::from_utf8_lossy(&second.body));
assert_eq!(second.header(ODM_RESPONSE_HEADER), None, "the second read is local");
assert_eq!(
second.header("x-amz-server-side-encryption"),
Some("AES256"),
"the write-back honours the bucket default encryption"
);
assert_eq!(second.body, body, "the encrypted copy reads back as the source bytes");
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"the encrypted local copy serves the second read"
);
Ok(())
}
/// Case 13: a pulled object inherits the bucket's default Object Lock
/// retention, so it cannot be deleted while the retention holds.
#[tokio::test]
async fn test_odm_pulled_object_inherits_object_lock_retention() -> TestResult {
let bucket = "odm-interaction-object-lock";
let env = OdmTestEnv::start().await?;
env.source.create_bucket_with_mode(SOURCE_BUCKET, BucketMode::Unversioned);
env.client
.create_bucket()
.bucket(bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
put_object_lock_configuration(&env.client, bucket, ObjectLockRetentionMode::Compliance, Some(1), None).await?;
let spec = env.fake_source_spec(SOURCE_BUCKET);
env.configure_and_wait(bucket, &spec).await?;
let key = "locked/record.bin";
let body = payload(32 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let pulled = env.raw_get(bucket, key).await?;
assert_eq!(pulled.status, 200, "{}", String::from_utf8_lossy(&pulled.body));
assert_eq!(pulled.body, body);
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
let head = env.client.head_object().bucket(bucket).key(key).send().await?;
assert_eq!(
head.object_lock_mode().map(|mode| mode.as_str()),
Some("COMPLIANCE"),
"the default retention mode is applied to the pulled object"
);
assert!(head.object_lock_retain_until_date().is_some(), "a retain-until date is set");
let version_id = head.version_id().ok_or("an Object Lock bucket is versioned")?.to_string();
let error = env
.client
.delete_object()
.bucket(bucket)
.key(key)
.version_id(&version_id)
.send()
.await
.expect_err("a COMPLIANCE-retained version cannot be deleted");
assert_eq!(error.code(), Some("AccessDenied"), "{error:?}");
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"the rejected delete never consults the source"
);
Ok(())
}
/// Case 14: the write-back obeys the bucket quota. The client is still
/// served from the source, but nothing is stored and the failure is counted.
#[tokio::test]
async fn test_odm_write_back_respects_the_bucket_quota() -> TestResult {
let bucket = "odm-interaction-quota";
let env = start_configured_env_with(
OdmEnvOptions {
env: vec![("RUSTFS_SCANNER_CYCLE", "1"), ("RUSTFS_SCANNER_START_DELAY_SECS", "0")],
..OdmEnvOptions::default()
},
bucket,
SOURCE_BUCKET,
|_| {},
)
.await?;
// Fill the bucket past the quota it is about to get, so the write-back's
// admission check has to reject it.
let filler = payload(2 * 1024 * 1024);
env.client
.put_object()
.bucket(bucket)
.key("quota/filler.bin")
.body(aws_sdk_s3::primitives::ByteStream::from(filler.clone()))
.send()
.await?;
wait_for_bucket_usage(&env, bucket, filler.len() as u64).await?;
set_bucket_quota(&env, bucket, 1024 * 1024).await?;
let key = "quota/oversized.bin";
let body = payload(2 * 1024 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(response.body, body, "a full bucket still serves the client from the source");
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/quota", 1, SETTLE)
.await?;
env.assert_local_absent(bucket, key).await;
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"the rejected write-back is not retried against the source"
);
Ok(())
}
/// The quota route answers 503 until the durable-quota capability is
/// confirmed on the fresh single-node deployment, so the write is retried.
async fn set_bucket_quota(env: &OdmTestEnv, bucket: &str, quota_bytes: u64) -> TestResult {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let response = admin(
&env.rustfs,
http::Method::PUT,
&format!("/rustfs/admin/v3/quota/{bucket}"),
Some(serde_json::json!({ "quota": quota_bytes, "quota_type": "HARD" })),
)
.await?;
if response.status < 300 {
return Ok(());
}
if response.status != 503 || Instant::now() >= deadline {
return Err(format!("set quota for {bucket}: {} {}", response.status, response.body).into());
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
async fn wait_for_bucket_usage(env: &OdmTestEnv, bucket: &str, at_least: u64) -> TestResult {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let response = admin(&env.rustfs, http::Method::GET, &format!("/rustfs/admin/v3/quota-stats/{bucket}"), None).await?;
if response.status == 200 {
let usage = serde_json::from_str::<Value>(&response.body)?
.get("current_usage")
.and_then(Value::as_u64)
.unwrap_or(0);
if usage >= at_least {
return Ok(());
}
}
if Instant::now() >= deadline {
return Err(format!("bucket usage for {bucket} did not reach {at_least} bytes: {}", response.body).into());
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
/// Case 15: a pull emits an ordinary creation event attributed to the
/// migration principal, and `emit_events=false` silences it.
#[tokio::test]
async fn test_odm_pull_emits_object_created_events_unless_disabled() -> TestResult {
let emitting = "odm-interaction-events";
let silent = "odm-interaction-events-off";
// The collector binds first: the outbound guard rejects a webhook
// endpoint on a private address unless its origin is allowed at startup.
let (endpoint, mut events) = spawn_event_collector().await?;
let allowed_origin = reqwest::Url::parse(&endpoint)?.origin().ascii_serialization();
let env = start_configured_env_with(
OdmEnvOptions {
env: vec![(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str())],
..OdmEnvOptions::default()
},
emitting,
SOURCE_BUCKET,
|_| {},
)
.await?;
let mut silent_spec = env.fake_source_spec(SOURCE_BUCKET);
silent_spec.policy.emit_events = false;
env.configure_and_wait(silent, &silent_spec).await?;
let target = "odm-events";
let switches = admin(
&env.rustfs,
http::Method::PUT,
"/rustfs/admin/v3/module-switches",
Some(serde_json::json!({ "notify_enabled": true, "audit_enabled": false })),
)
.await?;
assert_eq!(switches.status, 200, "{}", switches.body);
let queue_dir = format!("{}/notify-queue-{target}", env.rustfs.temp_dir);
tokio::fs::create_dir_all(&queue_dir).await?;
let configured = admin(
&env.rustfs,
http::Method::PUT,
&format!("/rustfs/admin/v3/target/notify_webhook/{target}"),
Some(serde_json::json!({
"key_values": [
{ "key": "endpoint", "value": endpoint },
{ "key": "queue_dir", "value": queue_dir },
]
})),
)
.await?;
assert_eq!(configured.status, 200, "{}", configured.body);
wait_for_target_online(&env.rustfs, target).await?;
for bucket in [emitting, silent] {
put_notification_config(&env, bucket, target).await?;
}
// Control: an ordinary client PUT must produce an event, so a missing
// one below is about the write-back and not about the pipeline.
let control_key = "events/control.bin";
env.client
.put_object()
.bucket(emitting)
.key(control_key)
.body(aws_sdk_s3::primitives::ByteStream::from(payload(1024)))
.send()
.await?;
let control = wait_for_event(&mut events, emitting, control_key, Duration::from_secs(60))
.await
.ok_or("the notification pipeline delivered no event for a plain PUT")?;
assert_eq!(
control.pointer("/eventName").and_then(Value::as_str),
Some("s3:ObjectCreated:Put"),
"{control}"
);
let emitting_key = "events/pulled.bin";
let silent_key = "events/quiet.bin";
let body = payload(16 * 1024);
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new(emitting_key, body.clone()),
SeedObject::new(silent_key, body.clone()),
],
);
for (bucket, key) in [(emitting, emitting_key), (silent, silent_key)] {
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{bucket}: {}", String::from_utf8_lossy(&response.body));
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "{bucket}/{key} must be stored");
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1, "{bucket}/{key}");
}
let record = wait_for_event(&mut events, emitting, emitting_key, Duration::from_secs(60))
.await
.ok_or("no creation event for the pulled object")?;
assert_eq!(
record.pointer("/eventName").and_then(Value::as_str),
Some("s3:ObjectCreated:Put"),
"{record}"
);
assert_eq!(
record.pointer("/userIdentity/principalId").and_then(Value::as_str),
Some(ODM_PRINCIPAL_ID),
"{record}"
);
// The silent bucket's object landed before the event above was observed,
// so a missing event here is a decision, not a race.
assert!(
wait_for_event(&mut events, silent, silent_key, Duration::from_secs(5))
.await
.is_none(),
"emit_events=false must not publish a creation event"
);
Ok(())
}
async fn put_notification_config(env: &OdmTestEnv, bucket: &str, target: &str) -> TestResult {
let queue = QueueConfiguration::builder()
.id(format!("{bucket}-rule"))
.queue_arn(format!("arn:rustfs:sqs:us-east-1:{target}:webhook"))
.events(Event::from("s3:ObjectCreated:*"))
.filter(
NotificationConfigurationFilter::builder()
.key(
S3KeyFilter::builder()
.filter_rules(FilterRule::builder().name(FilterRuleName::Prefix).value("events/").build())
.build(),
)
.build(),
)
.build()?;
env.client
.put_bucket_notification_configuration()
.bucket(bucket)
.notification_configuration(NotificationConfiguration::builder().queue_configurations(queue).build())
.send()
.await?;
Ok(())
}
async fn wait_for_target_online(env: &RustFSTestEnvironment, target: &str) -> TestResult {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let response = admin(env, http::Method::GET, "/rustfs/admin/v3/target/list", None).await?;
if response.status == 200 {
let body: Value = serde_json::from_str(&response.body)?;
let online = body["notification_endpoints"].as_array().is_some_and(|endpoints| {
endpoints.iter().any(|endpoint| {
endpoint["account_id"].as_str() == Some(target) && endpoint["status"].as_str() == Some("online")
})
});
if online {
return Ok(());
}
}
if Instant::now() >= deadline {
return Err(format!("webhook target {target} did not come online: {}", response.body).into());
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
/// Minimal HTTP receiver: answers everything 200 (so the target's
/// reachability probe reports online) and forwards parsed POST bodies.
async fn spawn_event_collector() -> Result<(String, mpsc::UnboundedReceiver<Value>), BoxError> {
let listener = TcpListener::bind("0.0.0.0:0").await?;
let port = listener.local_addr()?.port();
let endpoint = format!("http://{}/events", std::net::SocketAddr::new(local_ip()?, port));
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let tx = tx.clone();
tokio::spawn(async move {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 4096];
let mut content_length = 0usize;
let mut header_end = None;
while header_end.is_none() {
match stream.read(&mut chunk).await {
Ok(0) | Err(_) => return,
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
}
header_end = buffer.windows(4).position(|window| window == b"\r\n\r\n");
}
let header_end = header_end.expect("loop exits only with a header end");
let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string();
for line in headers.split("\r\n").skip(1) {
if let Some((name, value)) = line.split_once(':')
&& name.trim().eq_ignore_ascii_case("content-length")
{
content_length = value.trim().parse().unwrap_or(0);
}
}
let body_offset = header_end + 4;
while buffer.len() - body_offset < content_length {
match stream.read(&mut chunk).await {
Ok(0) | Err(_) => return,
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
}
}
let _ = stream
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
.await;
let _ = stream.shutdown().await;
if let Ok(value) = serde_json::from_slice::<Value>(&buffer[body_offset..body_offset + content_length]) {
let _ = tx.send(value);
}
});
}
});
Ok((endpoint, rx))
}
/// The first delivered record for `bucket`/`key`, or `None` on timeout.
async fn wait_for_event(
events: &mut mpsc::UnboundedReceiver<Value>,
bucket: &str,
key: &str,
timeout: Duration,
) -> Option<Value> {
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.checked_duration_since(Instant::now())?;
let envelope = tokio::time::timeout(remaining, events.recv()).await.ok()??;
for record in envelope["Records"].as_array().into_iter().flatten() {
// S3 event notifications URL-encode the object key.
let record_key = record.pointer("/s3/object/key").and_then(Value::as_str).map(|raw| {
urlencoding::decode(raw)
.map(|decoded| decoded.into_owned())
.unwrap_or_else(|_| raw.to_string())
});
if record.pointer("/s3/bucket/name").and_then(Value::as_str) == Some(bucket) && record_key.as_deref() == Some(key) {
return Some(record.clone());
}
}
}
}
/// Case 16: a pulled object enters the replication pipeline like any other
/// write, and a configuration whose source is one of the bucket's own
/// replication targets is rejected.
#[tokio::test]
async fn test_odm_pulled_object_replicates_and_target_as_source_is_rejected() -> TestResult {
let bucket = "odm-interaction-replication";
let replica_bucket = "odm-replica";
let fast_env = replication_fast_env();
let env = start_configured_env_with(
OdmEnvOptions {
env: fast_env.clone(),
..OdmEnvOptions::default()
},
bucket,
SOURCE_BUCKET,
|_| {},
)
.await?;
let replica = FakeS3Target::start().await?;
replica.create_bucket(replica_bucket);
enable_versioning(&env, bucket).await?;
let arn = set_remote_target(&env.rustfs, bucket, &replica.address(), replica_bucket).await?;
put_bucket_replication(&env.rustfs, bucket, &arn).await?;
let key = "replicated/asset.bin";
let body = payload(64 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.body, body);
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
let deadline = Instant::now() + SETTLE;
while !replica.has_object(replica_bucket, key) {
assert!(Instant::now() < deadline, "the pulled object was never replicated to the target");
tokio::time::sleep(Duration::from_millis(200)).await;
}
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"replication reads the local copy, never the migration source"
);
let looping = OdmSourceSpec::for_fake_source(&replica, replica_bucket);
let rejected = env.configure_source(bucket, &looping).await?;
assert_eq!(
rejected.status, 400,
"a bucket may not migrate from its own replication target: {}",
rejected.body
);
Ok(())
}
async fn set_remote_target(
env: &RustFSTestEnvironment,
bucket: &str,
endpoint: &str,
target_bucket: &str,
) -> Result<String, BoxError> {
let response = admin(
env,
http::Method::PUT,
&format!("/rustfs/admin/v3/set-remote-target?bucket={}", urlencoding::encode(bucket)),
Some(serde_json::json!({
"endpoint": endpoint,
"credentials": { "accessKey": FAKE_ACCESS_KEY, "secretKey": FAKE_SECRET_KEY },
"targetbucket": target_bucket,
"secure": false,
"skipTlsVerify": false,
"type": "replication"
})),
)
.await?;
if response.status != 200 {
return Err(format!("set remote target: {} {}", response.status, response.body).into());
}
Ok(serde_json::from_str(&response.body)?)
}
async fn put_bucket_replication(env: &RustFSTestEnvironment, bucket: &str, arn: &str) -> TestResult {
let body = format!(
r#"<ReplicationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Role></Role>
<Rule>
<ID>odm-rule</ID>
<Priority>1</Priority>
<Status>Enabled</Status>
<DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication>
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
<Destination><Bucket>{arn}</Bucket></Destination>
</Rule>
</ReplicationConfiguration>"#
);
let url = format!("{}/{bucket}?replication", env.url);
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(body.into_bytes()),
Some("application/xml"),
)
.await?;
if response.status() != 200 {
let status = response.status();
return Err(format!("put bucket replication: {status} {}", response.text().await.unwrap_or_default()).into());
}
Ok(())
}
/// Case 17: a local delete marker is the authoritative answer in a versioned
/// bucket, while an unversioned delete leaves nothing behind and the key is
/// migrated again.
#[tokio::test]
async fn test_odm_delete_marker_shadows_the_source_but_a_plain_delete_does_not() -> TestResult {
let versioned = "odm-interaction-delete-marker";
let unversioned = "odm-interaction-plain-delete";
let env = start_configured_env(versioned, SOURCE_BUCKET, |_| {}).await?;
let spec = env.fake_source_spec(SOURCE_BUCKET);
env.configure_and_wait(unversioned, &spec).await?;
enable_versioning(&env, versioned).await?;
let key = "deleted/doc.bin";
let source_body = payload(8 * 1024);
let local_body = payload(4 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, source_body.clone())]);
for bucket in [versioned, unversioned] {
env.client
.put_object()
.bucket(bucket)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(local_body.clone()))
.send()
.await?;
env.client.delete_object().bucket(bucket).key(key).send().await?;
}
let shadowed = env.raw_get(versioned, key).await?;
assert_eq!(shadowed.status, 404, "{}", String::from_utf8_lossy(&shadowed.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
0,
"a local delete marker answers without the source"
);
let migrated = env.raw_get(unversioned, key).await?;
assert_eq!(migrated.status, 200, "{}", String::from_utf8_lossy(&migrated.body));
assert_eq!(migrated.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(migrated.body, source_body, "an unversioned delete leaves the source authoritative");
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
Ok(())
}
/// Case 18: deleting the configuration stops all source traffic without
/// touching what was already migrated, and reinstalling it resumes.
#[tokio::test]
async fn test_odm_disable_keeps_pulled_objects_and_stops_source_traffic() -> TestResult {
let bucket = "odm-interaction-disable";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let pulled_key = "disable/pulled.bin";
let untouched_key = "disable/untouched.bin";
let body = payload(32 * 1024);
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new(pulled_key, body.clone()),
SeedObject::new(untouched_key, body.clone()),
],
);
let pulled = env.raw_get(bucket, pulled_key).await?;
assert_eq!(pulled.status, 200, "{}", String::from_utf8_lossy(&pulled.body));
assert!(env.wait_local_listed(bucket, pulled_key, SETTLE).await?);
let disabled = env.disable(bucket).await?;
assert_eq!(disabled.status, 204, "{}", disabled.body);
let still_readable = env.raw_get(bucket, pulled_key).await?;
assert_eq!(still_readable.status, 200, "{}", String::from_utf8_lossy(&still_readable.body));
assert_eq!(still_readable.body, body, "a migrated object survives the disable");
assert_eq!(still_readable.header(ODM_RESPONSE_HEADER), None);
assert_eq!(env.source.count_requests(Operation::GetObject, pulled_key), 1);
let missing = env.raw_get(bucket, untouched_key).await?;
assert_eq!(missing.status, 404, "{}", String::from_utf8_lossy(&missing.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, untouched_key),
0,
"a disabled bucket never reaches the source"
);
let spec = env.fake_source_spec(SOURCE_BUCKET);
env.configure_and_wait(bucket, &spec).await?;
let resumed = env.raw_get(bucket, untouched_key).await?;
assert_eq!(resumed.status, 200, "{}", String::from_utf8_lossy(&resumed.body));
assert_eq!(resumed.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(resumed.body, body);
assert_eq!(env.source.count_requests(Operation::GetObject, untouched_key), 1);
Ok(())
}
/// Case 19: the admin surface an operator sees — the configuration read back
/// without its secret, and a status document whose counters match the source
/// journal exactly.
#[tokio::test]
async fn test_odm_admin_config_is_redacted_and_status_counts_match_the_source() -> TestResult {
let bucket = "odm-interaction-admin";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let hit_key = "admin/present.bin";
let miss_key = "admin/absent.bin";
let body = payload(16 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(hit_key, body.clone())]);
let config = env.get_config(bucket).await?;
assert_eq!(config.status, 200, "{}", config.body);
let config = config.json()?;
assert_eq!(
config
.pointer("/config/source/credentials/secret_key")
.and_then(Value::as_str),
Some("REDACTED"),
"{config}"
);
assert_eq!(
config
.pointer("/config/source/credentials/access_key")
.and_then(Value::as_str),
Some(FAKE_ACCESS_KEY),
"the access key stays readable: {config}"
);
assert!(
!config.to_string().contains(FAKE_SECRET_KEY),
"the secret must not appear anywhere in the response"
);
let hit = env.raw_get(bucket, hit_key).await?;
assert_eq!(hit.status, 200, "{}", String::from_utf8_lossy(&hit.body));
for _ in 0..2 {
let miss = env.raw_get(bucket, miss_key).await?;
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
}
assert!(env.wait_local_listed(bucket, hit_key, SETTLE).await?);
let status = env.status_json(bucket).await?;
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
assert_eq!(status.pointer("/enabled").and_then(Value::as_bool), Some(true), "{status}");
assert_eq!(status.pointer("/module_enabled").and_then(Value::as_bool), Some(true), "{status}");
assert_eq!(status.pointer("/provider").and_then(Value::as_str), Some("s3"), "{status}");
assert_eq!(
status
.pointer("/counters/requests_total/get/source_hit")
.and_then(Value::as_u64),
Some(1),
"one source hit, matching the one source GET: {status}"
);
assert_eq!(
status
.pointer("/counters/requests_total/get/source_miss")
.and_then(Value::as_u64),
Some(1),
"only the first miss reached the source: {status}"
);
assert_eq!(
status
.pointer("/counters/requests_total/get/negative_cached")
.and_then(Value::as_u64),
Some(1),
"the second miss stopped at the negative cache: {status}"
);
assert_eq!(
status
.pointer("/counters/pulled_objects_total/inline")
.and_then(Value::as_u64),
Some(1),
"{status}"
);
assert_eq!(
status.pointer("/counters/pulled_bytes_total").and_then(Value::as_u64),
Some(body.len() as u64),
"{status}"
);
assert_eq!(env.source.count_requests(Operation::GetObject, hit_key), 1);
assert_eq!(
env.source.count_requests(Operation::HeadObject, miss_key),
1,
"the status counters and the source journal agree"
);
Ok(())
}