Files
rustfs/crates/e2e_test/src/replication_extension_test.rs
T
abdullahnah92 c3aac2279f fix(site-replication): keep reverse direction after config broadcast (#5292)
* fix(site-replication): keep reverse direction after config broadcast

`site-repl-*` rules encode the sender's outbound direction: their
destination ARN names the receiver. `apply_bucket_meta_item` wrote an
incoming rule set verbatim over the receiver's, leaving the receiver with
a rule whose ARN is its own deployment ID. `reconcile_site_replication_bucket_targets`
skips the local peer, so no bucket target can back that ARN and every
object was dropped; the follow-up call reconciled targets only, so
nothing rebuilt the lost reverse rule. Replication went one-directional
after any PutBucketReplication broadcast — the console's Save button,
`mc replicate import`, a metadata import, or `/site-replication/repair`.

Only operator-authored rules now travel between sites; each site owns its
`site-repl-*` rules and rebuilds them from the current peer set.

Four defects kept that invisible or unrecoverable:

- `update_all_targets` discarded target-client build errors silently, and
  `replicate_object` logged the resulting missing-target drop at debug
  while every other failure there logs at error. Both now report.
- `site_replication_rule_complete` never checked that a rule's
  destination named a remote site, so two sites holding identical
  configs — the post-clobber state — passed as in sync.
- `update_service_account` cannot rewrite `parent_user`, and IAM records
  encrypted with a previous root secret decode as "no such account".
  Startup now reconciles the account, reseeding from the secret every
  site-replication bucket target already stores, and refuses the
  delete-then-create sequence when the parent cannot back an account.
  Bucket rules are reconciled too, so an already-broken site heals on
  upgrade.
- A joined site never verified it could reach the initiator, whose
  endpoint is derived from the Host header of the admin request that
  created the topology. The join now probes each peer and reports through
  `initial_sync_error_message`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(site-replication): report unreachable targets and reconcile on a timer

Rule-shape checking cannot see an unreachable peer. A `site-repl-*` rule
can be perfectly formed while the endpoint recorded for its peer is one
this site cannot reach: `update_all_targets` then builds no client and
`replicate_object` drops every object against that ARN, yet the rule set
still reads as correct and the bucket reports in sync.

Each site now reports whether all of its `site-repl-*` rules resolve to a
live target (`SRBucketInfo.replicationTargetsOnline`, read from the
already-resolved client map so the status path stays cheap), and the
status aggregation treats an offline report as a mismatch. The field is
additive and optional: peers that omit it are "unknown", never a fault,
so a mixed-version topology does not flip every bucket to out of sync.

The reconcilers also run on a 10-minute timer instead of at startup only,
so drift is repaired without waiting for a restart. Both are no-ops when
the wiring already matches — the bucket pass compares serialized targets
and the rule set before writing. The tick takes the site-replication
lifecycle lock with a non-blocking try_acquire and skips the round when
an add/remove/endpoint-refresh holds it: those run in phases, and
rebuilding rules between two of them would resurrect what the operation
just tore down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(site-replication): invert reconcile dependency to satisfy layers

`startup_services.rs` sits in the infra layer and was calling the
reconcilers in `admin::handlers::site_replication`, which is interface —
a reverse dependency that check_layer_dependencies.sh rejects.

Moving the reconcilers down is not viable in this change: they rest on
the site-replication state core (`SiteReplicationState` alone has 107
in-file uses, `load_site_replication_state` 38, the state lock 33), so
relocating it would move ~2000 lines and ~200 call sites through a
bug-fix PR.

Invert the direction instead. A new infra module owns the contract and
the schedule; the admin layer registers its reconciler from
`register_site_replication_route`, which runs while the admin router is
built — `init_startup_http_servers` awaits that before
`init_startup_runtime_services` reconciles, so the hook is always
installed in time. No logic moves and no baseline entry is added: the
dependency genuinely reverses.

The lifecycle guard now wraps both reconcilers in one round rather than
each separately, closing the window between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(site-replication): harden reconcile per review feedback

Addresses the automated review on #5292.

Security: secret recovery from bucket targets accepted any target carrying
the `site-replicator-0` access key. Bucket targets are writable by anyone
holding `admin:SetBucketTarget`, so such a principal could plant a secret
and have reconciliation recreate the broadly privileged replication
account with it. A target must now name a peer in the persisted state and
point at that peer's recorded endpoint, disagreeing targets abort the
recovery, and only missing/unreadable-account errors may trigger it at
all — a transient store failure no longer rewrites a live account.

Durability: the repair no longer deletes before creating. A readable
account is rebound in place through a new `parent_user` field on
`UpdateServiceAccountOpts`, gated to `site-replicator-0` under
`allow_site_replicator_account` exactly as the account itself is. The
parent also lives in the session-token claims, and
`prepare_service_account_auth` denies the account when the two disagree,
so both move together.

Availability: the reconcile scheduler no longer requires an inline IAM
bootstrap. Deferred IAM recovers in the background with no callback into
the scheduler, which left a recovered node with self-pointing rules until
the next restart. It now starts unconditionally and returns early while
IAM or the object store are unavailable. Its first pass runs inside the
task, so walking every bucket no longer delays startup.

Correctness: an endpoint refresh commits bucket targets and peer state in
separate steps without holding the lifecycle lock, so a tick landing
between them rewrote targets from the stale endpoint; the reconciler now
also skips while any pending marker is set. Rule repair preserves an
operator-authored `role` and clears only sender-owned site-replication
ARNs, matching the merge path.

Hot path: the per-object missing-target message returns to debug. The
condition is reported once per bucket per reconcile pass instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-07-27 22:39:23 +08:00

5720 lines
213 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.
use crate::common::{
RustFSTestEnvironment, awscurl_available, awscurl_post_sts_form_urlencoded, init_logging, local_http_client,
replication_fast_env, rustfs_binary_path,
};
use crate::kms::common::{create_key_with_specific_id, sse_customer_key_md5_base64};
use crate::storage_api::replication_extension::BucketTargetSys;
use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::operation::list_object_versions::ListObjectVersionsOutput;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, DeleteMarkerEntry, ObjectVersion, ServerSideEncryption,
VersioningConfiguration,
};
use aws_sdk_s3::{Client, Config};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
use bytes::Bytes;
use futures::{Stream, StreamExt};
use http::header::{CONTENT_TYPE, HOST};
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use local_ip_address::local_ip;
use rcgen::{
BasicConstraints, CertificateParams, CertifiedIssuer, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose,
SanType, generate_simple_self_signed,
};
use reqwest::StatusCode;
use rustfs_madmin::{
AddServiceAccountReq, ListServiceAccountsResp, PeerInfo, PeerSite, ReplicateAddStatus, ReplicateEditStatus,
ReplicateRemoveStatus, SRRemoveReq, SRResyncOpStatus, SRStatusInfo, SiteReplicationInfo, SyncStatus,
};
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use s3s::header::X_AMZ_REPLICATION_STATUS;
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::error::Error;
use std::net::IpAddr;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use time::{Duration as TimeDuration, OffsetDateTime};
use tokio::fs;
use tokio::net::TcpListener;
use tokio::sync::watch;
use tokio::task::JoinSet;
use tokio::time::{Duration, sleep, timeout};
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
/// A replication source server validates the remote target endpoint, and the e2e
/// target runs on loopback (127.0.0.1), which RustFS's SSRF egress guard rejects by
/// default. This suite opts its source servers into the loopback allowance explicitly
/// so the shared harness (`RustFSTestEnvironment` / the cluster harness) stays
/// fail-closed and every other e2e scenario keeps exercising the production SSRF policy.
const LOOPBACK_REPLICATION_TARGET_ENV: &[(&str, &str)] = &[("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", "true")];
/// Short data-scanner cycle for the failure-recovery tests (backlog#1147 repl-5).
///
/// When a replication target is unreachable, `replicate_object` marks the source
/// object's status FAILED in `xl.meta` (it is NOT queued to the on-disk MRF
/// overflow file, which only backstops worker-queue saturation). The mechanism
/// that re-drives those persisted PENDING/FAILED objects — including after a
/// source restart — is the data scanner's replication heal pass
/// (`heal_replication` -> `queue_replication_heal`). The scanner cycle floors at
/// 1s, so this pins it to the floor to keep convergence within seconds. Combine
/// with [`replication_fast_env`] (health-check / MRF-flush / resync polling) so
/// every recovery loop runs at its minimum interval and each scenario finishes
/// well under the two-minute budget.
///
/// `RUSTFS_DATA_USAGE_UPDATE_DIR_CYCLES=1` matters for changes to
/// already-scanned objects (e.g. a delete marker stacked on a replicated key):
/// existing compacted folders are hash-sharded across that many cycles before
/// being rescanned (default 16, `scanner_folder.rs`), so on a long-lived source
/// a failed delete-marker replication may otherwise wait 16 scan cycles before
/// the heal pass revisits it. New keys are unaffected (new folders are always
/// scanned), which is why only restart-free recovery of EXISTING keys needs it.
const FAST_SCANNER_ENV: &[(&str, &str)] = &[
("RUSTFS_SCANNER_CYCLE", "1"),
("RUSTFS_SCANNER_START_DELAY_SECS", "1"),
("RUSTFS_DATA_USAGE_UPDATE_DIR_CYCLES", "1"),
];
const REPL17_KMS_KEY_ID: &str = "repl17-local-key";
const REPL17_SSEC_KEY: &str = "01234567890123456789012345678901";
const REPLICATION_FAILED_EVENT: &str = "s3:Replication:OperationFailedReplication";
const REPLICATION_EVENT_MAX_BUFFER_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, serde::Deserialize)]
struct ReplicationResetStatusResponse {
#[serde(rename = "Targets", default)]
targets: Vec<ReplicationResetStatusTarget>,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct ReplicationResetStatusTarget {
#[serde(rename = "Arn", default)]
arn: String,
#[serde(rename = "ResetID", default)]
reset_id: String,
#[serde(rename = "Status", default)]
status: String,
}
async fn signed_request(
method: http::Method,
url: &str,
access_key: &str,
secret_key: &str,
body: Option<Vec<u8>>,
content_type: Option<&str>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
let mut request = http::Request::builder().method(method.clone()).uri(uri);
request = request.header(HOST, authority);
request = request.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
if let Some(content_type) = content_type {
request = request.header(CONTENT_TYPE, content_type);
}
let content_len = body.as_ref().map(|body| body.len() as i64).unwrap_or_default();
let signed = sign_v4(request.body(Body::empty())?, content_len, access_key, secret_key, "", "us-east-1");
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?;
let client = local_http_client();
let mut request_builder = client.request(reqwest_method, url);
for (name, value) in signed.headers() {
request_builder = request_builder.header(name, value);
}
if let Some(body) = body {
request_builder = request_builder.body(body);
}
Ok(request_builder.send().await?)
}
async fn signed_request_with_client(
client: &reqwest::Client,
method: http::Method,
url: &str,
access_key: &str,
secret_key: &str,
body: Option<Vec<u8>>,
content_type: Option<&str>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
let mut request = http::Request::builder().method(method.clone()).uri(uri);
request = request.header(HOST, authority);
request = request.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
if let Some(content_type) = content_type {
request = request.header(CONTENT_TYPE, content_type);
}
let content_len = body.as_ref().map(|body| body.len() as i64).unwrap_or_default();
let signed = sign_v4(request.body(Body::empty())?, content_len, access_key, secret_key, "", "us-east-1");
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?;
let mut request_builder = client.request(reqwest_method, url);
for (name, value) in signed.headers() {
request_builder = request_builder.header(name, value);
}
if let Some(body) = body {
request_builder = request_builder.body(body);
}
Ok(request_builder.send().await?)
}
async fn signed_request_with_session_token(
method: http::Method,
url: &str,
access_key: &str,
secret_key: &str,
session_token: &str,
body: Option<Vec<u8>>,
content_type: Option<&str>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
let mut request = http::Request::builder().method(method.clone()).uri(uri);
request = request.header(HOST, authority);
request = request.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
if !session_token.is_empty() {
request = request.header("x-amz-security-token", session_token);
}
if let Some(content_type) = content_type {
request = request.header(CONTENT_TYPE, content_type);
}
let content_len = body.as_ref().map(|body| body.len() as i64).unwrap_or_default();
let signed = sign_v4(
request.body(Body::empty())?,
content_len,
access_key,
secret_key,
session_token,
"us-east-1",
);
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?;
let client = local_http_client();
let mut request_builder = client.request(reqwest_method, url);
for (name, value) in signed.headers() {
request_builder = request_builder.header(name, value);
}
if let Some(body) = body {
request_builder = request_builder.body(body);
}
Ok(request_builder.send().await?)
}
fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].to_string())
}
fn parse_assume_role_credentials(xml: &str) -> Result<(String, String, String), Box<dyn Error + Send + Sync>> {
let access_key = extract_xml_tag(xml, "AccessKeyId").ok_or("missing AccessKeyId in AssumeRole response")?;
let secret_key = extract_xml_tag(xml, "SecretAccessKey").ok_or("missing SecretAccessKey in AssumeRole response")?;
let session_token = extract_xml_tag(xml, "SessionToken").ok_or("missing SessionToken in AssumeRole response")?;
Ok((access_key, secret_key, session_token))
}
struct ReplicationTargetOptions<'a> {
endpoint: &'a str,
access_key: &'a str,
secret_key: &'a str,
target_bucket: &'a str,
secure: bool,
skip_tls_verify: bool,
ca_cert_pem: Option<&'a str>,
}
async fn set_replication_target(
source_env: &RustFSTestEnvironment,
source_bucket: &str,
target_env: &RustFSTestEnvironment,
target_bucket: &str,
) -> Result<String, Box<dyn Error + Send + Sync>> {
set_replication_target_with_options(
source_env,
source_bucket,
ReplicationTargetOptions {
endpoint: &target_env.address,
access_key: &target_env.access_key,
secret_key: &target_env.secret_key,
target_bucket,
secure: false,
skip_tls_verify: false,
ca_cert_pem: None,
},
)
.await
}
async fn set_replication_target_with_options(
source_env: &RustFSTestEnvironment,
source_bucket: &str,
options: ReplicationTargetOptions<'_>,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let mut body = serde_json::json!({
"endpoint": options.endpoint,
"credentials": {
"accessKey": options.access_key,
"secretKey": options.secret_key
},
"targetbucket": options.target_bucket,
"secure": options.secure,
"skipTlsVerify": options.skip_tls_verify,
"type": "replication"
});
if let Some(ca_cert_pem) = options.ca_cert_pem {
body["caCertPem"] = serde_json::Value::String(ca_cert_pem.to_string());
}
let url = format!(
"{}/rustfs/admin/v3/set-remote-target?bucket={}",
source_env.url,
urlencoding::encode(source_bucket)
);
let response = signed_request(
http::Method::PUT,
&url,
&source_env.access_key,
&source_env.secret_key,
Some(body.to_string().into_bytes()),
Some("application/json"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("set remote target failed: {status} {body}").into());
}
let body = response.bytes().await?;
let arn: String = serde_json::from_slice(&body)?;
Ok(arn)
}
async fn send_set_replication_target_request(
source_env: &RustFSTestEnvironment,
source_bucket: &str,
update: bool,
body: serde_json::Value,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let mut url = format!(
"{}/rustfs/admin/v3/set-remote-target?bucket={}",
source_env.url,
urlencoding::encode(source_bucket)
);
if update {
url.push_str("&update=true");
}
signed_request(
http::Method::PUT,
&url,
&source_env.access_key,
&source_env.secret_key,
Some(body.to_string().into_bytes()),
Some("application/json"),
)
.await
}
async fn put_bucket_replication(
env: &RustFSTestEnvironment,
bucket: &str,
target_arn: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
put_bucket_replication_with_delete_statuses(env, bucket, target_arn, "Enabled", None).await
}
async fn put_bucket_replication_with_delete_statuses(
env: &RustFSTestEnvironment,
bucket: &str,
target_arn: &str,
delete_marker_status: &str,
version_delete_status: Option<&str>,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let delete_replication = version_delete_status
.map(|status| format!("<DeleteReplication><Status>{status}</Status></DeleteReplication>"))
.unwrap_or_default();
let body = format!(
r#"<ReplicationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Role></Role>
<Rule>
<ID>rule-1</ID>
<Priority>1</Priority>
<Status>Enabled</Status>
<DeleteMarkerReplication>
<Status>{delete_marker_status}</Status>
</DeleteMarkerReplication>
{delete_replication}
<ExistingObjectReplication>
<Status>Enabled</Status>
</ExistingObjectReplication>
<Destination>
<Bucket>{target_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() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("put bucket replication failed: {status} {body}").into());
}
Ok(())
}
async fn put_bucket_replication_rules(
env: &RustFSTestEnvironment,
bucket: &str,
target_arns: &[&str],
) -> Result<(), Box<dyn Error + Send + Sync>> {
let mut rules = String::new();
for (idx, target_arn) in target_arns.iter().enumerate() {
rules.push_str(&format!(
r#"
<Rule>
<ID>rule-{}</ID>
<Priority>{}</Priority>
<Status>Enabled</Status>
<DeleteMarkerReplication>
<Status>Enabled</Status>
</DeleteMarkerReplication>
<ExistingObjectReplication>
<Status>Enabled</Status>
</ExistingObjectReplication>
<Destination>
<Bucket>{}</Bucket>
</Destination>
</Rule>"#,
idx + 1,
idx + 1,
target_arn
));
}
let body = format!(
r#"<ReplicationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Role></Role>{rules}
</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() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("put bucket replication with multiple rules failed: {status} {body}").into());
}
Ok(())
}
async fn delete_bucket_replication(
env: &RustFSTestEnvironment,
bucket: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let url = format!("{}/{bucket}?replication", env.url);
signed_request(http::Method::DELETE, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn get_bucket_replication(
env: &RustFSTestEnvironment,
bucket: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let url = format!("{}/{bucket}?replication", env.url);
signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn enable_bucket_versioning(env: &RustFSTestEnvironment, bucket: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
let client = env.create_s3_client();
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
Ok(())
}
fn insecure_https_client() -> Result<reqwest::Client, Box<dyn Error + Send + Sync>> {
Ok(reqwest::Client::builder()
.no_proxy()
.danger_accept_invalid_certs(true)
.build()?)
}
fn trusted_https_client(ca_cert_pem: &str) -> Result<reqwest::Client, Box<dyn Error + Send + Sync>> {
let ca_cert = reqwest::Certificate::from_pem(ca_cert_pem.as_bytes())?;
Ok(reqwest::Client::builder().no_proxy().add_root_certificate(ca_cert).build()?)
}
async fn new_replication_source_env() -> Result<RustFSTestEnvironment, Box<dyn Error + Send + Sync>> {
// Reuse the shared harness's portable temp-dir/port setup. This previously built
// a bespoke `/private/tmp/...` path, which only exists on macOS and is unwritable
// on the Linux CI runner, so the HTTPS-target tests failed before starting RustFS.
RustFSTestEnvironment::new().await
}
async fn new_replication_https_target_env() -> Result<RustFSTestEnvironment, Box<dyn Error + Send + Sync>> {
let mut env = new_replication_source_env().await?;
let public_ip = local_ip().map_err(|err| std::io::Error::other(format!("resolve local IP failed: {err}")))?;
let port = env
.address
.rsplit(':')
.next()
.ok_or_else(|| std::io::Error::other("target env address missing port"))?
.to_string();
env.address = format!("0.0.0.0:{port}");
env.url = format!("https://{public_ip}:{port}");
Ok(env)
}
async fn generate_self_signed_tls_material(tls_dir: &Path, additional_san: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
fs::create_dir_all(tls_dir).await?;
let cert = generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string(), additional_san.to_string()])?;
fs::write(tls_dir.join("rustfs_cert.pem"), cert.cert.pem()).await?;
fs::write(tls_dir.join("rustfs_key.pem"), cert.signing_key.serialize_pem()).await?;
Ok(())
}
fn test_certificate_params(common_name: &str) -> CertificateParams {
let mut params = CertificateParams::default();
let issued_at = OffsetDateTime::now_utc() - TimeDuration::minutes(5);
params.not_before = issued_at;
params.not_after = issued_at + TimeDuration::days(1);
params.distinguished_name.push(DnType::CountryName, "US");
params.distinguished_name.push(DnType::OrganizationName, "RustFS");
params.distinguished_name.push(DnType::CommonName, common_name);
params
}
async fn generate_private_ca_tls_material(tls_dir: &Path, additional_san: &str) -> Result<String, Box<dyn Error + Send + Sync>> {
fs::create_dir_all(tls_dir).await?;
let ca_key = KeyPair::generate()?;
let mut ca_params = test_certificate_params("RustFS Replication Test CA");
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let ca = CertifiedIssuer::self_signed(ca_params, ca_key)?;
let server_key = KeyPair::generate()?;
let mut server_params = test_certificate_params("localhost");
server_params.is_ca = IsCa::ExplicitNoCa;
server_params.key_usages = vec![KeyUsagePurpose::DigitalSignature, KeyUsagePurpose::KeyEncipherment];
server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
server_params
.subject_alt_names
.push(SanType::DnsName("localhost".try_into()?));
server_params
.subject_alt_names
.push(SanType::IpAddress("127.0.0.1".parse::<IpAddr>()?));
match additional_san.parse::<IpAddr>() {
Ok(ip) => server_params.subject_alt_names.push(SanType::IpAddress(ip)),
Err(_) => server_params
.subject_alt_names
.push(SanType::DnsName(additional_san.try_into()?)),
}
let server_cert = server_params.signed_by(&server_key, &ca)?;
let ca_cert_pem = ca.pem();
fs::write(tls_dir.join("rustfs_cert.pem"), server_cert.pem()).await?;
fs::write(tls_dir.join("rustfs_key.pem"), server_key.serialize_pem()).await?;
fs::write(tls_dir.join("ca.crt"), &ca_cert_pem).await?;
Ok(ca_cert_pem)
}
async fn start_https_rustfs_server(env: &mut RustFSTestEnvironment, tls_dir: &Path) -> Result<(), Box<dyn Error + Send + Sync>> {
let binary_path = rustfs_binary_path();
let process = Command::new(&binary_path)
.env("RUST_LOG", "rustfs=info,rustfs_notify=debug")
.env("RUSTFS_TLS_PATH", tls_dir)
.env("RUSTFS_CONSOLE_ENABLE", "false")
.env("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", "true")
.args([
"--address",
&env.address,
"--access-key",
&env.access_key,
"--secret-key",
&env.secret_key,
&env.temp_dir,
])
.spawn()?;
env.process = Some(process);
Ok(())
}
async fn wait_for_https_server_ready(
client: &reqwest::Client,
env: &RustFSTestEnvironment,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let url = format!("{}/", env.url);
for _ in 0..60 {
match signed_request_with_client(client, http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await {
Ok(response) if response.status().is_success() => return Ok(()),
Ok(_) | Err(_) => sleep(Duration::from_millis(500)).await,
}
}
Err("RustFS HTTPS server failed to become ready within 30 seconds".into())
}
fn assert_untrusted_site_peer_rejected(error: &str, target_url: &str) {
let error_lower = error.to_ascii_lowercase();
let certificate_error =
error.contains("400 Bad Request") && (error_lower.contains("tls") || error_lower.contains("certificate"));
let https_connect_error =
error.contains("500 Internal Server Error") && error_lower.contains("failed (connect)") && error.contains(target_url);
assert!(certificate_error || https_connect_error, "unexpected untrusted HTTPS peer error: {error}");
}
async fn ensure_https_bucket_exists(
client: &reqwest::Client,
env: &RustFSTestEnvironment,
bucket: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let bucket_url = format!("{}/{bucket}/", env.url);
let response =
signed_request_with_client(client, http::Method::HEAD, &bucket_url, &env.access_key, &env.secret_key, None, None).await?;
if response.status() == StatusCode::OK {
return Ok(());
}
let response = signed_request_with_client(
client,
http::Method::PUT,
&bucket_url,
&env.access_key,
&env.secret_key,
Some(Vec::new()),
None,
)
.await?;
match response.status() {
StatusCode::OK | StatusCode::CONFLICT => Ok(()),
status => Err(format!("unexpected HTTPS bucket setup status: {status}").into()),
}
}
async fn enable_bucket_versioning_over_https(
client: &reqwest::Client,
env: &RustFSTestEnvironment,
bucket: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let body = r#"<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>"#;
let url = format!("{}/{bucket}?versioning", env.url);
let response = signed_request_with_client(
client,
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(body.as_bytes().to_vec()),
Some("application/xml"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("enable HTTPS bucket versioning failed: {status} {body}").into());
}
Ok(())
}
async fn wait_for_replicated_object_over_https(
client: &reqwest::Client,
env: &RustFSTestEnvironment,
bucket: &str,
key: &str,
expected_body: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
let url = format!("{}/{bucket}/{key}", env.url);
loop {
let response =
signed_request_with_client(client, http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await?;
match response.status() {
StatusCode::OK => {
let body = response.text().await?;
if body == expected_body {
return Ok(());
}
return Err(format!("replicated HTTPS object body mismatch: expected {expected_body}, got {body}").into());
}
StatusCode::NOT_FOUND if tokio::time::Instant::now() < deadline => {
sleep(Duration::from_secs(1)).await;
}
status if tokio::time::Instant::now() < deadline => {
let body = response.text().await.unwrap_or_default();
if body.contains("NoSuchKey") || body.contains("NoSuchBucket") || body.contains("NotFound") {
sleep(Duration::from_secs(1)).await;
continue;
}
return Err(format!("unexpected HTTPS replication read status: {status} {body}").into());
}
status => {
let body = response.text().await.unwrap_or_default();
return Err(format!("HTTPS replicated object was not readable in time: {status} {body}").into());
}
}
}
}
fn create_user_s3_client(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str) -> Client {
let credentials = Credentials::new(access_key, secret_key, None, None, "e2e-site-replication");
let config = Config::builder()
.credentials_provider(credentials)
.region(Region::new("us-east-1"))
.endpoint_url(&env.url)
.force_path_style(true)
.behavior_version_latest()
.build();
Client::from_conf(config)
}
async fn admin_create_user(
env: &RustFSTestEnvironment,
username: &str,
secret_key: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/add-user?accessKey={}", env.url, username);
let body = serde_json::json!({
"secretKey": secret_key,
"status": "enabled"
});
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(body.to_string().into_bytes()),
Some("application/json"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("create user failed: {status} {body}").into());
}
Ok(())
}
async fn admin_add_canned_policy(
env: &RustFSTestEnvironment,
policy_name: &str,
policy: &serde_json::Value,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/add-canned-policy?name={}", env.url, policy_name);
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(policy.to_string().into_bytes()),
Some("application/json"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("add canned policy failed: {status} {body}").into());
}
Ok(())
}
async fn admin_attach_policy_to_user(
env: &RustFSTestEnvironment,
policy_name: &str,
username: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let url = format!(
"{}/rustfs/admin/v3/set-user-or-group-policy?policyName={}&userOrGroup={}&isGroup=false",
env.url, policy_name, username
);
let response = signed_request(http::Method::PUT, &url, &env.access_key, &env.secret_key, Some(Vec::new()), None).await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("attach policy to user failed: {status} {body}").into());
}
Ok(())
}
async fn admin_update_group_members(
env: &RustFSTestEnvironment,
group_name: &str,
members: &[&str],
) -> Result<(), Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/update-group-members", env.url);
let body = serde_json::json!({
"group": group_name,
"members": members,
"isRemove": false,
"groupStatus": "enabled"
});
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(body.to_string().into_bytes()),
Some("application/json"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("update group members failed: {status} {body}").into());
}
Ok(())
}
async fn admin_attach_policy_to_group(
env: &RustFSTestEnvironment,
policy_name: &str,
group_name: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let url = format!(
"{}/rustfs/admin/v3/set-user-or-group-policy?policyName={}&userOrGroup={}&isGroup=true",
env.url, policy_name, group_name
);
let response = signed_request(http::Method::PUT, &url, &env.access_key, &env.secret_key, Some(Vec::new()), None).await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("attach policy to group failed: {status} {body}").into());
}
Ok(())
}
async fn wait_for_replicated_object(
client: &aws_sdk_s3::Client,
bucket: &str,
key: &str,
expected_body: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
loop {
match client.get_object().bucket(bucket).key(key).send().await {
Ok(output) => {
let body = output.body.collect().await?.into_bytes();
let body = String::from_utf8(body.to_vec())?;
if body == expected_body {
return Ok(());
}
return Err(format!("replicated object body mismatch: expected {expected_body}, got {body}").into());
}
Err(_err) if tokio::time::Instant::now() < deadline => {
sleep(Duration::from_secs(1)).await;
continue;
}
Err(err) => return Err(err.into()),
}
}
}
async fn wait_for_replicated_sha256(
client: &aws_sdk_s3::Client,
bucket: &str,
key: &str,
expected_sha256: [u8; 32],
) -> Result<(), Box<dyn Error + Send + Sync>> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
loop {
match client.get_object().bucket(bucket).key(key).send().await {
Ok(output) => {
let body = output.body.collect().await?.into_bytes();
let actual_sha256: [u8; 32] = Sha256::digest(&body).into();
if actual_sha256 == expected_sha256 {
return Ok(());
}
return Err(
format!("replicated object SHA-256 mismatch: expected {expected_sha256:?}, got {actual_sha256:?}").into(),
);
}
Err(_err) if tokio::time::Instant::now() < deadline => {
sleep(Duration::from_secs(1)).await;
continue;
}
Err(err) => return Err(err.into()),
}
}
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct ReplicatedVersion {
key: String,
version_id: String,
delete_marker: bool,
is_latest: bool,
last_modified: (i64, u32),
e_tag: Option<String>,
}
async fn list_replication_state(client: &Client, bucket: &str) -> Result<Vec<ReplicatedVersion>, Box<dyn Error + Send + Sync>> {
let mut state = Vec::new();
let mut key_marker = None;
let mut version_id_marker = None;
loop {
let output = client
.list_object_versions()
.bucket(bucket)
.set_key_marker(key_marker)
.set_version_id_marker(version_id_marker)
.send()
.await?;
for version in output.versions() {
let last_modified = version.last_modified().ok_or("listed object version omitted LastModified")?;
state.push(ReplicatedVersion {
key: version.key().ok_or("listed object version omitted key")?.to_string(),
version_id: version
.version_id()
.ok_or("listed object version omitted version ID")?
.to_string(),
delete_marker: false,
is_latest: version.is_latest().unwrap_or(false),
last_modified: (last_modified.secs(), last_modified.subsec_nanos()),
e_tag: Some(version.e_tag().ok_or("listed object version omitted ETag")?.to_string()),
});
}
for marker in output.delete_markers() {
let last_modified = marker.last_modified().ok_or("listed delete marker omitted LastModified")?;
state.push(ReplicatedVersion {
key: marker.key().ok_or("listed delete marker omitted key")?.to_string(),
version_id: marker
.version_id()
.ok_or("listed delete marker omitted version ID")?
.to_string(),
delete_marker: true,
is_latest: marker.is_latest().unwrap_or(false),
last_modified: (last_modified.secs(), last_modified.subsec_nanos()),
e_tag: None,
});
}
if output.is_truncated() != Some(true) {
break;
}
key_marker = Some(
output
.next_key_marker()
.ok_or("truncated version listing omitted next key marker")?
.to_string(),
);
version_id_marker = output.next_version_id_marker().map(str::to_string);
}
state.sort();
Ok(state)
}
async fn assert_replication_converged(
source_client: &Client,
source_bucket: &str,
target_client: &Client,
target_bucket: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
let mut consecutive_matches = 0;
loop {
let source = list_replication_state(source_client, source_bucket).await?;
let target = list_replication_state(target_client, target_bucket).await?;
if source == target {
consecutive_matches += 1;
if consecutive_matches == 2 {
return Ok(());
}
} else {
consecutive_matches = 0;
}
if tokio::time::Instant::now() >= deadline {
return Err(format!("replication did not converge in time; source={source:?}, target={target:?}").into());
}
sleep(Duration::from_millis(250)).await;
}
}
async fn get_version_body(
client: &Client,
bucket: &str,
key: &str,
version_id: &str,
) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
Ok(client
.get_object()
.bucket(bucket)
.key(key)
.version_id(version_id)
.send()
.await?
.body
.collect()
.await?
.into_bytes()
.to_vec())
}
/// Poll the source object until it reports a not-yet-replicated status.
///
/// A source object with a reachable replication config carries an
/// `x-amz-replication-status` header (surfaced by the SDK as
/// `replication_status()`). While the target is down it must read `PENDING` or
/// `FAILED`; it can never be `COMPLETED`. Used by the failure-recovery tests to
/// prove the outage was actually observed before recovery is driven.
async fn wait_for_source_replication_pending_or_failed(
client: &Client,
bucket: &str,
key: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
loop {
let head = client.head_object().bucket(bucket).key(key).send().await?;
match head.replication_status().map(|status| status.as_str()) {
Some("PENDING") | Some("FAILED") => return Ok(()),
other => {
if tokio::time::Instant::now() >= deadline {
return Err(format!("source object {key} never reported PENDING/FAILED; last status={other:?}").into());
}
sleep(Duration::from_millis(200)).await;
}
}
}
}
async fn wait_for_source_replication_status(client: &Client, bucket: &str, key: &str, expected: &str, ssec: bool) -> TestResult {
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
let wait = async {
loop {
let request = client.head_object().bucket(bucket).key(key);
let head = if ssec {
request
.sse_customer_algorithm("AES256")
.sse_customer_key(&customer_key)
.sse_customer_key_md5(&customer_key_md5)
.send()
.await?
} else {
request.send().await?
};
if head.replication_status().map(|status| status.as_str()) == Some(expected) {
return Ok(());
}
sleep(Duration::from_millis(200)).await;
}
};
match timeout(Duration::from_secs(30), wait).await {
Ok(result) => result,
Err(_) => Err(format!("source object {key} did not reach replication status {expected} within 30 seconds").into()),
}
}
async fn wait_for_replication_failure_event_stream<S, E>(stream: S, expected_key: &str, max_wait: Duration) -> TestResult
where
S: Stream<Item = Result<Bytes, E>>,
E: Error + Send + Sync + 'static,
{
let wait = async {
futures::pin_mut!(stream);
let mut pending = Vec::new();
loop {
let Some(chunk) = stream.next().await else {
return Err("replication failure event stream ended before the expected event".into());
};
let chunk = chunk.map_err(|err| -> Box<dyn Error + Send + Sync> { Box::new(err) })?;
if chunk.is_empty() {
continue;
}
if pending.len().saturating_add(chunk.len()) > REPLICATION_EVENT_MAX_BUFFER_BYTES {
return Err("replication failure event buffer exceeded 1 MiB".into());
}
pending.extend_from_slice(&chunk);
while let Some(newline) = pending.iter().position(|byte| *byte == b'\n') {
let line = pending.drain(..=newline).collect::<Vec<_>>();
let payload = std::str::from_utf8(&line)?.trim();
if payload.is_empty() {
continue;
}
let envelope: serde_json::Value = serde_json::from_str(payload)?;
let Some(records) = envelope["Records"].as_array() else {
continue;
};
for record in records {
let Some(object_key) = record["s3"]["object"]["key"].as_str() else {
continue;
};
let form_decoded_key = object_key.replace('+', " ");
let object_key = urlencoding::decode(&form_decoded_key)
.map(|decoded| decoded.into_owned())
.unwrap_or_else(|_| object_key.to_string());
if object_key == expected_key && record["eventName"].as_str() == Some(REPLICATION_FAILED_EVENT) {
return Ok(());
}
}
}
}
};
match timeout(max_wait, wait).await {
Ok(result) => result,
Err(_) => Err(format!("replication failure event for {expected_key} was not received within {max_wait:?}").into()),
}
}
async fn wait_for_replication_failure_event(response: reqwest::Response, expected_key: &str) -> TestResult {
wait_for_replication_failure_event_stream(response.bytes_stream(), expected_key, Duration::from_secs(30)).await
}
fn target_history_contains_key(output: &ListObjectVersionsOutput, key: &str) -> bool {
output.versions().iter().any(|version| version.key() == Some(key))
|| output.delete_markers().iter().any(|marker| marker.key() == Some(key))
}
async fn assert_failed_replication_stays_absent_for(
source_client: &Client,
source_bucket: &str,
target_client: &Client,
target_bucket: &str,
key: &str,
ssec: bool,
duration: Duration,
) -> TestResult {
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
let wait = async {
let deadline = tokio::time::Instant::now() + duration;
loop {
let source_request = source_client.head_object().bucket(source_bucket).key(key);
let source = if ssec {
source_request
.sse_customer_algorithm("AES256")
.sse_customer_key(&customer_key)
.sse_customer_key_md5(&customer_key_md5)
.send()
.await?
} else {
source_request.send().await?
};
if source.replication_status().map(|status| status.as_str()) != Some("FAILED") {
return Err(format!("source replication status for {source_bucket}/{key} did not remain FAILED").into());
}
let output = target_client
.list_object_versions()
.bucket(target_bucket)
.prefix(key)
.send()
.await?;
if target_history_contains_key(&output, key) {
return Err(format!("failed replication created target history for {target_bucket}/{key}").into());
}
if tokio::time::Instant::now() >= deadline {
return Ok(());
}
sleep(Duration::from_millis(250)).await;
}
};
match timeout(duration + Duration::from_secs(5), wait).await {
Ok(result) => result,
Err(_) => Err(format!("timed out while checking failed replication stability for {target_bucket}/{key}").into()),
}
}
async fn subscribe_to_replication_failure(
env: &RustFSTestEnvironment,
bucket: &str,
key: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let url = format!(
"{}/{bucket}?events={}&prefix={}&ping=1",
env.url,
urlencoding::encode(REPLICATION_FAILED_EVENT),
urlencoding::encode(key)
);
let response = timeout(
Duration::from_secs(30),
signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None),
)
.await
.map_err(|_| "replication failure event subscription did not respond within 30 seconds")??;
if response.status() != StatusCode::OK {
return Err(format!("failed to subscribe to replication failure events: {}", response.status()).into());
}
Ok(response)
}
async fn build_sse_replication_pair(
label: &str,
enable_kms: bool,
) -> Result<(RustFSTestEnvironment, RustFSTestEnvironment, String, String), Box<dyn Error + Send + Sync>> {
let mut source_env = RustFSTestEnvironment::new().await?;
let mut target_env = RustFSTestEnvironment::new().await?;
let source_kms_key_dir = format!("{}/kms-keys", source_env.temp_dir);
let target_kms_key_dir = format!("{}/kms-keys", target_env.temp_dir);
if enable_kms {
fs::create_dir_all(&source_kms_key_dir).await?;
fs::create_dir_all(&target_kms_key_dir).await?;
create_key_with_specific_id(&source_kms_key_dir, REPL17_KMS_KEY_ID).await?;
create_key_with_specific_id(&target_kms_key_dir, REPL17_KMS_KEY_ID).await?;
}
let mut source_process_env = replication_fast_env();
source_process_env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
source_process_env.extend_from_slice(FAST_SCANNER_ENV);
source_process_env.extend_from_slice(&[("NO_PROXY", "127.0.0.1,localhost"), ("HTTP_PROXY", ""), ("HTTPS_PROXY", "")]);
if enable_kms {
source_process_env.extend_from_slice(&[
("RUSTFS_KMS_ENABLE", "true"),
("RUSTFS_KMS_BACKEND", "local"),
("RUSTFS_KMS_KEY_DIR", source_kms_key_dir.as_str()),
("RUSTFS_KMS_DEFAULT_KEY_ID", REPL17_KMS_KEY_ID),
("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true"),
]);
}
source_env.start_rustfs_server_with_env(vec![], &source_process_env).await?;
let mut target_process_env = vec![("NO_PROXY", "127.0.0.1,localhost"), ("HTTP_PROXY", ""), ("HTTPS_PROXY", "")];
if enable_kms {
target_process_env.extend_from_slice(&[
("RUSTFS_KMS_ENABLE", "true"),
("RUSTFS_KMS_BACKEND", "local"),
("RUSTFS_KMS_KEY_DIR", target_kms_key_dir.as_str()),
("RUSTFS_KMS_DEFAULT_KEY_ID", REPL17_KMS_KEY_ID),
("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true"),
]);
}
target_env
.start_rustfs_server_without_cleanup_with_env(&target_process_env)
.await?;
let source_bucket = format!("repl17-{label}-src");
let target_bucket = format!("repl17-{label}-dst");
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(&source_bucket).send().await?;
target_client.create_bucket().bucket(&target_bucket).send().await?;
enable_bucket_versioning(&source_env, &source_bucket).await?;
enable_bucket_versioning(&target_env, &target_bucket).await?;
let target_arn = set_replication_target(&source_env, &source_bucket, &target_env, &target_bucket).await?;
put_bucket_replication(&source_env, &source_bucket, &target_arn).await?;
Ok((source_env, target_env, source_bucket, target_bucket))
}
async fn assert_managed_sse_replication_fails_explicitly(label: &str, kms: bool) -> TestResult {
let (source_env, target_env, source_bucket, target_bucket) = build_sse_replication_pair(label, true).await?;
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
let key = format!("{label}-contract.txt");
let body = format!("repl-17 {label} payload").into_bytes();
let failure_events = subscribe_to_replication_failure(&source_env, &source_bucket, &key).await?;
let encryption = if kms {
ServerSideEncryption::AwsKms
} else {
ServerSideEncryption::Aes256
};
let request = source_client
.put_object()
.bucket(&source_bucket)
.key(&key)
.body(ByteStream::from(body.clone()))
.server_side_encryption(encryption.clone());
let request = if kms {
request.ssekms_key_id(REPL17_KMS_KEY_ID)
} else {
request
};
request.send().await?;
let source = source_client.get_object().bucket(&source_bucket).key(&key).send().await?;
assert_eq!(source.server_side_encryption(), Some(&encryption));
assert_eq!(source.body.collect().await?.into_bytes().as_ref(), body.as_slice());
wait_for_replication_failure_event(failure_events, &key).await?;
wait_for_source_replication_status(&source_client, &source_bucket, &key, "FAILED", false).await?;
assert_failed_replication_stays_absent_for(
&source_client,
&source_bucket,
&target_client,
&target_bucket,
&key,
false,
Duration::from_secs(5),
)
.await?;
Ok(())
}
async fn wait_for_source_delete_marker_replication_failed(
env: &RustFSTestEnvironment,
bucket: &str,
key: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
let url = format!(
"{}/rustfs/admin/v3/replication/diff?bucket={}&prefix={}",
env.url,
urlencoding::encode(bucket),
urlencoding::encode(key)
);
loop {
let response = signed_request(http::Method::POST, &url, &env.access_key, &env.secret_key, None, None).await?;
if response.status() != StatusCode::OK {
return Err(format!("replication diff failed with status {}", response.status()).into());
}
let diff: serde_json::Value = response.json().await?;
let failed = diff["Entries"].as_array().is_some_and(|entries| {
entries.iter().any(|entry| {
entry["Object"].as_str() == Some(key)
&& entry["IsDeleteMarker"].as_bool() == Some(true)
&& entry["ReplicationStatus"].as_str() == Some("FAILED")
})
});
if failed {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(format!("source delete marker {key} never reported FAILED; last diff={diff}").into());
}
sleep(Duration::from_millis(200)).await;
}
}
/// Return the `LastModified` of the (single) delete marker for `key`, if present.
async fn delete_marker_last_modified(
client: &Client,
bucket: &str,
key: &str,
) -> Result<Option<aws_sdk_s3::primitives::DateTime>, Box<dyn Error + Send + Sync>> {
let output = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
Ok(output
.delete_markers()
.iter()
.filter(|marker| marker.key() == Some(key))
.find_map(|marker| marker.last_modified().cloned()))
}
/// Poll the target until a delete marker for `key` appears, returning its mtime.
async fn wait_for_target_delete_marker(
client: &Client,
bucket: &str,
key: &str,
) -> Result<aws_sdk_s3::primitives::DateTime, Box<dyn Error + Send + Sync>> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
loop {
if let Some(mtime) = delete_marker_last_modified(client, bucket, key).await? {
return Ok(mtime);
}
if tokio::time::Instant::now() >= deadline {
return Err(format!("target never received a delete marker for {key}").into());
}
sleep(Duration::from_millis(250)).await;
}
}
async fn run_replication_check(
env: &RustFSTestEnvironment,
bucket: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let url = format!("{}/{bucket}?replication-check", env.url);
signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn remove_replication_target(
env: &RustFSTestEnvironment,
bucket: &str,
arn: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let url = format!(
"{}/rustfs/admin/v3/remove-remote-target?bucket={}&arn={}",
env.url,
urlencoding::encode(bucket),
urlencoding::encode(arn)
);
signed_request(http::Method::DELETE, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn remove_replication_target_request(
env: &RustFSTestEnvironment,
bucket: Option<&str>,
arn: Option<&str>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let mut url = format!("{}/rustfs/admin/v3/remove-remote-target", env.url);
let mut separator = '?';
if let Some(bucket) = bucket {
url.push(separator);
separator = '&';
url.push_str("bucket=");
url.push_str(&urlencoding::encode(bucket));
}
if let Some(arn) = arn {
url.push(separator);
url.push_str("arn=");
url.push_str(&urlencoding::encode(arn));
}
signed_request(http::Method::DELETE, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn add_service_account(
env: &RustFSTestEnvironment,
signer_access_key: &str,
signer_secret_key: &str,
req: &AddServiceAccountReq,
) -> Result<(String, String), Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/add-service-account", env.url);
let response = signed_request(
http::Method::PUT,
&url,
signer_access_key,
signer_secret_key,
Some(serde_json::to_vec(req)?),
Some("application/json"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("add service account failed: {status} {body}").into());
}
let body = response.bytes().await?;
let parsed: serde_json::Value = serde_json::from_slice(&body)?;
let credentials = parsed
.get("credentials")
.ok_or("add service account response missing credentials")?;
let access_key = credentials
.get("accessKey")
.and_then(|value| value.as_str())
.ok_or("add service account response missing access key")?
.to_string();
let secret_key = credentials
.get("secretKey")
.and_then(|value| value.as_str())
.ok_or("add service account response missing secret key")?
.to_string();
Ok((access_key, secret_key))
}
async fn add_service_account_with_session_token(
env: &RustFSTestEnvironment,
signer_access_key: &str,
signer_secret_key: &str,
session_token: &str,
req: &AddServiceAccountReq,
) -> Result<(String, String), Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/add-service-account", env.url);
let response = signed_request_with_session_token(
http::Method::PUT,
&url,
signer_access_key,
signer_secret_key,
session_token,
Some(serde_json::to_vec(req)?),
Some("application/json"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("add service account with session token failed: {status} {body}").into());
}
let body = response.bytes().await?;
let parsed: serde_json::Value = serde_json::from_slice(&body)?;
let credentials = parsed
.get("credentials")
.ok_or("add service account response missing credentials")?;
let access_key = credentials
.get("accessKey")
.and_then(|value| value.as_str())
.ok_or("add service account response missing access key")?
.to_string();
let secret_key = credentials
.get("secretKey")
.and_then(|value| value.as_str())
.ok_or("add service account response missing secret key")?
.to_string();
Ok((access_key, secret_key))
}
async fn list_service_accounts(
env: &RustFSTestEnvironment,
signer_access_key: &str,
signer_secret_key: &str,
user: Option<&str>,
) -> Result<ListServiceAccountsResp, Box<dyn Error + Send + Sync>> {
let mut url = format!("{}/rustfs/admin/v3/list-service-accounts", env.url);
if let Some(user) = user {
url.push_str("?user=");
url.push_str(&urlencoding::encode(user));
}
let response = signed_request(http::Method::GET, &url, signer_access_key, signer_secret_key, None, None).await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("list service accounts failed: {status} {body}").into());
}
Ok(response.json().await?)
}
async fn get_account_info(
env: &RustFSTestEnvironment,
signer_access_key: &str,
signer_secret_key: &str,
) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/accountinfo", env.url);
let response = signed_request(http::Method::GET, &url, signer_access_key, signer_secret_key, None, None).await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("account info failed: {status} {body}").into());
}
Ok(response.json().await?)
}
async fn wait_for_service_accounts(
env: &RustFSTestEnvironment,
signer_access_key: &str,
signer_secret_key: &str,
user: Option<&str>,
expected: &[&str],
) -> Result<ListServiceAccountsResp, Box<dyn Error + Send + Sync>> {
for _ in 0..20 {
let resp = list_service_accounts(env, signer_access_key, signer_secret_key, user).await?;
let access_keys: Vec<&str> = resp.accounts.iter().map(|account| account.access_key.as_str()).collect();
if expected
.iter()
.all(|expected_key| access_keys.iter().any(|actual| actual == expected_key))
{
return Ok(resp);
}
sleep(Duration::from_millis(250)).await;
}
Err(format!("service accounts did not reach expected keys {expected:?} on {}", env.address).into())
}
async fn wait_for_object_on_target(
client: &aws_sdk_s3::Client,
bucket: &str,
key: &str,
) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
for _ in 0..40 {
match client.get_object().bucket(bucket).key(key).send().await {
Ok(output) => {
let body = output.body.collect().await?.into_bytes().to_vec();
return Ok(body);
}
Err(err) => {
if matches!(err.code(), Some("NoSuchKey" | "NotFound" | "NoSuchVersion")) {
sleep(Duration::from_millis(250)).await;
continue;
}
return Err(err.into());
}
}
}
Err(format!("object {bucket}/{key} was not replicated in time").into())
}
async fn wait_for_bucket_on_target(client: &aws_sdk_s3::Client, bucket: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
for _ in 0..40 {
match client.head_bucket().bucket(bucket).send().await {
Ok(_) => return Ok(()),
Err(err) => {
if matches!(err.code(), Some("NotFound" | "NoSuchBucket")) {
sleep(Duration::from_millis(250)).await;
continue;
}
return Err(err.into());
}
}
}
Err(format!("bucket {bucket} was not replicated to the target site in time").into())
}
async fn wait_for_user_get_object(client: &Client, bucket: &str, key: &str) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
let mut last_error = None;
for _ in 0..40 {
match client.get_object().bucket(bucket).key(key).send().await {
Ok(output) => {
let body = output.body.collect().await?.into_bytes().to_vec();
return Ok(body);
}
Err(err) => {
last_error = Some(err.to_string());
sleep(Duration::from_millis(250)).await;
}
}
}
Err(format!(
"user could not read replicated object {bucket}/{key} in time; last error: {}",
last_error.unwrap_or_else(|| "unknown".to_string())
)
.into())
}
async fn list_replication_targets_request(
env: &RustFSTestEnvironment,
bucket: Option<&str>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let mut url = format!("{}/rustfs/admin/v3/list-remote-targets", env.url);
if let Some(bucket) = bucket {
url.push_str("?bucket=");
url.push_str(&urlencoding::encode(bucket));
}
signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn wait_for_remote_target_arn(env: &RustFSTestEnvironment, bucket: &str) -> Result<String, Box<dyn Error + Send + Sync>> {
for _ in 0..40 {
let response = list_replication_targets_request(env, Some(bucket)).await?;
if response.status() == StatusCode::OK {
let targets: Vec<serde_json::Value> = response.json().await?;
if let Some(arn) = targets
.first()
.and_then(|target| target.get("arn"))
.and_then(|arn| arn.as_str())
.filter(|arn| !arn.is_empty())
{
return Ok(arn.to_string());
}
}
sleep(Duration::from_millis(250)).await;
}
Err(format!("site replication did not configure a remote target for bucket {bucket} in time").into())
}
async fn site_replication_add(
env: &RustFSTestEnvironment,
sites: &[PeerSite],
) -> Result<ReplicateAddStatus, Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/site-replication/add?replicateILMExpiry=false", env.url);
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(serde_json::to_vec(sites)?),
Some("application/json"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("site replication add failed: {status} {body}").into());
}
Ok(serde_json::from_slice(&response.bytes().await?)?)
}
async fn site_replication_info(env: &RustFSTestEnvironment) -> Result<SiteReplicationInfo, Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/site-replication/info", env.url);
let response = signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("site replication info failed: {status} {body}").into());
}
Ok(serde_json::from_slice(&response.bytes().await?)?)
}
async fn site_replication_resync_op(
env: &RustFSTestEnvironment,
operation: &str,
peer: &PeerInfo,
) -> Result<SRResyncOpStatus, Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/site-replication/resync/op?operation={operation}", env.url);
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(serde_json::to_vec(peer)?),
Some("application/json"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("site replication resync {operation} failed: {status} {body}").into());
}
Ok(serde_json::from_slice(&response.bytes().await?)?)
}
async fn site_replication_edit(
env: &RustFSTestEnvironment,
query: &str,
peer: &PeerInfo,
) -> Result<ReplicateEditStatus, Box<dyn Error + Send + Sync>> {
let url = if query.is_empty() {
format!("{}/rustfs/admin/v3/site-replication/edit", env.url)
} else {
format!("{}/rustfs/admin/v3/site-replication/edit?{query}", env.url)
};
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(serde_json::to_vec(peer)?),
Some("application/json"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("site replication edit failed: {status} {body}").into());
}
Ok(serde_json::from_slice(&response.bytes().await?)?)
}
async fn site_replication_status(env: &RustFSTestEnvironment, query: &str) -> Result<SRStatusInfo, Box<dyn Error + Send + Sync>> {
let url = if query.is_empty() {
format!("{}/rustfs/admin/v3/site-replication/status", env.url)
} else {
format!("{}/rustfs/admin/v3/site-replication/status?{query}", env.url)
};
let response = signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("site replication status failed: {status} {body}").into());
}
Ok(serde_json::from_slice(&response.bytes().await?)?)
}
fn proxy_error_response(error: impl std::fmt::Display) -> Response<Full<bytes::Bytes>> {
Response::builder()
.status(reqwest::StatusCode::BAD_GATEWAY)
.body(Full::new(bytes::Bytes::from(error.to_string())))
.expect("static proxy response must be valid")
}
async fn forward_replication_proxy_request(
request: Request<Incoming>,
backend_url: &str,
client: &reqwest::Client,
request_count: &AtomicU64,
mut replication_enabled: watch::Receiver<bool>,
) -> Response<Full<bytes::Bytes>> {
let (parts, body) = request.into_parts();
let is_replication = parts
.headers
.get(X_AMZ_REPLICATION_STATUS)
.is_some_and(|value| value.as_bytes().eq_ignore_ascii_case(b"REPLICA"));
if is_replication {
request_count.fetch_add(1, Ordering::Relaxed);
while !*replication_enabled.borrow() {
if replication_enabled.changed().await.is_err() {
return proxy_error_response("replication gate closed");
}
}
}
let Some(path_and_query) = parts.uri.path_and_query() else {
return proxy_error_response("request URI omitted path");
};
let body = match body.collect().await {
Ok(body) => body.to_bytes(),
Err(error) => return proxy_error_response(error),
};
let mut forwarded = client.request(parts.method, format!("{backend_url}{path_and_query}"));
for (name, value) in &parts.headers {
forwarded = forwarded.header(name, value);
}
let response = match forwarded.body(body).send().await {
Ok(response) => response,
Err(error) => return proxy_error_response(error),
};
let status = response.status();
let headers = response.headers().clone();
let body = match response.bytes().await {
Ok(body) => body,
Err(error) => return proxy_error_response(error),
};
let mut proxied = Response::builder().status(status);
for (name, value) in &headers {
proxied = proxied.header(name, value);
}
proxied.body(Full::new(body)).expect("upstream response must be valid")
}
async fn start_replication_counting_proxy(
backend_url: &str,
tasks: &mut JoinSet<()>,
) -> Result<(String, Arc<AtomicU64>, watch::Sender<bool>), Box<dyn Error + Send + Sync>> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let proxy_url = format!("http://{}", listener.local_addr()?);
let backend_url = backend_url.to_string();
let request_count = Arc::new(AtomicU64::new(0));
let task_request_count = request_count.clone();
let (replication_enabled, task_replication_enabled) = watch::channel(true);
tasks.spawn(async move {
let client = local_http_client();
let mut connections = JoinSet::new();
loop {
tokio::select! {
accepted = listener.accept() => {
let Ok((stream, _)) = accepted else { break };
let backend_url = backend_url.clone();
let client = client.clone();
let request_count = task_request_count.clone();
let replication_enabled = task_replication_enabled.clone();
connections.spawn(async move {
let service = service_fn(move |request| {
let backend_url = backend_url.clone();
let client = client.clone();
let request_count = request_count.clone();
let replication_enabled = replication_enabled.clone();
async move {
Ok::<_, Infallible>(
forward_replication_proxy_request(
request,
&backend_url,
&client,
&request_count,
replication_enabled,
)
.await,
)
}
});
let _ = http1::Builder::new().serve_connection(TokioIo::new(stream), service).await;
});
}
_ = connections.join_next(), if !connections.is_empty() => {}
}
}
});
Ok((proxy_url, request_count, replication_enabled))
}
async fn site_replication_remove(
env: &RustFSTestEnvironment,
req: &SRRemoveReq,
) -> Result<ReplicateRemoveStatus, Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/site-replication/remove", env.url);
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(serde_json::to_vec(req)?),
Some("application/json"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("site replication remove failed: {status} {body}").into());
}
Ok(serde_json::from_slice(&response.bytes().await?)?)
}
async fn site_replication_state_edit(
env: &RustFSTestEnvironment,
body: &rustfs_madmin::SRStateEditReq,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/site-replication/state/edit", env.url);
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(serde_json::to_vec(body)?),
Some("application/json"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("site replication state edit failed: {status} {body}").into());
}
Ok(())
}
async fn get_replication_reset_status(
env: &RustFSTestEnvironment,
bucket: &str,
arn: &str,
) -> Result<ReplicationResetStatusResponse, Box<dyn Error + Send + Sync>> {
let url = format!("{}/{bucket}?replication-reset-status&arn={}", env.url, urlencoding::encode(arn));
let response = signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("replication reset status failed: {status} {body}").into());
}
Ok(serde_json::from_slice(&response.bytes().await?)?)
}
async fn wait_for_site_replication_enabled(
env: &RustFSTestEnvironment,
expected_sites: usize,
) -> Result<SiteReplicationInfo, Box<dyn Error + Send + Sync>> {
for _ in 0..40 {
let info = site_replication_info(env).await?;
if info.enabled
&& info.sites.len() == expected_sites
&& info.sites.iter().all(|peer| peer.sync_state == SyncStatus::Enable)
{
return Ok(info);
}
sleep(Duration::from_millis(250)).await;
}
Err(format!("site replication did not reach {expected_sites} sites on {}", env.address).into())
}
async fn wait_for_site_replication_disabled(
env: &RustFSTestEnvironment,
) -> Result<SiteReplicationInfo, Box<dyn Error + Send + Sync>> {
wait_for_site_replication_info(env, |info| !info.enabled && info.sites.is_empty()).await
}
async fn assert_site_replication_bucket_detached(
env: &RustFSTestEnvironment,
bucket: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let targets_response = list_replication_targets_request(env, Some(bucket)).await?;
if targets_response.status() != StatusCode::OK {
return Err(format!("list remote targets failed after site removal: {}", targets_response.status()).into());
}
let targets: Vec<serde_json::Value> = targets_response.json().await?;
if !targets.is_empty() {
return Err(format!("site removal left remote targets for {bucket}: {targets:?}").into());
}
let replication_response = get_bucket_replication(env, bucket).await?;
if replication_response.status() != StatusCode::NOT_FOUND {
let status = replication_response.status();
let body = replication_response.text().await.unwrap_or_default();
return Err(format!("site removal left replication config for {bucket}: {status} {body}").into());
}
Ok(())
}
async fn wait_for_site_replication_info<F>(
env: &RustFSTestEnvironment,
predicate: F,
) -> Result<SiteReplicationInfo, Box<dyn Error + Send + Sync>>
where
F: Fn(&SiteReplicationInfo) -> bool,
{
for _ in 0..40 {
let info = site_replication_info(env).await?;
if predicate(&info) {
return Ok(info);
}
sleep(Duration::from_millis(250)).await;
}
Err(format!("site replication info did not reach expected state on {}", env.address).into())
}
async fn wait_for_site_replication_status<F>(
env: &RustFSTestEnvironment,
query: &str,
predicate: F,
) -> Result<SRStatusInfo, Box<dyn Error + Send + Sync>>
where
F: Fn(&SRStatusInfo) -> bool,
{
for _ in 0..40 {
let status = site_replication_status(env, query).await?;
if predicate(&status) {
return Ok(status);
}
sleep(Duration::from_millis(250)).await;
}
Err(format!("site replication status did not reach expected state on {}", env.address).into())
}
async fn wait_for_replication_reset_target<F>(
env: &RustFSTestEnvironment,
bucket: &str,
arn: &str,
predicate: F,
) -> Result<ReplicationResetStatusTarget, Box<dyn Error + Send + Sync>>
where
F: Fn(&ReplicationResetStatusTarget) -> bool,
{
let mut last_seen = None;
for _ in 0..40 {
let status = get_replication_reset_status(env, bucket, arn).await?;
if let Some(target) = status.targets.into_iter().find(|target| target.arn == arn) {
if predicate(&target) {
return Ok(target);
}
last_seen = Some(target);
}
sleep(Duration::from_millis(250)).await;
}
Err(format!(
"replication reset target {arn} for bucket {bucket} did not reach expected state; last seen: {:?}",
last_seen
)
.into())
}
async fn build_replication_pair(
enable_target_versioning: bool,
) -> Result<(RustFSTestEnvironment, RustFSTestEnvironment, String), Box<dyn Error + Send + Sync>> {
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-check-src";
let target_bucket = "replication-check-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
if enable_target_versioning {
enable_bucket_versioning(&target_env, target_bucket).await?;
}
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
Ok((source_env, target_env, source_bucket.to_string()))
}
#[tokio::test]
#[serial]
async fn test_replication_check_succeeds_with_remote_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (source_env, target_env, source_bucket) = build_replication_pair(true).await?;
let response = run_replication_check(&source_env, &source_bucket).await?;
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await?;
assert_eq!(payload["Status"], "OK");
assert_eq!(payload["ActiveMutation"], true);
assert_eq!(payload["Targets"].as_array().map(Vec::len), Some(1));
assert_eq!(payload["Targets"][0]["Status"], "OK");
assert_eq!(payload["Targets"][0]["Phases"]["Put"]["Status"], "OK");
assert_eq!(payload["Targets"][0]["Phases"]["DeleteMarker"]["Status"], "OK");
assert_eq!(payload["Targets"][0]["Phases"]["VersionDelete"]["Status"], "OK");
assert_eq!(payload["Targets"][0]["Phases"]["Cleanup"]["Status"], "OK");
let target_client = target_env.create_s3_client();
let versions = target_client
.list_object_versions()
.bucket("replication-check-dst")
.prefix(".rustfs.sys/replication-check/")
.send()
.await?;
assert!(
versions.versions().is_empty() && versions.delete_markers().is_empty(),
"successful check must remove every probe version and delete marker"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_replication_check_rejects_target_without_object_lock() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-check-lock-src";
let target_bucket = "replication-check-lock-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client
.create_bucket()
.bucket(source_bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
let response = run_replication_check(&source_env, source_bucket).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::OK);
let payload: serde_json::Value = serde_json::from_str(&body)?;
assert_eq!(payload["Status"], "FAILED");
assert_eq!(payload["Targets"][0]["Status"], "FAILED");
assert_eq!(payload["Targets"][0]["Phases"]["ObjectLock"]["Status"], "FAILED");
assert!(
payload["Targets"][0]["Phases"]["ObjectLock"]["Error"]
.as_str()
.unwrap_or_default()
.contains("object lock"),
"unexpected response: {body}"
);
assert_eq!(payload["Targets"][0]["Phases"]["Put"]["Status"], "SKIPPED");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_rejects_unversioned_source_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-check-unversioned-src";
let target_bucket = "replication-check-unversioned-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let err = set_replication_target(&source_env, source_bucket, &target_env, target_bucket)
.await
.expect_err("unversioned source bucket should be rejected during remote target setup");
let err = err.to_string();
assert!(err.contains("400 Bad Request"), "unexpected set remote target error: {err}");
assert!(err.contains("InvalidRequest"), "unexpected set remote target error: {err}");
assert!(
err.to_ascii_lowercase().contains("not versioned"),
"unexpected set remote target error: {err}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_replication_check_rejects_unversioned_source_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let bucket = "replication-check-source-unversioned";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
let response = run_replication_check(&env, bucket).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("versioning"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_replication_check_rejects_missing_replication_config() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let bucket = "replication-check-missing-config";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&env, bucket).await?;
let response = run_replication_check(&env, bucket).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("ReplicationConfigurationNotFoundError"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_replication_check_rejects_invalid_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let response = run_replication_check(&env, "replication-check-no-such-bucket").await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("NoSuchBucket"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_rejects_same_bucket_on_same_deployment() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let bucket = "replication-check-same-target";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&env, bucket).await?;
let body = serde_json::json!({
"endpoint": env.address,
"credentials": {
"accessKey": env.access_key,
"secretKey": env.secret_key
},
"targetbucket": bucket,
"secure": false,
"type": "replication"
});
let url = format!("{}/rustfs/admin/v3/set-remote-target?bucket={}", env.url, urlencoding::encode(bucket));
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(body.to_string().into_bytes()),
Some("application/json"),
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("IncorrectEndpoint"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_rejects_unversioned_target_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-check-src";
let target_bucket = "replication-check-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
let err = set_replication_target(&source_env, source_bucket, &target_env, target_bucket)
.await
.expect_err("unversioned target bucket should be rejected during remote target setup");
assert!(
err.to_string().contains("Remote target bucket not versioned"),
"unversioned destination must not be misreported as the source: {err}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_update_requires_arn() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-update-needs-arn-src";
let target_bucket = "replication-update-needs-arn-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let response = send_set_replication_target_request(
&source_env,
source_bucket,
true,
serde_json::json!({
"endpoint": target_env.address,
"credentials": {
"accessKey": target_env.access_key,
"secretKey": target_env.secret_key
},
"targetbucket": target_bucket,
"secure": false,
"type": "replication"
}),
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("arn is empty"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_update_rejects_missing_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-update-missing-target-src";
let target_bucket = "replication-update-missing-target-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let response = send_set_replication_target_request(
&source_env,
source_bucket,
true,
serde_json::json!({
"endpoint": target_env.address,
"credentials": {
"accessKey": target_env.access_key,
"secretKey": target_env.secret_key
},
"targetbucket": target_bucket,
"secure": false,
"type": "replication",
"arn": "arn:aws:s3:us-east-1:123456789012:replication::missing-target"
}),
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("target not found"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_rejects_invalid_target_url() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let bucket = "replication-invalid-target-url-src";
let source_client = source_env.create_s3_client();
source_client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&source_env, bucket).await?;
let response = send_set_replication_target_request(
&source_env,
bucket,
false,
serde_json::json!({
"endpoint": "://invalid-target-url",
"credentials": {
"accessKey": "replication",
"secretKey": "replication"
},
"targetbucket": "target-bucket",
"secure": false,
"type": "replication"
}),
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("invalid target url"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = new_replication_source_env()
.await
.map_err(|err| std::io::Error::other(format!("create source env failed: {err}")))?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await
.map_err(|err| std::io::Error::other(format!("start source HTTP server failed: {err}")))?;
let mut target_env = new_replication_https_target_env()
.await
.map_err(|err| std::io::Error::other(format!("create target env failed: {err}")))?;
let tls_dir = std::path::PathBuf::from(&target_env.temp_dir).join("tls");
let target_host = target_env
.url
.trim_start_matches("https://")
.split(':')
.next()
.ok_or_else(|| std::io::Error::other("target HTTPS URL missing host"))?
.to_string();
generate_self_signed_tls_material(&tls_dir, &target_host)
.await
.map_err(|err| std::io::Error::other(format!("generate self-signed TLS material failed: {err}")))?;
start_https_rustfs_server(&mut target_env, &tls_dir)
.await
.map_err(|err| std::io::Error::other(format!("start target HTTPS server failed: {err}")))?;
let https_client =
insecure_https_client().map_err(|err| std::io::Error::other(format!("build HTTPS client failed: {err}")))?;
wait_for_https_server_ready(&https_client, &target_env)
.await
.map_err(|err| std::io::Error::other(format!("wait for target HTTPS server ready failed: {err}")))?;
let source_bucket = "replication-self-signed-src";
let target_bucket = "replication-self-signed-dst";
let source_client = source_env.create_s3_client();
source_client
.create_bucket()
.bucket(source_bucket)
.send()
.await
.map_err(|err| std::io::Error::other(format!("create source bucket failed: {err}")))?;
enable_bucket_versioning(&source_env, source_bucket)
.await
.map_err(|err| std::io::Error::other(format!("enable source bucket versioning failed: {err}")))?;
ensure_https_bucket_exists(&https_client, &target_env, target_bucket)
.await
.map_err(|err| std::io::Error::other(format!("create target HTTPS bucket failed: {err}")))?;
enable_bucket_versioning_over_https(&https_client, &target_env, target_bucket)
.await
.map_err(|err| std::io::Error::other(format!("enable target HTTPS bucket versioning failed: {err}")))?;
let err = set_replication_target_with_options(
&source_env,
source_bucket,
ReplicationTargetOptions {
endpoint: target_env.url.trim_start_matches("https://"),
access_key: &target_env.access_key,
secret_key: &target_env.secret_key,
target_bucket,
secure: true,
skip_tls_verify: false,
ca_cert_pem: None,
},
)
.await
.expect_err("self-signed HTTPS target should fail without skipTlsVerify");
let err = err.to_string();
assert!(err.contains("400 Bad Request"), "unexpected HTTPS target setup error: {err}");
assert!(err.contains("InvalidRequest"), "unexpected HTTPS target setup error: {err}");
assert!(
err.to_ascii_lowercase().contains("certificate") || err.to_ascii_lowercase().contains("tls"),
"unexpected HTTPS target setup error: {err}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_allows_self_signed_https_target_with_skip_tls_verify() -> Result<(), Box<dyn Error + Send + Sync>>
{
init_logging();
let mut source_env = new_replication_source_env()
.await
.map_err(|err| std::io::Error::other(format!("create source env failed: {err}")))?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await
.map_err(|err| std::io::Error::other(format!("start source HTTP server failed: {err}")))?;
let mut target_env = new_replication_https_target_env()
.await
.map_err(|err| std::io::Error::other(format!("create target env failed: {err}")))?;
let tls_dir = std::path::PathBuf::from(&target_env.temp_dir).join("tls");
let target_host = target_env
.url
.trim_start_matches("https://")
.split(':')
.next()
.ok_or_else(|| std::io::Error::other("target HTTPS URL missing host"))?
.to_string();
generate_self_signed_tls_material(&tls_dir, &target_host)
.await
.map_err(|err| std::io::Error::other(format!("generate self-signed TLS material failed: {err}")))?;
start_https_rustfs_server(&mut target_env, &tls_dir)
.await
.map_err(|err| std::io::Error::other(format!("start target HTTPS server failed: {err}")))?;
let https_client =
insecure_https_client().map_err(|err| std::io::Error::other(format!("build HTTPS client failed: {err}")))?;
wait_for_https_server_ready(&https_client, &target_env)
.await
.map_err(|err| std::io::Error::other(format!("wait for target HTTPS server ready failed: {err}")))?;
let source_bucket = "replication-self-signed-ok-src";
let target_bucket = "replication-self-signed-ok-dst";
let object_key = "self-signed-replication.txt";
let body = "replication over self-signed https should succeed";
let source_client = source_env.create_s3_client();
source_client
.create_bucket()
.bucket(source_bucket)
.send()
.await
.map_err(|err| std::io::Error::other(format!("create source bucket failed: {err}")))?;
enable_bucket_versioning(&source_env, source_bucket)
.await
.map_err(|err| std::io::Error::other(format!("enable source bucket versioning failed: {err}")))?;
ensure_https_bucket_exists(&https_client, &target_env, target_bucket)
.await
.map_err(|err| std::io::Error::other(format!("create target HTTPS bucket failed: {err}")))?;
enable_bucket_versioning_over_https(&https_client, &target_env, target_bucket)
.await
.map_err(|err| std::io::Error::other(format!("enable target HTTPS bucket versioning failed: {err}")))?;
let target_arn = set_replication_target_with_options(
&source_env,
source_bucket,
ReplicationTargetOptions {
endpoint: target_env.url.trim_start_matches("https://"),
access_key: &target_env.access_key,
secret_key: &target_env.secret_key,
target_bucket,
secure: true,
skip_tls_verify: true,
ca_cert_pem: None,
},
)
.await?;
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
let response = run_replication_check(&source_env, source_bucket).await?;
assert_eq!(response.status(), StatusCode::OK);
source_client
.put_object()
.bucket(source_bucket)
.key(object_key)
.body(ByteStream::from(body.as_bytes().to_vec()))
.send()
.await?;
wait_for_replicated_object_over_https(&https_client, &target_env, target_bucket, object_key, body).await?;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem() -> Result<(), Box<dyn Error + Send + Sync>>
{
init_logging();
let mut source_env = new_replication_source_env()
.await
.map_err(|err| std::io::Error::other(format!("create source env failed: {err}")))?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await
.map_err(|err| std::io::Error::other(format!("start source HTTP server failed: {err}")))?;
let mut target_env = new_replication_https_target_env()
.await
.map_err(|err| std::io::Error::other(format!("create target env failed: {err}")))?;
let tls_dir = std::path::PathBuf::from(&target_env.temp_dir).join("tls");
let target_host = target_env
.url
.trim_start_matches("https://")
.split(':')
.next()
.ok_or_else(|| std::io::Error::other("target HTTPS URL missing host"))?
.to_string();
let ca_cert_pem = generate_private_ca_tls_material(&tls_dir, &target_host)
.await
.map_err(|err| std::io::Error::other(format!("generate private CA TLS material failed: {err}")))?;
start_https_rustfs_server(&mut target_env, &tls_dir)
.await
.map_err(|err| std::io::Error::other(format!("start target HTTPS server failed: {err}")))?;
let https_client =
trusted_https_client(&ca_cert_pem).map_err(|err| std::io::Error::other(format!("build HTTPS client failed: {err}")))?;
wait_for_https_server_ready(&https_client, &target_env)
.await
.map_err(|err| std::io::Error::other(format!("wait for target HTTPS server ready failed: {err}")))?;
let source_bucket = "replication-private-ca-src";
let target_bucket = "replication-private-ca-dst";
let source_client = source_env.create_s3_client();
source_client
.create_bucket()
.bucket(source_bucket)
.send()
.await
.map_err(|err| std::io::Error::other(format!("create source bucket failed: {err}")))?;
enable_bucket_versioning(&source_env, source_bucket)
.await
.map_err(|err| std::io::Error::other(format!("enable source bucket versioning failed: {err}")))?;
ensure_https_bucket_exists(&https_client, &target_env, target_bucket)
.await
.map_err(|err| std::io::Error::other(format!("create target HTTPS bucket failed: {err}")))?;
enable_bucket_versioning_over_https(&https_client, &target_env, target_bucket)
.await
.map_err(|err| std::io::Error::other(format!("enable target HTTPS bucket versioning failed: {err}")))?;
let err = set_replication_target_with_options(
&source_env,
source_bucket,
ReplicationTargetOptions {
endpoint: target_env.url.trim_start_matches("https://"),
access_key: &target_env.access_key,
secret_key: &target_env.secret_key,
target_bucket,
secure: true,
skip_tls_verify: false,
ca_cert_pem: None,
},
)
.await
.expect_err("private CA HTTPS target should fail without caCertPem");
let err = err.to_string();
assert!(err.contains("400 Bad Request"), "unexpected private CA target setup error: {err}");
assert!(err.contains("InvalidRequest"), "unexpected private CA target setup error: {err}");
assert!(
err.to_ascii_lowercase().contains("certificate") || err.to_ascii_lowercase().contains("tls"),
"unexpected private CA target setup error: {err}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_allows_private_ca_https_target_with_ca_cert_pem() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = new_replication_source_env()
.await
.map_err(|err| std::io::Error::other(format!("create source env failed: {err}")))?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await
.map_err(|err| std::io::Error::other(format!("start source HTTP server failed: {err}")))?;
let mut target_env = new_replication_https_target_env()
.await
.map_err(|err| std::io::Error::other(format!("create target env failed: {err}")))?;
let tls_dir = std::path::PathBuf::from(&target_env.temp_dir).join("tls");
let target_host = target_env
.url
.trim_start_matches("https://")
.split(':')
.next()
.ok_or_else(|| std::io::Error::other("target HTTPS URL missing host"))?
.to_string();
let ca_cert_pem = generate_private_ca_tls_material(&tls_dir, &target_host)
.await
.map_err(|err| std::io::Error::other(format!("generate private CA TLS material failed: {err}")))?;
start_https_rustfs_server(&mut target_env, &tls_dir)
.await
.map_err(|err| std::io::Error::other(format!("start target HTTPS server failed: {err}")))?;
let https_client =
trusted_https_client(&ca_cert_pem).map_err(|err| std::io::Error::other(format!("build HTTPS client failed: {err}")))?;
wait_for_https_server_ready(&https_client, &target_env)
.await
.map_err(|err| std::io::Error::other(format!("wait for target HTTPS server ready failed: {err}")))?;
let source_bucket = "replication-private-ca-ok-src";
let target_bucket = "replication-private-ca-ok-dst";
let object_key = "private-ca-replication.txt";
let body = "replication over private ca https should succeed";
let source_client = source_env.create_s3_client();
source_client
.create_bucket()
.bucket(source_bucket)
.send()
.await
.map_err(|err| std::io::Error::other(format!("create source bucket failed: {err}")))?;
enable_bucket_versioning(&source_env, source_bucket)
.await
.map_err(|err| std::io::Error::other(format!("enable source bucket versioning failed: {err}")))?;
ensure_https_bucket_exists(&https_client, &target_env, target_bucket)
.await
.map_err(|err| std::io::Error::other(format!("create target HTTPS bucket failed: {err}")))?;
enable_bucket_versioning_over_https(&https_client, &target_env, target_bucket)
.await
.map_err(|err| std::io::Error::other(format!("enable target HTTPS bucket versioning failed: {err}")))?;
let target_arn = set_replication_target_with_options(
&source_env,
source_bucket,
ReplicationTargetOptions {
endpoint: target_env.url.trim_start_matches("https://"),
access_key: &target_env.access_key,
secret_key: &target_env.secret_key,
target_bucket,
secure: true,
skip_tls_verify: false,
ca_cert_pem: Some(&ca_cert_pem),
},
)
.await?;
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
let response = run_replication_check(&source_env, source_bucket).await?;
assert_eq!(response.status(), StatusCode::OK);
source_client
.put_object()
.bucket(source_bucket)
.key(object_key)
.body(ByteStream::from(body.as_bytes().to_vec()))
.send()
.await?;
wait_for_replicated_object_over_https(&https_client, &target_env, target_bucket, object_key, body).await?;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_list_remote_targets_rejects_empty_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let response = list_replication_targets_request(&env, Some("")).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("bucket is required"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_list_remote_targets_rejects_invalid_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let response = list_replication_targets_request(&env, Some("missing-replication-target-bucket")).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("NoSuchBucket"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_remove_remote_target_rejects_missing_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let bucket = "replication-remove-missing-target";
let target_bucket = "replication-remove-missing-target-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let arn = set_replication_target(&source_env, bucket, &target_env, target_bucket).await?;
let first_remove = remove_replication_target(&source_env, bucket, &arn).await?;
assert_eq!(first_remove.status(), StatusCode::NO_CONTENT);
let response = remove_replication_target(&source_env, bucket, &arn).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("not found"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_remove_remote_target_rejects_missing_arn() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let bucket = "replication-remove-missing-arn";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&env, bucket).await?;
let response = remove_replication_target_request(&env, Some(bucket), None).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("arn is required"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_remove_remote_target_rejects_invalid_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let response = remove_replication_target_request(
&env,
Some("missing-replication-remove-bucket"),
Some("arn:aws:s3:us-east-1:123456789012:replication::missing"),
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("NoSuchBucket"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_remove_remote_target_rejects_target_used_by_replication() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (source_env, _target_env, source_bucket) = build_replication_pair(true).await?;
let targets_url = format!(
"{}/rustfs/admin/v3/list-remote-targets?bucket={}",
source_env.url,
urlencoding::encode(&source_bucket)
);
let targets_response = signed_request(
http::Method::GET,
&targets_url,
&source_env.access_key,
&source_env.secret_key,
None,
None,
)
.await?;
assert_eq!(targets_response.status(), StatusCode::OK);
let targets: Vec<serde_json::Value> = targets_response.json().await?;
let arn = targets
.first()
.and_then(|target| target.get("arn"))
.and_then(|arn| arn.as_str())
.ok_or("replication target arn missing")?
.to_string();
let response = remove_replication_target(&source_env, &source_bucket, &arn).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("removal disallowed"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_delete_bucket_replication_removes_remote_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-delete-config-src";
let target_bucket = "replication-delete-config-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
let delete_response = delete_bucket_replication(&source_env, source_bucket).await?;
assert!(
delete_response.status().is_success(),
"unexpected delete status: {}",
delete_response.status()
);
let targets_response = list_replication_targets_request(&source_env, Some(source_bucket)).await?;
assert_eq!(targets_response.status(), StatusCode::OK);
let targets: Vec<serde_json::Value> = targets_response.json().await?;
assert!(
targets
.iter()
.all(|target| target.get("arn").and_then(|arn| arn.as_str()) != Some(target_arn.as_str())),
"deleted replication config left stale target {target_arn}: {targets:?}"
);
let recreated_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication(&source_env, source_bucket, &recreated_arn).await?;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_bucket_replication_replicates_put_object_issue_2539() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "issue-2539-src";
let target_bucket = "issue-2539-dst";
let object_key = "put-object.txt";
let body = "bucket replication should copy PutObject payload";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
source_client
.put_object()
.bucket(source_bucket)
.key(object_key)
.body(ByteStream::from(body.as_bytes().to_vec()))
.send()
.await?;
wait_for_replicated_object(&target_client, target_bucket, object_key, body).await?;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_bucket_replication_converges_delete_marker_and_version_purge() -> TestResult {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
let mut source_env_vars = replication_fast_env();
source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-delete-state-src";
let target_bucket = "replication-delete-state-dst";
let object_key = "versioned-object.txt";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication_with_delete_statuses(&source_env, source_bucket, &target_arn, "Enabled", Some("Enabled")).await?;
let first_put = source_client
.put_object()
.bucket(source_bucket)
.key(object_key)
.body(ByteStream::from_static(b"versioned replication payload v1"))
.send()
.await?;
let purged_version_id = first_put
.version_id()
.ok_or("first source PUT omitted version ID")?
.to_string();
let second_put = source_client
.put_object()
.bucket(source_bucket)
.key(object_key)
.body(ByteStream::from_static(b"versioned replication payload v2"))
.send()
.await?;
let retained_version_id = second_put
.version_id()
.ok_or("second source PUT omitted version ID")?
.to_string();
assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?;
let delete = source_client
.delete_object()
.bucket(source_bucket)
.key(object_key)
.send()
.await?;
let delete_marker_version_id = delete.version_id().ok_or("source DELETE omitted marker version ID")?;
assert_eq!(delete.delete_marker(), Some(true));
assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?;
assert!(
list_replication_state(&target_client, target_bucket)
.await?
.iter()
.any(|entry| entry.delete_marker && entry.version_id == delete_marker_version_id),
"target did not preserve the source delete-marker version ID"
);
source_client
.delete_object()
.bucket(source_bucket)
.key(object_key)
.version_id(&purged_version_id)
.send()
.await?;
assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?;
let target_state = list_replication_state(&target_client, target_bucket).await?;
assert!(
target_state.iter().all(|entry| entry.version_id != purged_version_id),
"target retained the explicitly purged object version"
);
assert!(
target_state
.iter()
.any(|entry| !entry.delete_marker && entry.version_id == retained_version_id),
"target removed the non-selected object version: {target_state:?}"
);
let retained = target_client
.get_object()
.bucket(target_bucket)
.key(object_key)
.version_id(&retained_version_id)
.send()
.await?;
assert_eq!(retained.body.collect().await?.into_bytes().as_ref(), b"versioned replication payload v2");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_bucket_replication_disabled_delete_marker_does_not_propagate() -> TestResult {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
let mut source_env_vars = replication_fast_env();
source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-no-delete-marker-src";
let target_bucket = "replication-no-delete-marker-dst";
let object_key = "retained-object.txt";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication_with_delete_statuses(&source_env, source_bucket, &target_arn, "Disabled", None).await?;
source_client
.put_object()
.bucket(source_bucket)
.key(object_key)
.body(ByteStream::from_static(b"delete-marker replication disabled"))
.send()
.await?;
assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?;
let delete = source_client
.delete_object()
.bucket(source_bucket)
.key(object_key)
.send()
.await?;
let delete_marker_version_id = delete
.version_id()
.ok_or("source DELETE omitted marker version ID")?
.to_string();
let observation_deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
let target_state = list_replication_state(&target_client, target_bucket).await?;
assert!(
target_state.iter().all(|entry| !entry.delete_marker),
"disabled delete-marker replication unexpectedly created a target marker: {target_state:?}"
);
if tokio::time::Instant::now() >= observation_deadline {
break;
}
sleep(Duration::from_millis(100)).await;
}
let retained = target_client
.get_object()
.bucket(target_bucket)
.key(object_key)
.send()
.await?;
assert_eq!(
retained.body.collect().await?.into_bytes().as_ref(),
b"delete-marker replication disabled"
);
source_client
.delete_object()
.bucket(source_bucket)
.key(object_key)
.version_id(delete_marker_version_id)
.send()
.await?;
assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_single_bucket_multipart_replication_fans_out_to_multiple_targets() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
const PART_SIZE: usize = 5 * 1024 * 1024;
const PART_COUNT: usize = 3;
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env_a = RustFSTestEnvironment::new().await?;
target_env_a.start_rustfs_server_without_cleanup(vec![]).await?;
let mut target_env_b = RustFSTestEnvironment::new().await?;
target_env_b.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-multipart-fanout-src";
let target_bucket_a = "replication-multipart-fanout-dst-a";
let target_bucket_b = "replication-multipart-fanout-dst-b";
let object_key = "multipart-fanout.bin";
let source_client = source_env.create_s3_client();
let target_client_a = target_env_a.create_s3_client();
let target_client_b = target_env_b.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client_a.create_bucket().bucket(target_bucket_a).send().await?;
target_client_b.create_bucket().bucket(target_bucket_b).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env_a, target_bucket_a).await?;
enable_bucket_versioning(&target_env_b, target_bucket_b).await?;
let target_arn_a = set_replication_target(&source_env, source_bucket, &target_env_a, target_bucket_a).await?;
let target_arn_b = set_replication_target(&source_env, source_bucket, &target_env_b, target_bucket_b).await?;
put_bucket_replication_rules(&source_env, source_bucket, &[target_arn_a.as_str(), target_arn_b.as_str()]).await?;
let created = source_client
.create_multipart_upload()
.bucket(source_bucket)
.key(object_key)
.send()
.await?;
let upload_id = created.upload_id().ok_or("missing multipart upload id")?.to_string();
let mut completed_parts = Vec::with_capacity(PART_COUNT);
let mut payload = Vec::with_capacity(PART_SIZE * PART_COUNT);
for part_number in 1..=PART_COUNT {
let part = vec![u8::try_from(part_number)?; PART_SIZE];
payload.extend_from_slice(&part);
let uploaded = source_client
.upload_part()
.bucket(source_bucket)
.key(object_key)
.upload_id(&upload_id)
.part_number(i32::try_from(part_number)?)
.body(ByteStream::from(part))
.send()
.await?;
completed_parts.push(
CompletedPart::builder()
.part_number(i32::try_from(part_number)?)
.set_e_tag(uploaded.e_tag().map(str::to_string))
.build(),
);
}
let completed = source_client
.complete_multipart_upload()
.bucket(source_bucket)
.key(object_key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
.send()
.await?;
let source_etag = completed.e_tag().ok_or("completed multipart upload omitted ETag")?;
let multipart_suffix = format!("-{PART_COUNT}");
assert!(
source_etag.trim_matches('"').ends_with(&multipart_suffix),
"unexpected source multipart ETag: {source_etag}"
);
let expected_sha256: [u8; 32] = Sha256::digest(&payload).into();
tokio::try_join!(
wait_for_replicated_sha256(&target_client_a, target_bucket_a, object_key, expected_sha256),
wait_for_replicated_sha256(&target_client_b, target_bucket_b, object_key, expected_sha256),
)?;
let target_etag_a = target_client_a
.head_object()
.bucket(target_bucket_a)
.key(object_key)
.send()
.await?
.e_tag()
.ok_or("first target omitted ETag")?
.to_string();
let target_etag_b = target_client_b
.head_object()
.bucket(target_bucket_b)
.key(object_key)
.send()
.await?
.e_tag()
.ok_or("second target omitted ETag")?
.to_string();
assert_eq!(target_etag_a, source_etag);
assert_eq!(target_etag_b, source_etag);
Ok(())
}
#[tokio::test]
async fn test_repl17_failure_observation_helpers() -> TestResult {
let expected_key = "space + percent%.txt";
let payload = format!(
"{{\"note\":\"é\",\"Records\":[{{\"eventName\":\"{REPLICATION_FAILED_EVENT}\",\"s3\":{{\"object\":{{\"key\":\"space+%2B+percent%25.txt\"}}}}}}]}}\n"
);
let utf8_split = payload.find('é').expect("multibyte fixture must be present") + 1;
let encoded_plus_split = payload.find("%2B").expect("encoded plus must be present") + 1;
let chunks = vec![
Ok::<_, Infallible>(Bytes::copy_from_slice(&payload.as_bytes()[..utf8_split])),
Ok::<_, Infallible>(Bytes::copy_from_slice(&payload.as_bytes()[utf8_split..encoded_plus_split])),
Ok::<_, Infallible>(Bytes::copy_from_slice(&payload.as_bytes()[encoded_plus_split..])),
];
wait_for_replication_failure_event_stream(futures::stream::iter(chunks), expected_key, Duration::from_millis(100)).await?;
let ping_only = futures::stream::unfold((), |_| async {
sleep(Duration::from_millis(5)).await;
Some((Ok::<_, Infallible>(Bytes::from_static(b"{}\n")), ()))
});
let timeout_error = wait_for_replication_failure_event_stream(ping_only, expected_key, Duration::from_millis(25))
.await
.expect_err("ping-only event stream must hit the global deadline");
assert!(timeout_error.to_string().contains("was not received within"));
let mut oversized_line = vec![b'x'; REPLICATION_EVENT_MAX_BUFFER_BYTES];
oversized_line.push(b'\n');
let oversized = futures::stream::once(async { Ok::<_, Infallible>(Bytes::from(oversized_line)) });
let oversized_error = wait_for_replication_failure_event_stream(oversized, expected_key, Duration::from_millis(100))
.await
.expect_err("oversized event lines must be rejected");
assert!(oversized_error.to_string().contains("buffer exceeded 1 MiB"));
let version_output = ListObjectVersionsOutput::builder()
.versions(ObjectVersion::builder().key(expected_key).build())
.build();
assert!(target_history_contains_key(&version_output, expected_key));
let marker_output = ListObjectVersionsOutput::builder()
.delete_markers(DeleteMarkerEntry::builder().key(expected_key).build())
.build();
assert!(target_history_contains_key(&marker_output, expected_key));
assert!(!target_history_contains_key(&marker_output, "other-key"));
Ok(())
}
/// backlog#1147 repl-17: SSE-C currently fails replication explicitly. Pin the
/// observed contract: the source remains decryptable with its customer key,
/// reports FAILED, emits the standard failure event, and leaves no target data.
#[tokio::test]
#[serial]
async fn test_bucket_replication_sse_c_contract() -> TestResult {
init_logging();
let (source_env, target_env, source_bucket, target_bucket) = build_sse_replication_pair("ssec", false).await?;
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
let key = "ssec-contract.txt";
let body = b"repl-17 SSE-C payload";
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
let failure_events = subscribe_to_replication_failure(&source_env, &source_bucket, key).await?;
source_client
.put_object()
.bucket(&source_bucket)
.key(key)
.body(ByteStream::from_static(body))
.sse_customer_algorithm("AES256")
.sse_customer_key(&customer_key)
.sse_customer_key_md5(&customer_key_md5)
.send()
.await?;
let source = source_client
.get_object()
.bucket(&source_bucket)
.key(key)
.sse_customer_algorithm("AES256")
.sse_customer_key(&customer_key)
.sse_customer_key_md5(&customer_key_md5)
.send()
.await?;
assert_eq!(source.body.collect().await?.into_bytes().as_ref(), body);
wait_for_replication_failure_event(failure_events, key).await?;
wait_for_source_replication_status(&source_client, &source_bucket, key, "FAILED", true).await?;
assert_failed_replication_stays_absent_for(
&source_client,
&source_bucket,
&target_client,
&target_bucket,
key,
true,
Duration::from_secs(5),
)
.await?;
target_client
.put_object()
.bucket(&target_bucket)
.key(key)
.body(ByteStream::from_static(b"observer negative-path fixture"))
.send()
.await?;
target_client.delete_object().bucket(&target_bucket).key(key).send().await?;
let history_error = assert_failed_replication_stays_absent_for(
&source_client,
&source_bucket,
&target_client,
&target_bucket,
key,
true,
Duration::ZERO,
)
.await
.expect_err("target history must violate the failed replication contract");
assert!(history_error.to_string().contains("created target history"));
Ok(())
}
/// backlog#1147 repl-17 / backlog#1291: SSE-S3 must fail closed until managed
/// encryption is supported on the target. The current plaintext replication is
/// a known security bug, so this pins the required contract without blessing it.
#[tokio::test]
#[serial]
#[ignore = "backlog#1291: SSE-S3 replication silently drops encryption"]
async fn test_bucket_replication_sse_s3_contract() -> TestResult {
init_logging();
assert_managed_sse_replication_fails_explicitly("sse-s3", false).await
}
/// backlog#1147 repl-17: SSE-KMS currently fails closed rather than creating an
/// unreadable replica; the shared helper verifies FAILED, the failure event,
/// source readability, and a stable absence of all target versions.
#[tokio::test]
#[serial]
async fn test_bucket_replication_sse_kms_failure_contract() -> TestResult {
init_logging();
assert_managed_sse_replication_fails_explicitly("sse-kms", true).await
}
/// backlog#1147 repl-5, scenario (a) — target outage + recovery (rustfs#3421 / #2071).
///
/// Kills the replication target mid-workload, asserts the source records the
/// undelivered objects as PENDING/FAILED, then recovers the target with its data
/// directory intact and asserts both sides converge with no data loss. The
/// still-running source's data scanner (short cycle via [`FAST_SCANNER_ENV`])
/// re-drives the failed objects once the target is reachable again.
#[tokio::test]
#[serial]
async fn test_bucket_replication_recovers_after_target_outage() -> TestResult {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
let mut source_env_vars = replication_fast_env();
source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
source_env_vars.extend_from_slice(FAST_SCANNER_ENV);
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "repl-outage-recovery-src";
let target_bucket = "repl-outage-recovery-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
// Baseline object replicated while the target is healthy; it must remain on
// both sides through the outage (no loss of already-replicated data).
source_client
.put_object()
.bucket(source_bucket)
.key("before-outage.txt")
.body(ByteStream::from_static(b"baseline written before the target outage"))
.send()
.await?;
assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?;
// Target outage: everything written now cannot replicate.
target_env.stop_server();
let outage_keys = ["during-outage-1.txt", "during-outage-2.txt"];
for key in outage_keys {
source_client
.put_object()
.bucket(source_bucket)
.key(key)
.body(ByteStream::from(format!("written while the target was down: {key}").into_bytes()))
.send()
.await?;
}
// The outage is observable on the source: the objects are not yet replicated.
wait_for_source_replication_pending_or_failed(&source_client, source_bucket, outage_keys[0]).await?;
// Recover the target in place; the source scanner re-drives the failures.
target_env.restart_server_preserving_data(vec![], &[]).await?;
assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?;
// Explicit no-loss check: baseline + every outage write reached the target.
let target_state = list_replication_state(&target_client, target_bucket).await?;
for key in ["before-outage.txt"].into_iter().chain(outage_keys) {
assert!(
target_state.iter().any(|entry| entry.key == key && !entry.delete_marker),
"target missing object {key} after outage recovery; state={target_state:?}"
);
}
Ok(())
}
/// backlog#1147 repl-5, scenario (b) — failure state survives a source restart
/// (mirrors backlog#858 delete-decision re-derivation and #859 no-drop).
///
/// Several object writes plus a delete of an already-replicated object all fail
/// while the target is down. The SOURCE is then restarted with the target still
/// unreachable, so replication can only resume from the per-object status
/// persisted in `xl.meta` — nothing in memory survives. Bringing the target back
/// must converge every persisted failure, including the replayed delete marker
/// (whose replication decision is re-derived from the live config).
#[tokio::test]
#[serial]
async fn test_bucket_replication_replays_failed_entries_after_source_restart() -> TestResult {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
let mut source_env_vars = replication_fast_env();
source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
source_env_vars.extend_from_slice(FAST_SCANNER_ENV);
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "repl-source-restart-src";
let target_bucket = "repl-source-restart-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication_with_delete_statuses(&source_env, source_bucket, &target_arn, "Enabled", Some("Enabled")).await?;
// Replicate an object before the outage so its later deletion produces a
// delete marker whose replay decision must be re-derived (backlog#858).
let deleted_key = "deleted-during-outage.txt";
source_client
.put_object()
.bucket(source_bucket)
.key(deleted_key)
.body(ByteStream::from_static(b"exists on both sides before deletion"))
.send()
.await?;
assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?;
// Outage: two fresh objects plus a delete marker all fail to replicate.
target_env.stop_server();
let failed_keys = ["failed-1.txt", "failed-2.txt"];
for key in failed_keys {
source_client
.put_object()
.bucket(source_bucket)
.key(key)
.body(ByteStream::from(
format!("queued for replay while the target was down: {key}").into_bytes(),
))
.send()
.await?;
}
source_client
.delete_object()
.bucket(source_bucket)
.key(deleted_key)
.send()
.await?;
wait_for_source_replication_pending_or_failed(&source_client, source_bucket, failed_keys[0]).await?;
// Restart the SOURCE while the target is still down: recovery must reload the
// failure state from disk, since no in-memory queue survives the restart.
source_env.restart_server_preserving_data(vec![], &source_env_vars).await?;
// Bring the target back; the restarted source re-drives every persisted
// failure to convergence.
target_env.restart_server_preserving_data(vec![], &[]).await?;
assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?;
// No entry dropped across the restart (backlog#859) and the delete marker
// replayed to the target (backlog#858).
let target_state = list_replication_state(&target_client, target_bucket).await?;
for key in failed_keys {
assert!(
target_state.iter().any(|entry| entry.key == key && !entry.delete_marker),
"source-restart replay dropped object {key}; state={target_state:?}"
);
}
assert!(
target_state
.iter()
.any(|entry| entry.key == deleted_key && entry.delete_marker),
"replayed delete marker for {deleted_key} did not reach the target (backlog#858); state={target_state:?}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_bucket_replication_replayed_delete_marker_preserves_source_mtime_without_source_restart() -> TestResult {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
let mut source_env_vars = replication_fast_env();
source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
source_env_vars.extend_from_slice(FAST_SCANNER_ENV);
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "repl-dm-mtime-src";
let target_bucket = "repl-dm-mtime-dst";
let object_key = "delete-marker-mtime.txt";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication_with_delete_statuses(&source_env, source_bucket, &target_arn, "Enabled", Some("Enabled")).await?;
source_client
.put_object()
.bucket(source_bucket)
.key(object_key)
.body(ByteStream::from_static(b"object to be delete-marked during the outage"))
.send()
.await?;
assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?;
// Create the delete marker while the target is down so it replicates through
// the failure-replay path rather than the immediate path. (HEAD on the key
// now returns the delete marker, so the source status is read from the
// version listing instead of head_object.)
target_env.stop_server();
let delete = source_client
.delete_object()
.bucket(source_bucket)
.key(object_key)
.send()
.await?;
assert_eq!(delete.delete_marker(), Some(true));
let source_mtime = delete_marker_last_modified(&source_client, source_bucket, object_key)
.await?
.ok_or("source has no delete marker after DELETE")?;
wait_for_source_delete_marker_replication_failed(&source_env, source_bucket, object_key).await?;
// Widen the gap so a replay-time-stamping regression is unmistakable.
sleep(Duration::from_secs(3)).await;
target_env.restart_server_preserving_data(vec![], &[]).await?;
let target_mtime = wait_for_target_delete_marker(&target_client, target_bucket, object_key).await?;
assert_eq!(
target_mtime, source_mtime,
"replayed delete marker did not preserve the source mtime (backlog#867): source={source_mtime:?}, target={target_mtime:?}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_sequential_bucket_replication_succeeds_for_multiple_buckets() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
for idx in 1..=5 {
let source_bucket = format!("replication-multi-src-{idx}");
let target_bucket = format!("replication-multi-dst-{idx}");
let object_key = format!("probe-{idx}.txt");
let body = format!("payload-{idx}");
source_client.create_bucket().bucket(&source_bucket).send().await?;
target_client.create_bucket().bucket(&target_bucket).send().await?;
enable_bucket_versioning(&source_env, &source_bucket).await?;
enable_bucket_versioning(&target_env, &target_bucket).await?;
let target_arn = set_replication_target(&source_env, &source_bucket, &target_env, &target_bucket).await?;
put_bucket_replication(&source_env, &source_bucket, &target_arn).await?;
source_client
.put_object()
.bucket(&source_bucket)
.key(&object_key)
.body(ByteStream::from(body.clone().into_bytes()))
.send()
.await?;
wait_for_replicated_object(&target_client, &target_bucket, &object_key, &body).await?;
}
Ok(())
}
#[tokio::test]
#[serial]
async fn test_replication_recovers_after_runtime_target_cache_is_cleared() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-refresh-src";
let target_bucket = "replication-refresh-dst";
let object_key = "probe-refresh.txt";
let body = "payload-refresh";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
BucketTargetSys::get().delete(source_bucket).await;
source_client
.put_object()
.bucket(source_bucket)
.key(object_key)
.body(ByteStream::from(body.as_bytes().to_vec()))
.send()
.await?;
wait_for_replicated_object(&target_client, target_bucket, object_key, body).await?;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_site_replication_allows_self_signed_https_with_skip_tls_verify_real_dual_node() -> TestResult {
init_logging();
let mut source_env = new_replication_source_env().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = new_replication_https_target_env().await?;
let tls_dir = std::path::PathBuf::from(&target_env.temp_dir).join("tls");
let target_host = target_env
.url
.trim_start_matches("https://")
.split(':')
.next()
.ok_or_else(|| std::io::Error::other("target HTTPS URL missing host"))?
.to_string();
generate_self_signed_tls_material(&tls_dir, &target_host).await?;
start_https_rustfs_server(&mut target_env, &tls_dir).await?;
let https_client = insecure_https_client()?;
wait_for_https_server_ready(&https_client, &target_env).await?;
let source_site = PeerSite {
name: "source-site".to_string(),
endpoint: source_env.url.clone(),
access_key: source_env.access_key.clone(),
secret_key: source_env.secret_key.clone(),
..Default::default()
};
let target_site = PeerSite {
name: "target-site".to_string(),
endpoint: target_env.url.clone(),
access_key: target_env.access_key.clone(),
secret_key: target_env.secret_key.clone(),
..Default::default()
};
let add_error = site_replication_add(&source_env, &[source_site.clone(), target_site.clone()])
.await
.expect_err("site replication add must reject an untrusted self-signed HTTPS peer");
let add_error = add_error.to_string();
assert_untrusted_site_peer_rejected(&add_error, &target_env.url);
let disabled = wait_for_site_replication_disabled(&source_env).await?;
assert!(!disabled.enabled && disabled.sites.is_empty());
let add_status = site_replication_add(
&source_env,
&[
source_site,
PeerSite {
skip_tls_verify: true,
..target_site
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {add_status:?}");
let _source_info = wait_for_site_replication_enabled(&source_env, 2).await?;
let source_client = source_env.create_s3_client();
let bucket = "site-repl-self-signed-tls";
let key = "self-signed.txt";
let body = "site replication over self-signed https";
source_client.create_bucket().bucket(bucket).send().await?;
source_client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body.as_bytes().to_vec()))
.send()
.await?;
wait_for_replicated_object_over_https(&https_client, &target_env, bucket, key, body).await?;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_site_replication_allows_private_ca_https_with_ca_cert_pem_real_dual_node() -> TestResult {
init_logging();
let mut source_env = new_replication_source_env().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = new_replication_https_target_env().await?;
let tls_dir = std::path::PathBuf::from(&target_env.temp_dir).join("tls");
let target_host = target_env
.url
.trim_start_matches("https://")
.split(':')
.next()
.ok_or_else(|| std::io::Error::other("target HTTPS URL missing host"))?
.to_string();
let ca_cert_pem = generate_private_ca_tls_material(&tls_dir, &target_host).await?;
start_https_rustfs_server(&mut target_env, &tls_dir).await?;
let https_client = trusted_https_client(&ca_cert_pem)?;
wait_for_https_server_ready(&https_client, &target_env).await?;
let source_site = PeerSite {
name: "source-site".to_string(),
endpoint: source_env.url.clone(),
access_key: source_env.access_key.clone(),
secret_key: source_env.secret_key.clone(),
..Default::default()
};
let target_site = PeerSite {
name: "target-site".to_string(),
endpoint: target_env.url.clone(),
access_key: target_env.access_key.clone(),
secret_key: target_env.secret_key.clone(),
..Default::default()
};
let add_error = site_replication_add(&source_env, &[source_site.clone(), target_site.clone()])
.await
.expect_err("site replication add must reject a private CA HTTPS peer without caCertPem");
let add_error = add_error.to_string();
assert_untrusted_site_peer_rejected(&add_error, &target_env.url);
let disabled = wait_for_site_replication_disabled(&source_env).await?;
assert!(!disabled.enabled && disabled.sites.is_empty());
let add_status = site_replication_add(
&source_env,
&[
source_site,
PeerSite {
ca_cert_pem,
..target_site
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {add_status:?}");
let _source_info = wait_for_site_replication_enabled(&source_env, 2).await?;
let source_client = source_env.create_s3_client();
let bucket = "site-repl-private-ca-tls";
let key = "private-ca.txt";
let body = "site replication over private ca https";
source_client.create_bucket().bucket(bucket).send().await?;
source_client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body.as_bytes().to_vec()))
.send()
.await?;
wait_for_replicated_object_over_https(&https_client, &target_env, bucket, key, body).await?;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_site_replication_resync_lifecycle_survives_real_server_restart() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let resync_process_env = [
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", "true"),
// Verbose server logging can block startup when this focused test is run
// through a captured test process rather than nextest.
("RUST_LOG", "error"),
];
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.capture_log_path = Some(format!("{}/server.log", source_env.temp_dir));
source_env.start_rustfs_server_with_env(vec![], &resync_process_env).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.capture_log_path = Some(format!("{}/server.log", target_env.temp_dir));
target_env
.start_rustfs_server_without_cleanup_with_env(&resync_process_env)
.await?;
let source_bucket = "site-repl-resync-src";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
// Seed only the joining site so its synchronous backfill must call the initiating
// site before the add handler has persisted its final enabled state.
target_client.create_bucket().bucket(source_bucket).send().await?;
enable_bucket_versioning(&target_env, source_bucket).await?;
let add_status = site_replication_add(
&source_env,
&[
PeerSite {
name: "source-site".to_string(),
endpoint: source_env.url.clone(),
access_key: source_env.access_key.clone(),
secret_key: source_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "target-site".to_string(),
endpoint: target_env.url.clone(),
access_key: target_env.access_key.clone(),
secret_key: target_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {:?}", add_status);
let source_info = wait_for_site_replication_enabled(&source_env, 2).await?;
let _target_info = wait_for_site_replication_enabled(&target_env, 2).await?;
let remote_peer = source_info
.sites
.into_iter()
.find(|peer| peer.endpoint == target_env.url)
.ok_or("target peer missing from source site replication info")?;
// Wait for the initiating site to accept the join callback and configure the
// backfilled bucket before driving resync in the normal source-to-target direction.
wait_for_bucket_on_target(&source_client, source_bucket).await?;
let target_arn = wait_for_remote_target_arn(&source_env, source_bucket).await?;
for idx in 0..96 {
source_client
.put_object()
.bucket(source_bucket)
.key(format!("resync-object-{idx:02}"))
.body(ByteStream::from(vec![b'x'; 512 * 1024]))
.send()
.await?;
}
let started = site_replication_resync_op(&source_env, "start", &remote_peer).await?;
assert_eq!(started.status, "success", "unexpected start result: {:?}", started);
assert!(
started.buckets.iter().any(|bucket| {
bucket.bucket == source_bucket && matches!(bucket.status.as_str(), "started" | "running" | "completed" | "success")
}),
"source bucket start status missing: {:?}",
started
);
assert!(!started.resync_id.is_empty(), "start response omitted the resync id: {:?}", started);
let started_reset_id = started.resync_id.clone();
assert!(
matches!(started.state.as_str(), "pending" | "running"),
"the fixture must keep the first generation active long enough to test duplicate start: {:?}",
started
);
let duplicate_err = site_replication_resync_op(&source_env, "start", &remote_peer)
.await
.expect_err("duplicate start must be rejected while a generation is active");
assert!(
duplicate_err.to_string().contains("already active"),
"unexpected duplicate start error: {duplicate_err}"
);
let canceled = site_replication_resync_op(&source_env, "cancel", &remote_peer).await?;
assert_eq!(canceled.status, "success", "unexpected cancel result: {:?}", canceled);
assert_eq!(canceled.state, "canceled");
assert!(
canceled
.buckets
.iter()
.any(|bucket| bucket.bucket == source_bucket && matches!(bucket.status.as_str(), "canceled" | "success")),
"source bucket cancel status missing: {:?}",
canceled
);
let canceled_again = site_replication_resync_op(&source_env, "cancel", &remote_peer).await?;
assert_eq!(canceled_again.resync_id, canceled.resync_id, "repeated cancel must be idempotent");
assert_eq!(canceled_again.state, "canceled");
let canceled_target =
wait_for_replication_reset_target(&source_env, source_bucket, &target_arn, |target| target.status == "Canceled").await?;
assert_eq!(canceled_target.reset_id, started_reset_id);
let restarted = site_replication_resync_op(&source_env, "start", &remote_peer).await?;
assert_eq!(restarted.status, "success", "unexpected restart result: {:?}", restarted);
assert_ne!(restarted.resync_id, started_reset_id);
assert!(
matches!(restarted.state.as_str(), "pending" | "running"),
"the second generation must be active before the process restart: {:?}",
restarted
);
let restarted_reset_id = restarted.resync_id.clone();
source_env.restart_server_preserving_data(vec![], &resync_process_env).await?;
wait_for_site_replication_enabled(&source_env, 2).await?;
let after_restart = site_replication_resync_op(&source_env, "status", &remote_peer).await?;
assert_eq!(
after_restart.resync_id, restarted_reset_id,
"server restart changed the durable resync id"
);
assert_eq!(after_restart.generation, restarted.generation);
assert_eq!(after_restart.created_at, restarted.created_at);
assert!(
matches!(after_restart.state.as_str(), "pending" | "running" | "completed" | "failed"),
"unexpected recovered lifecycle state: {:?}",
after_restart
);
assert!(
after_restart.buckets.iter().any(|bucket| bucket.bucket == source_bucket),
"durable status lost the source bucket after restart: {:?}",
after_restart
);
let restart_snapshot = get_replication_reset_status(&source_env, source_bucket, &target_arn).await?;
let restarted_target = wait_for_replication_reset_target(&source_env, source_bucket, &target_arn, |target| {
target.reset_id == restarted_reset_id
})
.await
.map_err(|err| {
format!(
"restart ids: start={} restart={} snapshot={:?}; {err}",
started_reset_id, restarted_reset_id, restart_snapshot.targets
)
})?;
assert_eq!(restarted_target.reset_id, restarted_reset_id);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_site_replication_edit_and_status_peer_state_real_three_node() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut relay_env = RustFSTestEnvironment::new().await?;
relay_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
let relay_client = relay_env.create_s3_client();
let bucket = "site-repl-edit-endpoint";
let baseline_key = "before-edit.txt";
let baseline_payload = b"site replication before endpoint edit".to_vec();
let moved_key = "after-edit.txt";
let moved_payload = b"site replication after endpoint edit".to_vec();
let relayed_key = "after-edit-from-relay.txt";
let relayed_payload = b"site replication after endpoint edit from relay".to_vec();
let add_status = site_replication_add(
&source_env,
&[
PeerSite {
name: "source-site".to_string(),
endpoint: source_env.url.clone(),
access_key: source_env.access_key.clone(),
secret_key: source_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "target-site".to_string(),
endpoint: target_env.url.clone(),
access_key: target_env.access_key.clone(),
secret_key: target_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "relay-site".to_string(),
endpoint: relay_env.url.clone(),
access_key: relay_env.access_key.clone(),
secret_key: relay_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {:?}", add_status);
let source_info = wait_for_site_replication_enabled(&source_env, 3).await?;
let _target_info = wait_for_site_replication_enabled(&target_env, 3).await?;
let _relay_info = wait_for_site_replication_enabled(&relay_env, 3).await?;
let mut remote_peer = source_info
.sites
.into_iter()
.find(|peer| peer.endpoint == target_env.url)
.ok_or("target peer missing from source site replication info")?;
source_client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&source_env, bucket).await?;
wait_for_bucket_on_target(&target_client, bucket).await?;
wait_for_bucket_on_target(&relay_client, bucket).await?;
source_client
.put_object()
.bucket(bucket)
.key(baseline_key)
.body(ByteStream::from(baseline_payload.clone()))
.send()
.await?;
let replicated_baseline = wait_for_object_on_target(&target_client, bucket, baseline_key).await?;
assert_eq!(replicated_baseline, baseline_payload);
let old_target_address = target_env.address.clone();
let new_target_port = RustFSTestEnvironment::find_available_port().await?;
let new_target_address = format!("127.0.0.1:{new_target_port}");
let new_target_url = format!("http://{new_target_address}");
remote_peer.sync_state = SyncStatus::Enable;
remote_peer.endpoint = new_target_url.clone();
let edit_status = site_replication_edit(&source_env, "", &remote_peer).await?;
assert!(edit_status.success, "unexpected site edit result: {:?}", edit_status);
let source_after_sync = wait_for_site_replication_info(&source_env, |info| {
info.sites
.iter()
.any(|peer| peer.endpoint == new_target_url && peer.sync_state == SyncStatus::Enable)
})
.await?;
let target_after_sync = wait_for_site_replication_info(&target_env, |info| {
info.sites
.iter()
.any(|peer| peer.endpoint == new_target_url && peer.sync_state == SyncStatus::Enable)
})
.await?;
let relay_after_sync = wait_for_site_replication_info(&relay_env, |info| {
info.sites
.iter()
.any(|peer| peer.endpoint == new_target_url && peer.sync_state == SyncStatus::Enable)
})
.await?;
assert!(
source_after_sync
.sites
.iter()
.any(|peer| peer.endpoint == new_target_url && peer.sync_state == SyncStatus::Enable)
);
assert!(
target_after_sync
.sites
.iter()
.any(|peer| peer.endpoint == new_target_url && peer.sync_state == SyncStatus::Enable)
);
assert!(
relay_after_sync
.sites
.iter()
.any(|peer| peer.endpoint == new_target_url && peer.sync_state == SyncStatus::Enable)
);
assert_eq!(relay_after_sync.sites.len(), 3);
for (env, unchanged_endpoint) in [(&source_env, &relay_env.address), (&relay_env, &source_env.address)] {
let mut endpoints_replaced = false;
let mut last_targets = Vec::new();
for _ in 0..40 {
let response = list_replication_targets_request(env, Some(bucket)).await?;
if response.status() == StatusCode::OK {
let targets: Vec<serde_json::Value> = response.json().await?;
let mut endpoints = targets
.iter()
.filter_map(|target| target.get("endpoint").and_then(|endpoint| endpoint.as_str()))
.collect::<Vec<_>>();
endpoints.sort_unstable();
let mut expected = vec![new_target_address.as_str(), unchanged_endpoint.as_str()];
expected.sort_unstable();
endpoints_replaced = endpoints == expected;
last_targets = targets;
if endpoints_replaced {
break;
}
}
sleep(Duration::from_millis(250)).await;
}
assert!(
endpoints_replaced,
"site edit did not replace the bucket target endpoint on {}: {last_targets:?}",
env.address
);
}
target_env.stop_server();
let old_endpoint_listener = tokio::net::TcpListener::bind(&old_target_address).await?;
target_env.address = new_target_address;
target_env.url = new_target_url.clone();
target_env
.start_rustfs_server_without_cleanup_with_env(LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let moved_target_client = target_env.create_s3_client();
let ilm_edit_status = site_replication_edit(&source_env, "enableILMExpiryReplication=true", &PeerInfo::default()).await?;
assert!(ilm_edit_status.success, "unexpected ilm edit result: {:?}", ilm_edit_status);
let source_after_ilm = wait_for_site_replication_info(&source_env, |info| {
info.sites.len() == 3 && info.sites.iter().all(|peer| peer.replicate_ilm_expiry)
})
.await?;
let target_after_ilm = wait_for_site_replication_info(&target_env, |info| {
info.sites.len() == 3 && info.sites.iter().all(|peer| peer.replicate_ilm_expiry)
})
.await?;
assert!(source_after_ilm.sites.iter().all(|peer| peer.replicate_ilm_expiry));
assert!(target_after_ilm.sites.iter().all(|peer| peer.replicate_ilm_expiry));
let status_query = "peer-state=true";
let source_status = wait_for_site_replication_status(&source_env, status_query, |status| {
status.peer_states.len() == 3
&& status
.peer_states
.values()
.all(|state| state.peers.len() == 3 && state.peers.values().all(|peer| peer.replicate_ilm_expiry))
})
.await?;
let target_status = wait_for_site_replication_status(&target_env, status_query, |status| {
status.peer_states.len() == 3
&& status
.peer_states
.values()
.all(|state| state.peers.len() == 3 && state.peers.values().all(|peer| peer.replicate_ilm_expiry))
})
.await?;
assert_eq!(source_status.peer_states.len(), 3);
assert_eq!(target_status.peer_states.len(), 3);
assert!(source_status.peer_states.values().all(|state| state.peers.len() == 3));
assert!(target_status.peer_states.values().all(|state| state.peers.len() == 3));
assert!(
source_status
.peer_states
.values()
.all(|state| state.peers.values().all(|peer| peer.replicate_ilm_expiry))
);
assert!(
target_status
.peer_states
.values()
.all(|state| state.peers.values().all(|peer| peer.replicate_ilm_expiry))
);
source_client
.put_object()
.bucket(bucket)
.key(moved_key)
.body(ByteStream::from(moved_payload.clone()))
.send()
.await?;
relay_client
.put_object()
.bucket(bucket)
.key(relayed_key)
.body(ByteStream::from(relayed_payload.clone()))
.send()
.await?;
let no_old_endpoint_connection = async {
match tokio::time::timeout(Duration::from_secs(3), old_endpoint_listener.accept()).await {
Err(_) => Ok::<(), Box<dyn Error + Send + Sync>>(()),
Ok(Ok((_, peer_address))) => Err(format!("source contacted the old site endpoint from {peer_address}").into()),
Ok(Err(err)) => Err(err.into()),
}
};
let (replicated_after_edit, replicated_from_relay, ()) = tokio::try_join!(
wait_for_object_on_target(&moved_target_client, bucket, moved_key),
wait_for_object_on_target(&moved_target_client, bucket, relayed_key),
no_old_endpoint_connection,
)?;
assert_eq!(replicated_after_edit, moved_payload);
assert_eq!(replicated_from_relay, relayed_payload);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_site_replication_remove_all_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env
.start_rustfs_server_without_cleanup_with_env(LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
let bucket = "site-repl-remove-all";
let baseline_key = "before-remove.txt";
let baseline_payload = b"site replication before remove all".to_vec();
let post_remove_key = "after-remove.txt";
let racing_bucket = "site-repl-remove-racing-create";
let add_status = site_replication_add(
&source_env,
&[
PeerSite {
name: "source-site".to_string(),
endpoint: source_env.url.clone(),
access_key: source_env.access_key.clone(),
secret_key: source_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "target-site".to_string(),
endpoint: target_env.url.clone(),
access_key: target_env.access_key.clone(),
secret_key: target_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {:?}", add_status);
let _source_info = wait_for_site_replication_enabled(&source_env, 2).await?;
let _target_info = wait_for_site_replication_enabled(&target_env, 2).await?;
source_client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&source_env, bucket).await?;
wait_for_bucket_on_target(&target_client, bucket).await?;
source_client
.put_object()
.bucket(bucket)
.key(baseline_key)
.body(ByteStream::from(baseline_payload.clone()))
.send()
.await?;
let replicated_baseline = wait_for_object_on_target(&target_client, bucket, baseline_key).await?;
assert_eq!(replicated_baseline, baseline_payload);
let create_racing_bucket = source_client.create_bucket().bucket(racing_bucket).send();
let remove_req = SRRemoveReq {
remove_all: true,
..Default::default()
};
let remove_all = site_replication_remove(&source_env, &remove_req);
let (create_result, remove_status) = tokio::join!(create_racing_bucket, remove_all);
create_result?;
let remove_status = remove_status?;
assert!(
!remove_status.status.is_empty() && remove_status.err_detail.is_empty(),
"unexpected site remove result: {:?}",
remove_status
);
let source_after_remove = wait_for_site_replication_disabled(&source_env).await?;
let target_after_remove = wait_for_site_replication_disabled(&target_env).await?;
assert!(!source_after_remove.enabled);
assert!(source_after_remove.sites.is_empty());
assert!(!target_after_remove.enabled);
assert!(target_after_remove.sites.is_empty());
source_client.head_bucket().bucket(bucket).send().await?;
source_client.head_bucket().bucket(racing_bucket).send().await?;
target_client.head_bucket().bucket(bucket).send().await?;
assert_site_replication_bucket_detached(&source_env, bucket).await?;
assert_site_replication_bucket_detached(&source_env, racing_bucket).await?;
match target_client.head_bucket().bucket(racing_bucket).send().await {
Ok(_) => assert_site_replication_bucket_detached(&target_env, racing_bucket).await?,
Err(err) if matches!(err.code(), Some("NoSuchBucket" | "NotFound")) => {}
Err(err) => return Err(err.into()),
}
assert_site_replication_bucket_detached(&target_env, bucket).await?;
source_client
.put_object()
.bucket(bucket)
.key(post_remove_key)
.body(ByteStream::from_static(b"site replication must stay stopped"))
.send()
.await?;
let absence_deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
match target_client.get_object().bucket(bucket).key(post_remove_key).send().await {
Ok(_) => return Err("object reached the removed site replication target".into()),
Err(err) if matches!(err.code(), Some("NoSuchKey" | "NotFound" | "NoSuchVersion")) => {
if tokio::time::Instant::now() >= absence_deadline {
break;
}
sleep(Duration::from_millis(250)).await;
}
Err(err) => return Err(err.into()),
}
}
Ok(())
}
#[tokio::test]
#[serial]
async fn test_site_replication_state_edit_fresh_and_stale_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env
.start_rustfs_server_without_cleanup_with_env(LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let add_status = site_replication_add(
&source_env,
&[
PeerSite {
name: "source-site".to_string(),
endpoint: source_env.url.clone(),
access_key: source_env.access_key.clone(),
secret_key: source_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "target-site".to_string(),
endpoint: target_env.url.clone(),
access_key: target_env.access_key.clone(),
secret_key: target_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {:?}", add_status);
let source_info = wait_for_site_replication_enabled(&source_env, 2).await?;
let target_info = wait_for_site_replication_enabled(&target_env, 2).await?;
assert!(source_info.sites.iter().all(|peer| !peer.replicate_ilm_expiry));
assert!(target_info.sites.iter().all(|peer| !peer.replicate_ilm_expiry));
let target_status =
wait_for_site_replication_status(&target_env, "peer-state=true", |status| status.peer_states.len() == 2).await?;
let current_updated_at = target_status
.peer_states
.values()
.find_map(|state| state.updated_at)
.ok_or("missing target site replication updated_at")?;
let mut stale_peers = BTreeMap::new();
for peer in target_info.sites {
let mut peer = peer;
peer.replicate_ilm_expiry = true;
stale_peers.insert(peer.deployment_id.clone(), peer);
}
site_replication_state_edit(
&target_env,
&rustfs_madmin::SRStateEditReq {
peers: stale_peers,
updated_at: Some(current_updated_at - TimeDuration::seconds(1)),
},
)
.await?;
let target_after_stale = site_replication_info(&target_env).await?;
let source_after_stale = site_replication_info(&source_env).await?;
assert!(target_after_stale.sites.iter().all(|peer| !peer.replicate_ilm_expiry));
assert!(source_after_stale.sites.iter().all(|peer| !peer.replicate_ilm_expiry));
let mut fresh_peers = BTreeMap::new();
for peer in target_after_stale.sites {
let mut peer = peer;
peer.replicate_ilm_expiry = true;
fresh_peers.insert(peer.deployment_id.clone(), peer);
}
let fresh_updated_at = current_updated_at + TimeDuration::seconds(1);
site_replication_state_edit(
&target_env,
&rustfs_madmin::SRStateEditReq {
peers: fresh_peers,
updated_at: Some(fresh_updated_at),
},
)
.await?;
let target_after_fresh = wait_for_site_replication_info(&target_env, |info| {
info.sites.len() == 2 && info.sites.iter().all(|peer| peer.replicate_ilm_expiry)
})
.await?;
assert!(target_after_fresh.sites.iter().all(|peer| peer.replicate_ilm_expiry));
let target_status_after_fresh = wait_for_site_replication_status(&target_env, "peer-state=true", |status| {
status.peer_states.len() == 2
&& status.peer_states.values().all(|state| {
state.updated_at == Some(fresh_updated_at) && state.peers.values().all(|peer| peer.replicate_ilm_expiry)
})
})
.await?;
assert!(target_status_after_fresh.peer_states.values().all(|state| {
state.updated_at == Some(fresh_updated_at) && state.peers.values().all(|peer| peer.replicate_ilm_expiry)
}));
let source_after_fresh = site_replication_info(&source_env).await?;
assert!(source_after_fresh.sites.iter().all(|peer| !peer.replicate_ilm_expiry));
Ok(())
}
#[tokio::test]
#[serial]
async fn test_site_replication_replicates_object_with_bucket_versioning_real_dual_node() -> TestResult {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env
.start_rustfs_server_without_cleanup_with_env(LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
let bucket = "site-repl-versioned";
let key = "hello.txt";
let payload = b"site replication should replicate after enabling versioning".to_vec();
let add_status = site_replication_add(
&source_env,
&[
PeerSite {
name: "source-site".to_string(),
endpoint: source_env.url.clone(),
access_key: source_env.access_key.clone(),
secret_key: source_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "target-site".to_string(),
endpoint: target_env.url.clone(),
access_key: target_env.access_key.clone(),
secret_key: target_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {:?}", add_status);
let _source_info = wait_for_site_replication_enabled(&source_env, 2).await?;
let _target_info = wait_for_site_replication_enabled(&target_env, 2).await?;
source_client.create_bucket().bucket(bucket).send().await?;
let versioning = source_client.get_bucket_versioning().bucket(bucket).send().await?;
assert_eq!(
versioning.status(),
Some(&BucketVersioningStatus::Enabled),
"site replication did not enable source bucket versioning"
);
let replication_response = signed_request(
http::Method::GET,
&format!("{}/{bucket}?replication", source_env.url),
&source_env.access_key,
&source_env.secret_key,
None,
None,
)
.await?;
let replication_status = replication_response.status();
let replication_body = replication_response.text().await.unwrap_or_default();
assert_eq!(
replication_status,
StatusCode::OK,
"source bucket replication config missing after site replication setup: {replication_body}"
);
source_client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(payload.clone()))
.send()
.await?;
let replicated = wait_for_object_on_target(&target_client, bucket, key).await?;
assert_eq!(replicated, payload);
Ok(())
}
/// Re-applying a site's own replication config must not disable the peer's reverse direction.
///
/// `PutBucketReplication` broadcasts the config to every peer — the console's replication
/// Save button, `mc replicate import`, and a bucket-metadata import all go through it. The
/// receiver used to overwrite its rules with the sender's, whose destination ARN names the
/// receiver itself. No bucket target can satisfy that ARN, so every object written on the
/// receiver was dropped with only a debug line, while `replicate status` still reported
/// "1/1 Buckets in sync" because both configs were byte-identical.
#[tokio::test]
#[serial]
async fn test_site_replication_config_broadcast_keeps_reverse_direction_real_dual_node() -> TestResult {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env
.start_rustfs_server_without_cleanup_with_env(LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
let bucket = "site-repl-config-broadcast";
let add_status = site_replication_add(
&source_env,
&[
PeerSite {
name: "broadcast-source".to_string(),
endpoint: source_env.url.clone(),
access_key: source_env.access_key.clone(),
secret_key: source_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "broadcast-target".to_string(),
endpoint: target_env.url.clone(),
access_key: target_env.access_key.clone(),
secret_key: target_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {add_status:?}");
wait_for_site_replication_enabled(&source_env, 2).await?;
wait_for_site_replication_enabled(&target_env, 2).await?;
source_client.create_bucket().bucket(bucket).send().await?;
wait_for_bucket_on_target(&target_client, bucket).await?;
// Both directions work before the broadcast.
source_client
.put_object()
.bucket(bucket)
.key("from-source.txt")
.body(ByteStream::from_static(b"written on the initiating site"))
.send()
.await?;
assert_eq!(
wait_for_object_on_target(&target_client, bucket, "from-source.txt").await?,
b"written on the initiating site".to_vec(),
);
target_client
.put_object()
.bucket(bucket)
.key("from-target.txt")
.body(ByteStream::from_static(b"written on the joined site"))
.send()
.await?;
assert_eq!(
wait_for_object_on_target(&source_client, bucket, "from-target.txt").await?,
b"written on the joined site".to_vec(),
);
// Round-trip the source's own config through PutBucketReplication, exactly what the
// console does when an operator opens the bucket's replication page and saves it.
let source_config = source_client
.get_bucket_replication()
.bucket(bucket)
.send()
.await?
.replication_configuration
.ok_or("source bucket has no replication configuration")?;
source_client
.put_bucket_replication()
.bucket(bucket)
.replication_configuration(source_config)
.send()
.await?;
let target_config = wait_for_site_replication_rule(&target_client, bucket).await?;
let target_deployment_id = site_replication_info(&target_env)
.await?
.sites
.iter()
.find(|peer| peer.endpoint == target_env.url)
.map(|peer| peer.deployment_id.clone())
.ok_or("joined site missing from its own replication info")?;
for rule in &target_config.rules {
let destination = rule
.destination
.as_ref()
.map(|destination| destination.bucket.as_str())
.unwrap_or_default();
assert!(
!destination.contains(&target_deployment_id),
"joined site adopted a rule pointing at itself: {destination}"
);
}
target_client
.put_object()
.bucket(bucket)
.key("from-target-after-broadcast.txt")
.body(ByteStream::from_static(b"written after the config broadcast"))
.send()
.await?;
assert_eq!(
wait_for_object_on_target(&source_client, bucket, "from-target-after-broadcast.txt").await?,
b"written after the config broadcast".to_vec(),
"config broadcast made replication one-directional"
);
Ok(())
}
async fn wait_for_site_replication_rule(
client: &aws_sdk_s3::Client,
bucket: &str,
) -> Result<aws_sdk_s3::types::ReplicationConfiguration, Box<dyn Error + Send + Sync>> {
for _ in 0..40 {
if let Ok(response) = client.get_bucket_replication().bucket(bucket).send().await
&& let Some(config) = response.replication_configuration
&& !config.rules.is_empty()
{
return Ok(config);
}
sleep(Duration::from_millis(250)).await;
}
Err(format!("bucket {bucket} never reported a replication rule").into())
}
#[tokio::test]
#[serial]
async fn test_site_replication_active_active_converges_without_loops_real_dual_node() -> TestResult {
init_logging();
match tokio::time::timeout(Duration::from_secs(420), async {
let mut site_env = replication_fast_env();
site_env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
let mut site_a_env = RustFSTestEnvironment::new().await?;
site_a_env.start_rustfs_server_with_env(vec![], &site_env).await?;
let mut site_b_env = RustFSTestEnvironment::new().await?;
site_b_env.start_rustfs_server_with_env(vec![], &site_env).await?;
let mut proxy_tasks = JoinSet::new();
let (site_a_proxy, site_a_replication_requests, site_a_replication_enabled) =
start_replication_counting_proxy(&site_a_env.url, &mut proxy_tasks).await?;
let (site_b_proxy, site_b_replication_requests, site_b_replication_enabled) =
start_replication_counting_proxy(&site_b_env.url, &mut proxy_tasks).await?;
let site_a_client = site_a_env.create_s3_client();
let site_b_client = site_b_env.create_s3_client();
let bucket = "site-repl-active-active";
let add_status = site_replication_add(
&site_a_env,
&[
PeerSite {
name: "active-site-a".to_string(),
endpoint: site_a_env.url.clone(),
access_key: site_a_env.access_key.clone(),
secret_key: site_a_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "active-site-b".to_string(),
endpoint: site_b_env.url.clone(),
access_key: site_b_env.access_key.clone(),
secret_key: site_b_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {add_status:?}");
let site_info = wait_for_site_replication_enabled(&site_a_env, 2).await?;
wait_for_site_replication_enabled(&site_b_env, 2).await?;
let mut site_a_peer = site_info
.sites
.iter()
.find(|peer| peer.endpoint == site_a_env.url)
.ok_or("site A peer missing from replication info")?
.clone();
site_a_peer.endpoint = site_a_proxy.clone();
site_a_peer.sync_state = SyncStatus::Enable;
let site_a_edit = site_replication_edit(&site_a_env, "", &site_a_peer).await?;
assert!(site_a_edit.success, "unexpected site A endpoint edit: {site_a_edit:?}");
let mut site_b_peer = site_info
.sites
.iter()
.find(|peer| peer.endpoint == site_b_env.url)
.ok_or("site B peer missing from replication info")?
.clone();
site_b_peer.endpoint = site_b_proxy.clone();
site_b_peer.sync_state = SyncStatus::Enable;
let site_b_edit = site_replication_edit(&site_a_env, "", &site_b_peer).await?;
assert!(site_b_edit.success, "unexpected site B endpoint edit: {site_b_edit:?}");
for env in [&site_a_env, &site_b_env] {
wait_for_site_replication_info(env, |info| {
info.sites.iter().any(|peer| peer.endpoint == site_a_proxy)
&& info.sites.iter().any(|peer| peer.endpoint == site_b_proxy)
})
.await?;
}
site_a_client.create_bucket().bucket(bucket).send().await?;
wait_for_bucket_on_target(&site_b_client, bucket).await?;
let (site_a_put, site_b_put) = tokio::join!(
site_a_client
.put_object()
.bucket(bucket)
.key("from-a.txt")
.body(ByteStream::from_static(b"written on site A"))
.send(),
site_b_client
.put_object()
.bucket(bucket)
.key("from-b.txt")
.body(ByteStream::from_static(b"written on site B"))
.send(),
);
site_a_put?;
site_b_put?;
wait_for_replicated_object(&site_b_client, bucket, "from-a.txt", "written on site A").await?;
wait_for_replicated_object(&site_a_client, bucket, "from-b.txt", "written on site B").await?;
let pre_conflict_counts = (
site_a_replication_requests.load(Ordering::Relaxed),
site_b_replication_requests.load(Ordering::Relaxed),
);
assert_eq!(pre_conflict_counts, (1, 1));
site_a_replication_enabled.send(false)?;
site_b_replication_enabled.send(false)?;
let site_a_conflict_version = site_a_client
.put_object()
.bucket(bucket)
.key("conflict.txt")
.body(ByteStream::from_static(b"conflict from site A"))
.send()
.await?
.version_id()
.ok_or("site A conflict PUT omitted version ID")?
.to_string();
sleep(Duration::from_millis(10)).await;
let site_b_conflict_version = site_b_client
.put_object()
.bucket(bucket)
.key("conflict.txt")
.body(ByteStream::from_static(b"conflict from site B"))
.send()
.await?
.version_id()
.ok_or("site B conflict PUT omitted version ID")?
.to_string();
assert_ne!(site_a_conflict_version, site_b_conflict_version);
tokio::time::timeout(Duration::from_secs(70), async {
loop {
let counts = (
site_a_replication_requests.load(Ordering::Relaxed),
site_b_replication_requests.load(Ordering::Relaxed),
);
assert!(counts.0 <= pre_conflict_counts.0 + 1 && counts.1 <= pre_conflict_counts.1 + 1);
if counts == (pre_conflict_counts.0 + 1, pre_conflict_counts.1 + 1) {
break;
}
sleep(Duration::from_millis(25)).await;
}
})
.await
.map_err(|_| "replication requests did not reach both conflict gates")?;
let site_a_isolated_conflict = list_replication_state(&site_a_client, bucket)
.await?
.into_iter()
.filter(|version| version.key == "conflict.txt")
.collect::<Vec<_>>();
let site_b_isolated_conflict = list_replication_state(&site_b_client, bucket)
.await?
.into_iter()
.filter(|version| version.key == "conflict.txt")
.collect::<Vec<_>>();
assert_eq!(site_a_isolated_conflict.len(), 1);
assert_eq!(site_a_isolated_conflict[0].version_id, site_a_conflict_version);
assert_eq!(site_b_isolated_conflict.len(), 1);
assert_eq!(site_b_isolated_conflict[0].version_id, site_b_conflict_version);
let (api_expected_winner, api_expected_body) = match site_a_isolated_conflict[0]
.last_modified
.cmp(&site_b_isolated_conflict[0].last_modified)
{
std::cmp::Ordering::Greater => (&site_a_conflict_version, b"conflict from site A".as_slice()),
std::cmp::Ordering::Less => (&site_b_conflict_version, b"conflict from site B".as_slice()),
std::cmp::Ordering::Equal => return Err("staggered conflict writes received equal LastModified values".into()),
};
site_a_replication_enabled.send(true)?;
site_b_replication_enabled.send(true)?;
tokio::time::timeout(
Duration::from_secs(70),
assert_replication_converged(&site_a_client, bucket, &site_b_client, bucket),
)
.await??;
for (version_id, expected) in [
(site_a_conflict_version.as_str(), b"conflict from site A".as_slice()),
(site_b_conflict_version.as_str(), b"conflict from site B".as_slice()),
] {
assert_eq!(get_version_body(&site_a_client, bucket, "conflict.txt", version_id).await?, expected);
assert_eq!(get_version_body(&site_b_client, bucket, "conflict.txt", version_id).await?, expected);
}
// Newer LastModified wins; the writes are staggered while replication is
// blocked so this test does not claim a tie-break for equal timestamps.
let site_a_current = site_a_client
.get_object()
.bucket(bucket)
.key("conflict.txt")
.send()
.await?
.body
.collect()
.await?
.into_bytes();
let site_b_current = site_b_client
.get_object()
.bucket(bucket)
.key("conflict.txt")
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(site_a_current, site_b_current);
let observed_winner_version = if site_a_current.as_ref() == b"conflict from site A" {
&site_a_conflict_version
} else if site_a_current.as_ref() == b"conflict from site B" {
&site_b_conflict_version
} else {
return Err(format!("unexpected active-active winner: {site_a_current:?}").into());
};
assert_eq!(site_a_current.as_ref(), api_expected_body);
assert_eq!(observed_winner_version, api_expected_winner);
site_a_client
.put_object()
.bucket(bucket)
.key("deleted.txt")
.body(ByteStream::from_static(b"delete me"))
.send()
.await?;
site_a_client.delete_object().bucket(bucket).key("deleted.txt").send().await?;
tokio::time::timeout(
Duration::from_secs(70),
assert_replication_converged(&site_a_client, bucket, &site_b_client, bucket),
)
.await??;
let site_a_deleted = site_a_client
.get_object()
.bucket(bucket)
.key("deleted.txt")
.send()
.await
.expect_err("site A deleted object unexpectedly rebounded");
let site_b_deleted = site_b_client
.get_object()
.bucket(bucket)
.key("deleted.txt")
.send()
.await
.expect_err("site B deleted object unexpectedly exists");
assert_eq!(site_a_deleted.as_service_error().and_then(|error| error.code()), Some("NoSuchKey"));
assert_eq!(site_b_deleted.as_service_error().and_then(|error| error.code()), Some("NoSuchKey"));
let stable_state = list_replication_state(&site_a_client, bucket).await?;
assert_eq!(stable_state.iter().filter(|version| version.key == "from-a.txt").count(), 1);
assert_eq!(stable_state.iter().filter(|version| version.key == "from-b.txt").count(), 1);
assert_eq!(stable_state.iter().filter(|version| version.key == "conflict.txt").count(), 2);
assert_eq!(
stable_state
.iter()
.filter(|version| version.key == "conflict.txt" && version.is_latest)
.count(),
1
);
assert!(
stable_state.iter().any(|version| version.key == "conflict.txt"
&& version.version_id == *observed_winner_version
&& version.is_latest)
);
assert_eq!(stable_state.iter().filter(|version| version.key == "deleted.txt").count(), 2);
assert_eq!(
stable_state
.iter()
.filter(|version| version.key == "deleted.txt" && version.delete_marker)
.count(),
1
);
assert_eq!(
stable_state
.iter()
.filter(|version| version.key == "deleted.txt" && !version.delete_marker)
.count(),
1
);
assert_eq!(
stable_state
.iter()
.filter(|version| version.key == "deleted.txt" && version.delete_marker && version.is_latest)
.count(),
1
);
assert_eq!(
stable_state
.iter()
.filter(|version| version.key == "deleted.txt" && version.is_latest)
.count(),
1
);
let baseline_counts = (
site_a_replication_requests.load(Ordering::Relaxed),
site_b_replication_requests.load(Ordering::Relaxed),
);
assert_eq!(baseline_counts, (2, 4));
tokio::time::sleep(Duration::from_secs(4)).await;
assert_eq!(
(
site_a_replication_requests.load(Ordering::Relaxed),
site_b_replication_requests.load(Ordering::Relaxed),
),
baseline_counts
);
assert_eq!(list_replication_state(&site_a_client, bucket).await?, stable_state);
assert_eq!(list_replication_state(&site_b_client, bucket).await?, stable_state);
Ok(())
})
.await
{
Ok(result) => result,
Err(_) => Err("active-active replication test timed out".into()),
}
}
#[tokio::test]
#[serial]
async fn test_site_replication_replicates_policy_backed_user_access_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env
.start_rustfs_server_without_cleanup_with_env(LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
let bucket = "site-repl-policy-user";
let key = "seed.txt";
let payload = b"site replication policy-backed user access".to_vec();
let policy_name = "site-repl-readonly";
let username = "site-repl-user";
let secret_key = "site-repl-user-secret-key-123456";
let add_status = site_replication_add(
&source_env,
&[
PeerSite {
name: "source-site".to_string(),
endpoint: source_env.url.clone(),
access_key: source_env.access_key.clone(),
secret_key: source_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "target-site".to_string(),
endpoint: target_env.url.clone(),
access_key: target_env.access_key.clone(),
secret_key: target_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {:?}", add_status);
let _source_info = wait_for_site_replication_enabled(&source_env, 2).await?;
let _target_info = wait_for_site_replication_enabled(&target_env, 2).await?;
source_client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&source_env, bucket).await?;
source_client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(payload.clone()))
.send()
.await?;
let replicated = wait_for_object_on_target(&target_client, bucket, key).await?;
assert_eq!(replicated, payload);
let policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": [format!("arn:aws:s3:::{bucket}/*")]
},
{
"Effect": "Allow",
"Action": ["s3:GetBucketLocation", "s3:ListBucket"],
"Resource": [format!("arn:aws:s3:::{bucket}")]
}
]
});
admin_add_canned_policy(&source_env, policy_name, &policy).await?;
admin_create_user(&source_env, username, secret_key).await?;
admin_attach_policy_to_user(&source_env, policy_name, username).await?;
let target_user_client = create_user_s3_client(&target_env, username, secret_key);
let fetched = wait_for_user_get_object(&target_user_client, bucket, key).await?;
assert_eq!(fetched, payload);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_site_replication_replicates_group_policy_backed_access_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>>
{
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env
.start_rustfs_server_without_cleanup_with_env(LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
let bucket = "site-repl-policy-group";
let key = "seed.txt";
let payload = b"site replication group-policy-backed user access".to_vec();
let policy_name = "site-repl-group-readonly";
let group_name = "site-repl-group";
let username = "site-repl-group-user";
let secret_key = "site-repl-group-user-secret-key-12";
let add_status = site_replication_add(
&source_env,
&[
PeerSite {
name: "source-site".to_string(),
endpoint: source_env.url.clone(),
access_key: source_env.access_key.clone(),
secret_key: source_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "target-site".to_string(),
endpoint: target_env.url.clone(),
access_key: target_env.access_key.clone(),
secret_key: target_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {:?}", add_status);
let _source_info = wait_for_site_replication_enabled(&source_env, 2).await?;
let _target_info = wait_for_site_replication_enabled(&target_env, 2).await?;
source_client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&source_env, bucket).await?;
source_client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(payload.clone()))
.send()
.await?;
let replicated = wait_for_object_on_target(&target_client, bucket, key).await?;
assert_eq!(replicated, payload);
let policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": [format!("arn:aws:s3:::{bucket}/*")]
},
{
"Effect": "Allow",
"Action": ["s3:GetBucketLocation", "s3:ListBucket"],
"Resource": [format!("arn:aws:s3:::{bucket}")]
}
]
});
admin_add_canned_policy(&source_env, policy_name, &policy).await?;
admin_create_user(&source_env, username, secret_key).await?;
admin_update_group_members(&source_env, group_name, &[username]).await?;
admin_attach_policy_to_group(&source_env, policy_name, group_name).await?;
let target_user_client = create_user_s3_client(&target_env, username, secret_key);
let fetched = wait_for_user_get_object(&target_user_client, bucket, key).await?;
assert_eq!(fetched, payload);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_service_account_policy_from_accountinfo_round_trips_real_single_node() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let account_info = get_account_info(&env, &env.access_key, &env.secret_key).await?;
let policy_str = account_info
.get("policy")
.and_then(|value| value.as_str())
.ok_or("account info policy should be a JSON string")?;
let policy: serde_json::Value = serde_json::from_str(policy_str)?;
let statements = policy
.get("Statement")
.and_then(|value| value.as_array())
.ok_or("account info policy should include Statement array")?;
assert!(!statements.is_empty(), "account info policy Statement should not be empty: {policy}");
let req = AddServiceAccountReq {
policy: Some(policy),
target_user: None,
access_key: "svcacct-info-sample".to_string(),
secret_key: "svcacct-info-sample-secret-key-123456".to_string(),
name: Some("svcacct-info-sample".to_string()),
description: Some("service account created from accountinfo sample policy".to_string()),
expiration: None,
comment: None,
};
let created = add_service_account(&env, &env.access_key, &env.secret_key, &req).await?;
assert_eq!(created.0, "svcacct-info-sample");
let listed =
wait_for_service_accounts(&env, &env.access_key, &env.secret_key, Some(&env.access_key), &["svcacct-info-sample"])
.await?;
assert!(
listed
.accounts
.iter()
.any(|account| account.access_key == "svcacct-info-sample"),
"created service account should be listed for parent user: {:?}",
listed.accounts
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_site_replication_replicates_multiple_service_accounts_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env
.start_rustfs_server_without_cleanup_with_env(LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let add_status = site_replication_add(
&source_env,
&[
PeerSite {
name: "source-site".to_string(),
endpoint: source_env.url.clone(),
access_key: source_env.access_key.clone(),
secret_key: source_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "target-site".to_string(),
endpoint: target_env.url.clone(),
access_key: target_env.access_key.clone(),
secret_key: target_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {:?}", add_status);
let _source_info = wait_for_site_replication_enabled(&source_env, 2).await?;
let _target_info = wait_for_site_replication_enabled(&target_env, 2).await?;
let first_req = AddServiceAccountReq {
policy: None,
target_user: None,
access_key: "svc-alpha".to_string(),
secret_key: "svc-alpha-secret-key-1234567890abcdef".to_string(),
name: Some("svc-alpha".to_string()),
description: Some("first replicated service account".to_string()),
expiration: None,
comment: None,
};
let first = add_service_account(&source_env, &source_env.access_key, &source_env.secret_key, &first_req).await?;
let target_after_first = wait_for_service_accounts(
&target_env,
&target_env.access_key,
&target_env.secret_key,
Some(&source_env.access_key),
&["svc-alpha"],
)
.await?;
assert!(
target_after_first
.accounts
.iter()
.any(|account| account.access_key == "svc-alpha"),
"target accounts missing svc-alpha: {:?}",
target_after_first.accounts
);
let second_req = AddServiceAccountReq {
policy: None,
target_user: None,
access_key: "svc-beta".to_string(),
secret_key: "svc-beta-secret-key-1234567890abcdef1".to_string(),
name: Some("svc-beta".to_string()),
description: Some("second replicated service account".to_string()),
expiration: None,
comment: None,
};
let _second = add_service_account(&source_env, &first.0, &first.1, &second_req).await?;
let target_after_second = wait_for_service_accounts(
&target_env,
&target_env.access_key,
&target_env.secret_key,
Some(&source_env.access_key),
&["svc-alpha", "svc-beta"],
)
.await?;
assert!(
target_after_second
.accounts
.iter()
.any(|account| account.access_key == "svc-beta"),
"target accounts missing svc-beta: {:?}",
target_after_second.accounts
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_site_replication_replicates_service_accounts_created_from_sts_session_real_dual_node() -> TestResult {
init_logging();
if !awscurl_available() {
eprintln!("Skipping STS site replication service-account test because awscurl is unavailable");
return Ok(());
}
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env
.start_rustfs_server_without_cleanup_with_env(LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let add_status = site_replication_add(
&source_env,
&[
PeerSite {
name: "source-site".to_string(),
endpoint: source_env.url.clone(),
access_key: source_env.access_key.clone(),
secret_key: source_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "target-site".to_string(),
endpoint: target_env.url.clone(),
access_key: target_env.access_key.clone(),
secret_key: target_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {:?}", add_status);
let _source_info = wait_for_site_replication_enabled(&source_env, 2).await?;
let _target_info = wait_for_site_replication_enabled(&target_env, 2).await?;
let assume_role_body = "Action=AssumeRole&Version=2011-06-15&DurationSeconds=3600";
let sts_xml = awscurl_post_sts_form_urlencoded(
&format!("{}/", source_env.url.trim_end_matches('/')),
assume_role_body,
&source_env.access_key,
&source_env.secret_key,
)
.await?;
let (sts_access_key, sts_secret_key, sts_session_token) = parse_assume_role_credentials(&sts_xml)?;
let first_req = AddServiceAccountReq {
policy: None,
target_user: None,
access_key: "svc-sts-alpha".to_string(),
secret_key: "svc-sts-alpha-secret-key-1234567890".to_string(),
name: Some("svc-sts-alpha".to_string()),
description: Some("sts-created replicated service account".to_string()),
expiration: None,
comment: None,
};
let first =
add_service_account_with_session_token(&source_env, &sts_access_key, &sts_secret_key, &sts_session_token, &first_req)
.await?;
let target_after_first = wait_for_service_accounts(
&target_env,
&target_env.access_key,
&target_env.secret_key,
Some(&source_env.access_key),
&["svc-sts-alpha"],
)
.await?;
assert!(
target_after_first
.accounts
.iter()
.any(|account| account.access_key == "svc-sts-alpha"),
"target accounts missing svc-sts-alpha: {:?}",
target_after_first.accounts
);
let second_req = AddServiceAccountReq {
policy: None,
target_user: None,
access_key: "svc-sts-beta".to_string(),
secret_key: "svc-sts-beta-secret-key-1234567890a".to_string(),
name: Some("svc-sts-beta".to_string()),
description: Some("second replicated service account from sts-created ak".to_string()),
expiration: None,
comment: None,
};
let _second = add_service_account(&source_env, &first.0, &first.1, &second_req).await?;
let target_after_second = wait_for_service_accounts(
&target_env,
&target_env.access_key,
&target_env.secret_key,
Some(&source_env.access_key),
&["svc-sts-alpha", "svc-sts-beta"],
)
.await?;
assert!(
target_after_second
.accounts
.iter()
.any(|account| account.access_key == "svc-sts-beta"),
"target accounts missing svc-sts-beta: {:?}",
target_after_second.accounts
);
Ok(())
}