Compare commits

..
Author SHA1 Message Date
Chris LuandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 27a6a4c6c5 s3: test internal header scrub through the auth-disabled wrapper
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 18:24:36 -07:00
Chris LuandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 9136d2711c s3: scrub internal headers before the auth-enabled early return
Auth and AuthPostPolicy skip authenticateRequestInternal when IAM is
disabled, so client-supplied internal headers still reached putToFiler.
Hoist the scrub into scrubInternalHeaders and run it unconditionally in
both wrappers as well as the shared auth entry point.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 18:23:46 -07:00
Chris LuandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 859f953d48 s3: strip internal SSE headers on auth entry
X-SeaweedFS-SSE-S3-Key-Data, -SSE-S3-Base-IV and -SSE-KMS-Base-IV are read
from the request to drive multipart encryption, so a client could inject
its own key material and IVs. The server sets them itself between handler
stages of a request; drop inbound values in authenticateRequestInternal.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 18:19:47 -07:00
Chris LuandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c953054e9b s3: strip internal object-lock and version headers on auth entry
putToFiler persists Seaweed-X-Amz-Object-Lock-Mode, -Retention-Until-Date,
-Legal-Hold and -Version-Id request headers into authoritative entry
metadata without a permission check, so an s3:PutObject-only writer could
pin COMPLIANCE retention and legal hold on its own uploads, bypassing the
dedicated-permission gate on the standard x-amz-object-lock-* headers.
The server only sets these headers from validated inputs, so drop them in
authenticateRequestInternal alongside the identity headers already
scrubbed there.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 18:19:07 -07:00
162 changed files with 2551 additions and 12481 deletions
+1 -2
View File
@@ -225,7 +225,7 @@ The full operation list is in [Amazon S3 API][AmazonS3API], and [Supported APIs
SeaweedFS is a lakehouse in one system. [S3 Table Buckets][S3TableBucket] hold Apache Iceberg tables by default, or [Lance][LanceCatalog] tables for vectors and multimodal data, and the built-in [Iceberg REST Catalog][IcebergCatalog] and Lance namespace serve them directly. There is no Hive Metastore, Glue, or separate catalog service to deploy, secure, and back up.
* Query engines operate on the same tables at the same time: [Spark][SparkIceberg], [Trino][TrinoIceberg], [Dremio][DremioIceberg], [DuckDB][DuckDBIceberg], [Apache Doris][DorisIceberg], [RisingWave][RisingWaveIceberg], ClickHouse, and [LanceDB][LanceDB]. Catalog commits are atomic compare-and-swap, so concurrent writers are safe. [Lakekeeper][Lakekeeper] can front the same storage with STS-vended credentials. [OLake Go][OLake] is a high-performance, open-source EL (Extract–Load) platform that bridges operational databases and open lakehouse storage - writing Apache Iceberg tables or Parquet files on Amazon S3.
* Query engines operate on the same tables at the same time: [Spark][SparkIceberg], [Trino][TrinoIceberg], [Dremio][DremioIceberg], [DuckDB][DuckDBIceberg], [Apache Doris][DorisIceberg], [RisingWave][RisingWaveIceberg], ClickHouse, and [LanceDB][LanceDB]. Catalog commits are atomic compare-and-swap, so concurrent writers are safe. [Lakekeeper][Lakekeeper] can front the same storage with STS-vended credentials.
* [Automated table maintenance][IcebergMaintenance]: compaction, snapshot expiration, orphan file removal, and manifest rewriting, configured per bucket or table through the S3 Tables maintenance APIs, and the same for [Lance][LanceMaintenance].
* IAM at the bucket, namespace, and table level with standard bucket policies, see [S3 Tables Security][S3TablesSecurity].
* A [Hadoop compatible file system][Hadoop] for Spark, Flink, and HBase.
@@ -487,7 +487,6 @@ Your support will be really appreciated by me and other supporters!
[RisingWaveIceberg]: https://github.com/seaweedfs/seaweedfs/wiki/RisingWave-Iceberg-Integration
[LanceDB]: https://github.com/seaweedfs/seaweedfs/wiki/LanceDB-Integration
[Lakekeeper]: https://github.com/seaweedfs/seaweedfs/wiki/Lakekeeper-Iceberg-Integration
[OLake]: https://github.com/seaweedfs/seaweedfs/wiki/OLake-Go-Iceberg-Integration
[IcebergMaintenance]: https://github.com/seaweedfs/seaweedfs/wiki/Iceberg-Table-Maintenance
[LanceMaintenance]: https://github.com/seaweedfs/seaweedfs/wiki/Lance-Maintenance-Worker
[S3TablesSecurity]: https://github.com/seaweedfs/seaweedfs/wiki/S3-Tables-Security
+1067 -1099
View File
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 54 KiB

+1 -4
View File
@@ -68,10 +68,7 @@ seaweed-volume --port 8080 --master localhost:9333 --dir /data/vol1 --max 7 \
| `--pprof` | `false` | Enable pprof HTTP handlers |
Set `RUST_LOG=debug` (or `trace`, `info`, `warn`) for log level control.
Durable (`fsync=true`) uploads always go through per-volume batching workers
that share one `.dat`/`.idx` sync per batch, matching the Go volume server;
non-fsync writes take the synchronous path.
Set `SEAWEED_WRITE_QUEUE=1` to enable batched async write processing.
## Features
-9
View File
@@ -59,8 +59,6 @@ service Seaweed {
}
rpc RaftLeadershipTransfer (RaftLeadershipTransferRequest) returns (RaftLeadershipTransferResponse) {
}
rpc ResumeVolumeGrowth (ResumeVolumeGrowthRequest) returns (ResumeVolumeGrowthResponse) {
}
rpc VolumeGrow (VolumeGrowRequest) returns (VolumeGrowResponse) {
}
rpc CollectionStatistics (CollectionStatisticsRequest) returns (CollectionStatisticsResponse) {
@@ -326,13 +324,6 @@ message VolumeGrowRequest {
float grow_avg_fill_threshold = 10;
}
message ResumeVolumeGrowthRequest {
}
message ResumeVolumeGrowthResponse {
bool was_frozen = 1;
}
message AssignResponse {
string fid = 1;
uint64 count = 4;
+4
View File
@@ -303,6 +303,8 @@ pub struct VolumeServerConfig {
pub grpc_allowed_wildcard_domain: String,
pub grpc_volume_allowed_common_names: Vec<String>,
pub tls_policy: TlsPolicy,
/// Enable batched write queue for improved throughput under load.
pub enable_write_queue: bool,
/// Path to security.toml — stored for SIGHUP reload.
pub security_file: String,
/// If true, FetchAndWriteNeedle skips remote S3 endpoint validation
@@ -966,6 +968,8 @@ fn resolve_config_with_env(cli: Cli, env: EnvLookup<'_>) -> VolumeServerConfig {
grpc_allowed_wildcard_domain: sec.grpc_allowed_wildcard_domain,
grpc_volume_allowed_common_names: sec.grpc_volume_allowed_common_names,
tls_policy: sec.tls_policy,
enable_write_queue: env_string(env, "SEAWEED_WRITE_QUEUE")
.is_some_and(|v| v == "1" || v == "true"),
security_file: cli.security_file,
allow_untrusted_remote_endpoints: cli.allow_untrusted_remote_endpoints,
}
+6 -5
View File
@@ -433,11 +433,12 @@ async fn run(
}
}
// The write queue is always on: durable (fsync) uploads go through it so
// a batch shares one .dat/.idx sync, the way Go's per-volume async writer
// group-commits. Non-fsync writes stay on the synchronous path.
let wq = WriteQueue::new(state.clone(), 128);
let _ = state.write_queue.set(wq);
// Initialize the batched write queue if enabled
if config.enable_write_queue {
info!("Batched write queue enabled");
let wq = WriteQueue::new(state.clone(), 128);
let _ = state.write_queue.set(wq);
}
// Set initial metric gauges for concurrent limits and max volumes
metrics::CONCURRENT_UPLOAD_LIMIT.set(state.concurrent_upload_limit);
+1
View File
@@ -431,6 +431,7 @@ mod tests {
grpc_allowed_wildcard_domain: String::new(),
grpc_volume_allowed_common_names: vec![],
tls_policy: TlsPolicy::default(),
enable_write_queue: false,
security_file: String::new(),
}
}
+25 -61
View File
@@ -16,7 +16,7 @@ use crate::pb::filer_pb;
use crate::pb::master_pb;
use crate::pb::volume_server_pb;
use crate::pb::volume_server_pb::volume_server_server::VolumeServer;
use crate::storage::erasure_coding::ec_shard::{ShardId, shard_id_try_from};
use crate::storage::erasure_coding::ec_shard::{DATA_SHARDS_COUNT, ShardId, shard_id_try_from};
use crate::storage::erasure_coding::ecj_merge::EcjIdDecoder;
use crate::storage::needle::needle::{self, Needle};
use crate::storage::types::*;
@@ -53,12 +53,6 @@ fn needle_not_found(needle_id: NeedleId) -> Status {
Status::not_found(format!("needle not found {}", needle_id.0))
}
/// Mirrors Go's `isNotLeaderErr`: the master returns raft's NotLeaderError,
/// which arrives as an Unknown-status message.
fn is_not_leader_status(e: &Status) -> bool {
e.message().contains("Not current leader")
}
/// Deadline from the caller's `grpc-timeout` header (TimeoutValue +
/// H|M|S|m|u|n unit, per the gRPC-over-HTTP2 spec). Tonic's GrpcTimeout layer
/// drops an over-deadline handler future anyway; parsing it here lets a queued
@@ -350,46 +344,22 @@ impl VolumeGrpcService {
is_readonly: bool,
) -> Result<(), Status> {
// VolumeMarkReadonly mutates raft-replicated master topology, so it must
// reach the leader the heartbeat confirmed. On "Not current leader",
// ask the seed masters who leads and retry there — mirrors Go's
// notifyMasterVolumeReadonly.
let master_url = self.state.current_master().await;
// reach the leader. Prefer the live leader the heartbeat is talking to
// (current_master_url), falling back to the static seed before the first
// heartbeat — mirrors store_ec.rs and Go's vs.GetMaster(). Sending it to
// the static seed fails "not current leader" after any master failover.
let master_url = {
let live = self.state.current_master_url.read().await.clone();
if !live.is_empty() {
live
} else {
self.state.master_url.clone()
}
};
if master_url.is_empty() {
if !self.state.has_master {
return Ok(());
}
return Err(Status::unavailable("no master leader known"));
return Ok(());
}
match self
.volume_mark_readonly_on_master(&master_url, info, is_readonly)
.await
{
Err(e) if is_not_leader_status(&e) => {
let leader = super::heartbeat::lookup_leader_from_peers(
&self.state.master_urls,
self.state.outgoing_grpc_tls.as_ref(),
)
.await
.ok_or_else(|| {
Status::unavailable(format!(
"master {} rejected mark-readonly and no peer named a leader",
master_url
))
})?;
self.volume_mark_readonly_on_master(&leader, info, is_readonly)
.await
}
result => result,
}
}
async fn volume_mark_readonly_on_master(
&self,
master_url: &str,
info: &MasterVolumeInfo,
is_readonly: bool,
) -> Result<(), Status> {
let grpc_addr = parse_grpc_address(master_url).map_err(|e| {
let grpc_addr = parse_grpc_address(&master_url).map_err(|e| {
Status::internal(format!("invalid master address {}: {}", master_url, e))
})?;
let channel = connect_channel(
@@ -1383,7 +1353,6 @@ impl VolumeServer for VolumeGrpcService {
// Go's CommitCompact waits out leased readers under
// dataFileAccessLock — do it without holding the store
// guard, so a slow download stalls only this vacuum.
let _sync_guard = vol.data_file_sync_lock().lock().unwrap();
let _lease = vol.instance().write_lock();
let is_read_only = vol.is_read_only();
vol.commit_compact_held()
@@ -2348,9 +2317,9 @@ impl VolumeServer for VolumeGrpcService {
// owns the .ecx on disk (volume not yet mounted — relevant
// when shards stream in mid-rebuild before any
// VolumeEcShardsMount has happened; see #9212), then any
// HDD, then any disk. The free-slot maths uses the
// volume's own data-shard count from its .vif so a
// custom-ratio volume is not scored as 10+4.
// HDD, then any disk. Pass the build's default data-shard
// count for free-slot maths; the helper takes it as a
// parameter so custom-ratio builds can swap it.
let vid = VolumeId(info.volume_id);
let dir = if info.disk_id > 0 {
let count = store.locations.len();
@@ -2365,13 +2334,11 @@ impl VolumeServer for VolumeGrpcService {
} else {
// The mounted-volume refusal above means no disk
// holds an in-memory claim here.
let data_shards =
store.lookup_ec_data_shards(&info.collection, vid);
store
.find_ec_shard_target_location(
&info.collection,
vid,
data_shards,
DATA_SHARDS_COUNT as u32,
&[],
)
.map(|i| store.locations[i].directory.clone())
@@ -3321,9 +3288,9 @@ impl VolumeServer for VolumeGrpcService {
// volume mounted, then a disk that owns the .ecx on disk (volume
// not yet mounted — relevant for ec.rebuild, where only the
// first shard carries .ecx and subsequent shards must land on
// the same disk; see #9212), then any HDD, then any disk. The
// free-slot maths uses the volume's own data-shard count from its
// .vif so a custom-ratio volume is not scored as 10+4.
// the same disk; see #9212), then any HDD, then any disk. Pass
// the build's default data-shard count; the helper takes it as a
// parameter so custom-ratio builds can swap it.
let (dest_dir, dest_idx_dir) = {
let store = self.state.store.read().unwrap();
let count = store.locations.len();
@@ -3355,11 +3322,10 @@ impl VolumeServer for VolumeGrpcService {
req.volume_id, shard_ids, dirs
)));
}
let data_shards = store.lookup_ec_data_shards(&req.collection, vid);
match store.find_ec_shard_target_location(
&req.collection,
vid,
data_shards,
DATA_SHARDS_COUNT as u32,
&shard_ids,
) {
Some(i) => {
@@ -4757,7 +4723,6 @@ impl VolumeServer for VolumeGrpcService {
// backend swap: between clearing the remote-only write
// restriction and attaching the local .dat a write would
// pass check_writable and then fail on a missing dat_file.
let _sync_guard = vol.data_file_sync_lock().lock().unwrap();
let _lease = vol.instance().write_lock();
// The store guard dropped before the lease was taken, so an
@@ -5806,8 +5771,7 @@ impl VolumeCopyJob {
(&self.dest.data_base_name, self.dest.has_remote_dat);
let mut preallocate_size: i64 = 0;
if !has_remote_dat {
let master = state.current_master().await;
let grpc_addr = super::heartbeat::to_grpc_address(&master);
let grpc_addr = super::heartbeat::to_grpc_address(&state.master_url);
// Race the master-configuration RPC against the caller's
// response channel: a stalled master (or a slow leader
// election) would otherwise hold the task and its .note
@@ -5832,7 +5796,7 @@ impl VolumeCopyJob {
}
}
Err(e) => {
tracing::warn!("get master {} configuration: {}", master, e);
tracing::warn!("get master {} configuration: {}", state.master_url, e);
}
}
+18 -86
View File
@@ -603,7 +603,7 @@ async fn batch_delete_file_ids(
let locations = lookup_volume(
&state.http_client,
&state.outgoing_http_scheme,
&state.current_master().await,
&state.master_url,
volume_id,
)
.await
@@ -675,7 +675,7 @@ async fn do_replicated_request(
let locations = lookup_volume(
&state.http_client,
&state.outgoing_http_scheme,
&state.current_master().await,
&state.master_url,
vid,
)
.await
@@ -832,7 +832,7 @@ async fn proxy_or_redirect_to_target(
let locations = match lookup_volume(
&state.http_client,
&state.outgoing_http_scheme,
&state.current_master().await,
&state.master_url,
vid.0,
)
.await
@@ -1238,7 +1238,7 @@ async fn proxy_missing_volume(
let query_string = uri.query().unwrap_or("").to_string();
if is_proxied_query(&query_string)
|| state.read_mode == ReadMode::Local
|| state.current_master().await.is_empty()
|| state.master_url.is_empty()
{
return StatusCode::NOT_FOUND.into_response();
}
@@ -1282,7 +1282,7 @@ async fn check_download_limit(
// Go tries proxy to replica once before the wait loop
// (checkDownloadLimit); it does not retry on each wakeup.
let should_try_replica =
!is_proxied_query(&query_string) && !state.current_master().await.is_empty() && {
!is_proxied_query(&query_string) && !state.master_url.is_empty() && {
let store = state.store.read().unwrap();
store
.find_volume(vid)
@@ -2433,9 +2433,6 @@ fn is_zero_u32(v: &u32) -> bool {
*v == 0
}
/// Store an uploaded needle on the local volume — durable (fsync) uploads
/// through the per-volume write queue, the rest synchronously — and then
/// replicate it if the volume's placement calls for more copies.
pub async fn post_handler(
State(state): State<Arc<VolumeServerState>>,
request: Request<Body>,
@@ -2956,13 +2953,19 @@ pub async fn post_handler(
let needle_etag = n.etag();
let needle_has_name = n.has_name();
let write_result = write_upload_needle(&state, vid, n, fsync).await;
let write_result = if let Some(wq) = state.write_queue.get() {
wq.submit(vid, n, fsync).await
} else {
let vol = {
let store = state.store.read().unwrap();
store.writable_volume(vid)
};
vol.and_then(|vol| vol.write_needle(&mut n, true, fsync))
};
// Replicate to remote volume servers if this volume has replicas.
// Matches Go's GetWritableRemoteReplications: skip if copy_count == 1.
// No leader-known gate: when the lookup cannot run, the write must fail
// rather than be acknowledged on fewer copies than required.
if !is_replicate && write_result.is_ok() {
if !is_replicate && write_result.is_ok() && !state.master_url.is_empty() {
let needs_replication = {
let store = state.store.read().unwrap();
store
@@ -3052,28 +3055,6 @@ pub async fn post_handler(
resp
}
/// Append an uploaded needle the way Go's `writeNeedle2` does: a durable
/// write goes to the volume's batching worker, which group-commits the
/// batch's .dat/.idx syncs, while anything else — a non-durable write, or a
/// durable one landing while the server is stopping and the workers are
/// winding down — takes the synchronous path and fsyncs inline.
async fn write_upload_needle(
state: &Arc<VolumeServerState>,
vid: VolumeId,
mut n: Needle,
fsync: bool,
) -> Result<(u64, Size, bool), crate::storage::volume::VolumeError> {
let queued = fsync && !*state.is_stopping.read().unwrap();
if queued && let Some(wq) = state.write_queue.get() {
return wq.submit(vid, n, fsync).await;
}
let vol = {
let store = state.store.read().unwrap();
store.writable_volume(vid)
};
vol.and_then(|vol| vol.write_needle(&mut n, true, fsync))
}
// ============================================================================
// Delete Handler
// ============================================================================
@@ -3360,7 +3341,7 @@ pub async fn delete_handler(
};
let is_replicate = del_query.split('&').any(|p| p == "type=replicate");
if !is_replicate && delete_result.is_ok() {
if !is_replicate && delete_result.is_ok() && !state.master_url.is_empty() {
let needs_replication = {
let store = state.store.read().unwrap();
store
@@ -4234,7 +4215,7 @@ async fn read_remote_chunk_needle(
let locations = lookup_volume(
&state.http_client,
&state.outgoing_http_scheme,
&state.current_master().await,
&state.master_url,
vid.0,
)
.await?;
@@ -6673,8 +6654,7 @@ mod tests {
let s = Arc::get_mut(&mut state).unwrap();
// Empty self_url makes `url.contains(self_url)` true for every peer.
s.self_url = "198.51.100.8:8080".to_string();
s.master_url = master_url.clone();
s.current_master_url = tokio::sync::RwLock::new(master_url);
s.master_url = master_url;
state
}
@@ -7208,52 +7188,4 @@ mod tests {
"the vacuum moves the needle once the stream drains"
);
}
/// Issue #801: an fsync upload takes the per-volume worker so its sync is
/// shared across the batch (Go's `writeNeedle2` split); a non-fsync write,
/// or an fsync write landing while the server is stopping, takes the
/// synchronous path instead.
#[tokio::test]
async fn test_write_upload_needle_routes_fsync_to_queue() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
assert!(
state
.write_queue
.set(crate::server::write_queue::WriteQueue::new(
state.clone(),
128,
))
.is_ok()
);
let queue = state.write_queue.get().unwrap();
let needle = |id: u64| {
let mut n = Needle {
id: NeedleId(id),
cookie: Cookie(TEST_COOKIE),
data: vec![id as u8; 8],
data_size: 8,
..Needle::default()
};
n.checksum = CRC::new(&n.data);
n
};
write_upload_needle(&state, VolumeId(1), needle(0x6e7a_0b01), true)
.await
.unwrap();
assert_eq!(queue.submits_for_test(), 1);
write_upload_needle(&state, VolumeId(1), needle(0x6e7a_0b02), false)
.await
.unwrap();
assert_eq!(queue.submits_for_test(), 1);
*state.is_stopping.write().unwrap() = true;
write_upload_needle(&state, VolumeId(1), needle(0x6e7a_0b03), true)
.await
.unwrap();
assert_eq!(queue.submits_for_test(), 1);
}
}
+16 -71
View File
@@ -155,11 +155,7 @@ pub async fn run_heartbeat_with_state(
}
PostAction::SleepPulse => {
new_leader = None;
// The lookup spends part of the retry delay rather than
// adding to it.
let retry_at = tokio::time::Instant::now() + pulse;
refresh_leader_from_peers(&config, &state, pulse).await;
tokio::time::sleep_until(retry_at).await;
tokio::time::sleep(pulse).await;
}
}
@@ -258,56 +254,10 @@ pub async fn try_get_master_configuration(
Ok(resp.into_inner())
}
/// Publish the leader the configured masters report, so lookups stop going
/// to the master the heartbeat just lost while the loop retries. Without an
/// answer inside `timeout` the published master is cleared: the lost master
/// is dead or no longer leader, and a follower answers lookups from its own
/// cache rather than refusing them. Mirrors Go's refreshLeaderFromPeers.
async fn refresh_leader_from_peers(
config: &HeartbeatConfig,
state: &Arc<VolumeServerState>,
timeout: Duration,
) {
let lookup = lookup_leader_from_peers(
&config.master_addresses,
state.outgoing_grpc_tls.as_ref(),
);
match tokio::time::timeout(timeout, lookup).await {
Ok(Some(leader)) => publish_current_master(state, &leader).await,
_ => state.current_master_url.write().await.clear(),
}
}
/// Ask each seed master for the raft leader and return the first answer, or
/// None when none replies with one. Mirrors Go's
/// `operation.LookupRaftLeaderMaster`.
pub async fn lookup_leader_from_peers(
master_addrs: &[String],
tls: Option<&super::grpc_client::OutgoingGrpcTlsConfig>,
) -> Option<String> {
for master_addr in master_addrs {
let grpc_addr = to_grpc_address(master_addr);
if let Ok(resp) = try_get_master_configuration(&grpc_addr, tls).await
&& !resp.leader.is_empty()
{
return Some(resp.leader);
}
}
None
}
fn is_stopping(state: &VolumeServerState) -> bool {
*state.is_stopping.read().unwrap()
}
/// Store the master lookups should go to, in the address form it was
/// announced in — `host:port` or `host:port.grpcPort` — so an explicit gRPC
/// port survives to the gRPC callers, mirroring Go's setCurrentMaster.
/// The heartbeat calls this only for a master known to lead.
async fn publish_current_master(state: &Arc<VolumeServerState>, master: &str) {
*state.current_master_url.write().await = master.to_string();
}
fn duplicate_uuid_retry_delay(retry_count: u32) -> Duration {
Duration::from_secs((1u64 << retry_count) * 2)
}
@@ -480,6 +430,14 @@ async fn do_heartbeat(
let mut response_stream = client.send_heartbeat(stream).await?.into_inner();
info!("Heartbeat stream established with {}", grpc_addr);
// Publish the master we're now talking to in canonical http host:port
// form so Ping admission can recognise it once a leader change moves us
// off the seed list. Mirrors Go's vs.setCurrentMaster(masterAddress).
{
let normalised = super::volume_server::to_http_address(current_master).into_owned();
let mut guard = state.current_master_url.write().await;
*guard = normalised;
}
if is_stopping(state) {
state.is_heartbeating.store(false, Ordering::Relaxed);
send_deregister_heartbeat(config, state, &tx).await;
@@ -493,31 +451,13 @@ async fn do_heartbeat(
volume_tick.tick().await;
ec_tick.tick().await;
let mut confirmed_leader = false;
loop {
tokio::select! {
resp = response_stream.message() => {
match resp {
Ok(Some(hb_resp)) => {
// A reply names the leader or comes from it, so it
// settles where lookups go before anything else on the
// reply is handled: a follower reply carries no other
// fields, and a leader stays the lookup target even
// when it rejects this registration. Mirrors Go.
if !hb_resp.leader.is_empty() {
if super::volume_server::to_http_address(current_master)
!= super::volume_server::to_http_address(&hb_resp.leader)
{
publish_current_master(state, &hb_resp.leader).await;
return Ok(Some(hb_resp.leader));
}
} else if !confirmed_leader {
// The leader answers every new stream without
// naming a leader, so this peer is the one lookups
// go to.
confirmed_leader = true;
publish_current_master(state, current_master).await;
}
// Match Go ordering: DuplicatedUuids first, then volume
// options, then leader redirect.
if !hb_resp.duplicated_uuids.is_empty() {
let uuids = hb_resp.duplicated_uuids.clone();
let duplicate_dirs = off_runtime(config, state, move |_, state| {
@@ -562,6 +502,11 @@ async fn do_heartbeat(
if metrics_changed {
state.metrics_notify.notify_waiters();
}
// Match Go: only redirect if leader is non-empty AND
// different from the current master we're connected to.
if !hb_resp.leader.is_empty() && current_master != hb_resp.leader {
return Ok(Some(hb_resp.leader));
}
}
Ok(None) => return Ok(None),
Err(e) => return Err(Box::new(e)),
+1
View File
@@ -170,6 +170,7 @@ mod tests {
grpc_allowed_wildcard_domain: String::new(),
grpc_volume_allowed_common_names: vec![],
tls_policy: TlsPolicy::default(),
enable_write_queue: false,
security_file: String::new(),
}
}
+9 -2
View File
@@ -1139,9 +1139,16 @@ async fn cached_lookup_ec_shard_locations(
state: &Arc<VolumeServerState>,
vid: VolumeId,
) -> io::Result<HashMap<ShardId, Vec<String>>> {
let master = state.current_master().await;
let master = {
let live = state.current_master_url.read().await.clone();
if !live.is_empty() {
live
} else {
state.master_url.clone()
}
};
if master.is_empty() {
return Err(io::Error::other("no master leader known for ec shard lookup"));
return Err(io::Error::other("no master configured for ec shard lookup"));
}
let grpc_addr =
+7 -19
View File
@@ -77,17 +77,14 @@ pub struct VolumeServerState {
pub pre_stop_seconds: u32,
/// Notify heartbeat to send an immediate update when volume state changes.
pub volume_state_notify: tokio::sync::Notify,
/// Per-volume write workers that group-commit durable (fsync) uploads.
/// Set once at startup in production; a test state may leave it unset,
/// which sends every write down the synchronous path.
/// Optional batched write queue for improved throughput under load.
pub write_queue: std::sync::OnceLock<WriteQueue>,
/// Read mode: local, proxy, or redirect for non-local volumes.
pub read_mode: ReadMode,
/// If true, FetchAndWriteNeedle skips remote S3 endpoint validation,
/// allowing arbitrary (incl. loopback / link-local / metadata) hosts.
pub allow_untrusted_remote_endpoints: bool,
/// First configured master address (e.g., "localhost:9333"). A seed, not a
/// lookup target: lookups go to `current_master_url`.
/// First master address for volume lookups (e.g., "localhost:9333").
pub master_url: String,
/// Seed master addresses for UI rendering.
pub master_urls: Vec<String>,
@@ -95,12 +92,10 @@ pub struct VolumeServerState {
/// Built once at construction so Ping admission stays O(1). Mirrors
/// Go's `seedMasterSet` on `VolumeServer`.
pub seed_master_set: std::collections::HashSet<String>,
/// Master this server takes as leader, in its announced
/// `host:port[.grpcPort]` form — callers pick http or gRPC form per use.
/// Empty until the heartbeat confirms one — a peer that answered a
/// stream without naming a leader, or the leader a follower named — and
/// cleared again when no master reports one. The heartbeat writes;
/// admission and lookups read. Mirrors Go's `currentMaster` plus
/// Current master this server is heartbeating with, in canonical http
/// `host:port` form. Empty when no heartbeat connection is active. The
/// heartbeat goroutine writes; admission reads — the lock keeps them
/// from racing on a leader change. Mirrors Go's `currentMaster` plus
/// `currentMasterLock`.
pub current_master_url: tokio::sync::RwLock<String>,
/// This server's own address (ip:port) for filtering self from lookup results.
@@ -143,13 +138,6 @@ pub struct VolumeServerState {
}
impl VolumeServerState {
/// The master lookups should go to: the leader the heartbeat confirmed,
/// or empty while none is known, in its announced `host:port[.grpcPort]`
/// form. Mirrors Go's `vs.getCurrentMaster()`.
pub async fn current_master(&self) -> String {
self.current_master_url.read().await.clone()
}
/// Check if the server is in maintenance mode; return gRPC error if so.
pub fn check_maintenance(&self) -> Result<(), tonic::Status> {
if self.maintenance.load(Ordering::Relaxed) {
@@ -219,7 +207,7 @@ impl VolumeServerState {
return false;
}
let current = self.current_master_url.read().await.clone();
if !current.is_empty() && to_http_address(&current) == key {
if !current.is_empty() && current == key {
return true;
}
self.seed_master_set.contains(&key)
+2 -17
View File
@@ -1,8 +1,7 @@
//! Async batched write processing for the volume server.
//!
//! Durable (fsync) uploads are submitted here instead of each handler calling
//! `write_needle` directly — Go's `writeNeedle2` makes the same split, so the
//! queue is always constructed. Each volume gets its own FIFO worker (Go's
//! Instead of each upload handler directly calling `write_needle`, writes are
//! submitted to a queue. Each volume gets its own FIFO worker (Go's
//! volume_write.go keeps one batching worker per volume), which drains its
//! channel in batches of up to 128 entries or 4 MiB and shares the .dat/.idx
//! flushes across durable writes (see `Volume::write_needles_grouped`).
@@ -67,9 +66,6 @@ pub struct WriteQueue {
/// alone would multiply the old single queue's capacity by volume count.
queue_permits: Arc<Semaphore>,
workers: Arc<Mutex<HashMap<VolumeId, mpsc::Sender<WriteRequest>>>>,
/// Submissions accepted, for tests asserting which writes route here.
#[cfg(test)]
submits: Arc<std::sync::atomic::AtomicU64>,
}
impl WriteQueue {
@@ -82,17 +78,9 @@ impl WriteQueue {
io_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_VOLUME_WRITES)),
queue_permits: Arc::new(Semaphore::new(capacity)),
workers: Arc::new(Mutex::new(HashMap::new())),
#[cfg(test)]
submits: Arc::new(std::sync::atomic::AtomicU64::new(0)),
}
}
/// How many writes were submitted, for tests asserting upload routing.
#[cfg(test)]
pub fn submits_for_test(&self) -> u64 {
self.submits.load(std::sync::atomic::Ordering::Relaxed)
}
/// Sender for the volume's worker, spawning one on first use.
fn worker_for(&self, volume_id: VolumeId) -> mpsc::Sender<WriteRequest> {
let mut workers = self.workers.lock().unwrap();
@@ -122,9 +110,6 @@ impl WriteQueue {
pub async fn submit(&self, volume_id: VolumeId, needle: Needle, fsync: bool) -> WriteResult {
// Workers spawn only for mounted volumes — a bogus id never creates
// a channel, so volume-id sprays can't grow the worker map.
#[cfg(test)]
self.submits
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
{
let store = self.state.store.read().unwrap();
if store.find_volume(volume_id).is_none() {
-1
View File
@@ -18,4 +18,3 @@ pub mod volume_idx_repair;
pub mod volume_open;
pub mod volume_report;
pub mod volume_report_hash;
pub mod volume_tail_repair;
+1 -63
View File
@@ -14,7 +14,7 @@ use crate::pb::master_pb;
use crate::storage::disk_location::DiskLocation;
use crate::storage::erasure_coding::ec_shard::{EcVolumeShard, MAX_SHARD_COUNT, ShardId};
use crate::storage::erasure_coding::ec_volume::{
ECJ_COMPACT_TMP_EXT, EcVolume, is_usable_ecx_file, read_ec_shard_config_across_dirs,
ECJ_COMPACT_TMP_EXT, EcVolume, is_usable_ecx_file,
};
use crate::storage::needle::needle::{Needle, get_actual_size};
use crate::storage::needle_map::NeedleMapKind;
@@ -416,39 +416,6 @@ impl Store {
.collect()
}
/// Look up the per-volume EC data-shard count, preferring a mounted EC
/// volume's own config and then the `.vif` on whichever disk holds it.
/// Falls back to the default when neither exists yet (e.g. before the
/// first shard streams in during ec.rebuild), so
/// `find_ec_shard_target_location`'s free-slot maths scores custom-ratio
/// volumes against their real width instead of the build default.
pub fn lookup_ec_data_shards(&self, collection: &str, vid: VolumeId) -> u32 {
use crate::storage::erasure_coding::ec_shard::DATA_SHARDS_COUNT;
for loc in &self.locations {
if let Some(ec_vol) = loc.find_ec_volume(vid) {
return ec_vol.data_shards;
}
}
let Some(first) = self.locations.first() else {
return DATA_SHARDS_COUNT as u32;
};
let other_dirs: Vec<String> = self.locations[1..]
.iter()
.flat_map(|loc| [loc.directory.clone(), loc.idx_directory.clone()])
.filter(|d| !d.is_empty())
.collect();
read_ec_shard_config_across_dirs(
&first.directory,
&first.idx_directory,
&other_dirs,
collection,
vid,
)
.map(|(data_shards, _, _)| data_shards)
.unwrap_or(DATA_SHARDS_COUNT as u32)
}
/// Create a new volume, placing it on the location with the most free space.
pub fn add_volume(
&mut self,
@@ -3013,35 +2980,6 @@ mod tests {
);
}
/// A volume whose `.vif` declares a custom EC ratio must feed its own
/// data-shard count into the free-slot maths — not the build default —
/// wherever the `.vif` happens to sit. With no `.vif` anywhere (a
/// brand-new volume mid-ec.rebuild) the answer is the default.
#[test]
fn test_lookup_ec_data_shards_reads_vif_on_any_disk() {
use crate::storage::erasure_coding::ec_shard::DATA_SHARDS_COUNT;
let (store, _tmp) = make_ec_target_test_store(3);
let collection = "custom-ratio";
let vid = VolumeId(4242);
assert_eq!(
store.lookup_ec_data_shards(collection, vid),
DATA_SHARDS_COUNT as u32,
"no .vif anywhere must answer the default"
);
// A 6+2 .vif lands on disk 2 only — the rebuild staging disk.
let base = volume_file_name(&store.locations[2].directory, collection, vid);
std::fs::write(
format!("{}.vif", base),
br#"{"ecShardConfig":{"dataShards":6,"parityShards":2}}"#,
)
.unwrap();
assert_eq!(store.lookup_ec_data_shards(collection, vid), 6);
}
/// Deleting a disk's last shard removes that disk's now-orphaned `.ecsum`;
/// a disk this delete never changed keeps its sidecar. Disk 0 models
/// ec.rebuild's index-prep state (`.ecsum` landed, no shards yet) — an
+71 -301
View File
@@ -29,7 +29,6 @@ use crate::storage::needle_map::{CompactNeedleMap, NeedleMap, NeedleMapKind, Red
use crate::storage::super_block::{ReplicaPlacement, SUPER_BLOCK_SIZE, SuperBlock};
use crate::storage::types::*;
use crate::storage::volume_open::open_volume_file;
use crate::storage::volume_tail_repair::TailRepairError;
// ============================================================================
// Errors
@@ -1248,12 +1247,6 @@ pub struct Volume {
idx_syncs_for_test: std::sync::atomic::AtomicUsize,
needle_map_kind: NeedleMapKind,
data_file_access_control: Arc<DataFileAccessControl>,
/// Serializes a data-file mutation with the sync that makes it durable:
/// write, delete and lifecycle paths hold it for their whole critical
/// section, while the fsync itself runs with only the `io` read guard so
/// readers of this volume do not wait on it. Lock order is
/// `data_file_sync_lock` -> `data_file_access_control` -> `io`.
data_file_sync_lock: std::sync::Mutex<()>,
/// Go's superBlockAccessLock.
super_block: RwLock<SuperBlock>,
@@ -1377,7 +1370,6 @@ impl Volume {
idx_syncs_for_test: Default::default(),
needle_map_kind,
data_file_access_control: Arc::new(DataFileAccessControl::default()),
data_file_sync_lock: std::sync::Mutex::new(()),
super_block: RwLock::new(SuperBlock {
replica_placement: replica_placement.unwrap_or_default(),
ttl: ttl.unwrap_or(crate::storage::needle::ttl::TTL::EMPTY),
@@ -1434,7 +1426,6 @@ impl Volume {
idx_syncs_for_test: Default::default(),
needle_map_kind: NeedleMapKind::InMemory,
data_file_access_control: Arc::new(DataFileAccessControl::default()),
data_file_sync_lock: std::sync::Mutex::new(()),
super_block: RwLock::new(SuperBlock::default()),
no_write: AtomicU8::new(0),
io_unavailable: Arc::default(),
@@ -1455,13 +1446,6 @@ impl Volume {
self.data_file_access_control.clone()
}
/// The mutation/sync serializer, for callers that manage the access
/// lease themselves (the `_held` lifecycle paths). Same lock order as
/// everywhere else: sync lock first, then the write lease, then `io`.
pub(crate) fn data_file_sync_lock(&self) -> &std::sync::Mutex<()> {
&self.data_file_sync_lock
}
pub(crate) fn is_instance(&self, instance: &Arc<DataFileAccessControl>) -> bool {
Arc::ptr_eq(instance, &self.data_file_access_control)
}
@@ -1725,48 +1709,6 @@ impl Volume {
),
}
// A crash between the .dat append and the .idx append leaves a
// .dat tail no index entry covers. Re-index complete needles and
// cut an incomplete remainder before the index loads, so the
// recovered rows land in the needle map and inside its append
// offset. Mirrors Go running repairUnindexedDatTail inside
// CheckVolumeDataIntegrity ahead of the needle map load. Only a
// volume about to load writable is repaired.
// A no_write_can_delete volume still appends tombstone records on
// delete, so it tears tails the same way and gets the same repair.
if !self.has_remote_file() && !self.no_write_flags().0 {
// An empty .idx gets the same repair: every check below
// skips an empty index on its own, so a tail the repair
// cannot explain there fails the load like in Go rather
// than warn and load writable.
let idx_empty = fs::metadata(self.file_name(".idx"))
.map(|m| m.len() == 0)
.unwrap_or(false);
match self.repair_unindexed_dat_tail(io) {
Ok(()) => {}
Err(e) if e.is_not_torn() && !idx_empty => warn!(
volume_id = self.id.0,
error = %e,
"unindexed .dat tail not repaired"
),
// The index refusing a read-write open is the same failure
// Go reports opening it ahead of the check: it fails the
// load rather than only the repair.
Err(TailRepairError::OpenIndex(e)) => return Err(e),
Err(e) => {
// A repair that could not complete must not load the
// volume writable on files it half-fixed: the check
// fails as in Go, so the volume loads read-only.
self.set_no_write_flag(NO_WRITE_OR_DELETE, true);
warn!(
volume_id = self.id.0,
error = %e,
"repair unindexed .dat tail"
);
}
}
}
self.load_index(io)?;
// Match Go: CheckVolumeDataIntegrity after loading index (volume_loading.go L154-159)
@@ -2038,7 +1980,6 @@ impl Volume {
}
pub(crate) fn load_remote_dat_file(&self) -> Result<(), VolumeError> {
let _sync_guard = self.data_file_sync_lock.lock().unwrap();
let _guard = self.data_file_access_control.write_lock();
let mut io = self.io.write().unwrap();
self.load_remote_dat_file_io(&mut io)
@@ -2125,7 +2066,7 @@ impl Volume {
self.current_dat_file_size_io(&io)
}
pub(crate) fn current_dat_file_size_io(&self, io: &VolumeIo) -> io::Result<u64> {
fn current_dat_file_size_io(&self, io: &VolumeIo) -> io::Result<u64> {
if let Some(ref f) = io.dat_file {
Ok(f.metadata()?.len())
} else if let Some(ref remote_dat_file) = io.remote_dat_file {
@@ -2349,7 +2290,7 @@ impl Volume {
self.read_needle_blob_unlocked(&io, offset, size)
}
pub(crate) fn read_needle_blob_unlocked(
fn read_needle_blob_unlocked(
&self,
io: &VolumeIo,
offset: i64,
@@ -2568,14 +2509,10 @@ impl Volume {
check_cookie: bool,
fsync: bool,
) -> Result<(u64, Size, bool), VolumeError> {
// A single write is a one-entry run of the grouped path: identical
// outcome, and the fsync handling lives in one place.
debug_assert!(check_cookie, "every write_needle caller checks the cookie");
let _ = check_cookie;
let mut writes = [(std::mem::take(n), fsync)];
let mut results = self.write_needles_grouped(&mut writes);
*n = std::mem::replace(&mut writes[0].0, Needle::default());
results.pop().unwrap_or(Err(VolumeError::NotFound))
let _guard = self.data_file_access_control.write_lock();
let mut io = self.io.write().unwrap();
self.check_writable()?;
self.do_write_request(&mut io, n, check_cookie, fsync)
}
/// Write a batch of needles the way Go's processBatch does: one .dat
@@ -2590,13 +2527,8 @@ impl Volume {
&self,
writes: &mut [(Needle, bool)],
) -> Vec<Result<(u64, Size, bool), VolumeError>> {
// The sync lock serializes a .dat mutation with the flush that makes
// it durable: a failed sync's rollback can never clobber an append
// that slipped in behind it, and a compaction swap or unlink cannot
// land between the append and its sync. Readers never take it, and
// the write-side guards are dropped for the sync itself, so reads of
// this volume do not wait on fsync.
let _sync_guard = self.data_file_sync_lock.lock().unwrap();
let _guard = self.data_file_access_control.write_lock();
let mut io = self.io.write().unwrap();
let mut results = Vec::with_capacity(writes.len());
let mut rest = writes;
while !rest.is_empty() {
@@ -2604,10 +2536,8 @@ impl Volume {
let len = rest.iter().take_while(|(n, _)| ids.insert(n.id)).count();
let (run, tail) = std::mem::take(&mut rest).split_at_mut(len);
if run.iter().any(|(_, fsync)| *fsync) {
results.extend(self.write_synced_run(run));
results.extend(self.write_synced_run(&mut io, run));
} else {
let _guard = self.data_file_access_control.write_lock();
let mut io = self.io.write().unwrap();
for (n, _) in run.iter_mut() {
results.push(
self.check_writable()
@@ -2622,6 +2552,7 @@ impl Volume {
fn write_synced_run(
&self,
io: &mut VolumeIo,
run: &mut [(Needle, bool)],
) -> Vec<Result<(u64, Size, bool), VolumeError>> {
// Per entry: Some(offset) once appended, None when it dedups.
@@ -2629,82 +2560,63 @@ impl Volume {
// Per entry: the I/O error streak once it is staged, where a write
// sent on its own would have recorded its success.
let mut marks = Vec::with_capacity(run.len());
let mut last_append_at_ns;
let mut last_append_at_ns = io.last_append_at_ns;
let mut run_start = None;
let mut sync = false;
{
// Append under the write guards, then drop them for the flush:
// readers need only `io`'s read side, while mutators and a
// compaction swap stay excluded by the caller's sync lock.
let _guard = self.data_file_access_control.write_lock();
let mut io = self.io.write().unwrap();
last_append_at_ns = io.last_append_at_ns;
for (n, fsync) in run.iter_mut() {
let r = self.append_unpublished(&mut io, n, &mut last_append_at_ns);
marks.push(self.io_errors.mark());
if let Ok(Some(offset)) = r {
run_start.get_or_insert(offset);
}
// Only a durable entry that got this far needs the sync.
sync |= *fsync && r.is_ok();
staged.push(r);
for (n, fsync) in run.iter_mut() {
let r = self.append_unpublished(io, n, &mut last_append_at_ns);
marks.push(self.io_errors.mark());
if let Ok(Some(offset)) = r {
run_start.get_or_insert(offset);
}
// Only a durable entry that got this far needs the sync.
sync |= *fsync && r.is_ok();
staged.push(r);
}
if sync && let Err(e) = self.flush_dat(io) {
self.check_read_write_error(Some(&e));
if let Some(start) = run_start {
self.undo_unsynced_append(io, start);
}
let e = VolumeError::Io(e);
return staged
.into_iter()
.map(|r| r.and_then(|_| Err(run_error(&e))))
.collect();
}
io.last_append_at_ns = last_append_at_ns;
for ((n, _), r) in run.iter().zip(&staged) {
if matches!(r, Ok(Some(_))) && n.append_at_ns > io.last_write_append_at_ns {
io.last_write_append_at_ns = n.append_at_ns;
io.last_write_needle_key = n.id;
io.last_write_deleted = false;
}
}
if sync {
let io = self.io.read().unwrap();
if let Err(e) = self.flush_dat(&io) {
self.check_read_write_error(Some(&e));
if let Some(start) = run_start {
self.undo_unsynced_append(&io, start);
}
let e = VolumeError::Io(e);
return staged
.into_iter()
.map(|r| r.and_then(|_| Err(run_error(&e))))
.collect();
// A durable entry that fails to publish stops the volume taking
// writes, so the entries after it are refused the way a lone
// write would be.
let mut refused = false;
for ((n, fsync), r) in run.iter().zip(staged.iter_mut()) {
if refused {
*r = Err(self
.check_writable()
.err()
.unwrap_or(VolumeError::ReadOnly(self.id)));
} else if let Ok(Some(offset)) = *r
&& let Err(e) = self.publish_write(io, n, offset, *fsync)
{
*r = Err(e);
refused = *fsync;
}
}
{
let _guard = self.data_file_access_control.write_lock();
let mut io = self.io.write().unwrap();
io.last_append_at_ns = last_append_at_ns;
for ((n, _), r) in run.iter().zip(&staged) {
if matches!(r, Ok(Some(_))) && n.append_at_ns > io.last_write_append_at_ns {
io.last_write_append_at_ns = n.append_at_ns;
io.last_write_needle_key = n.id;
io.last_write_deleted = false;
}
}
// A durable entry that fails to publish stops the volume taking
// writes, so the entries after it are refused the way a lone
// write would be.
let mut refused = false;
for ((n, fsync), r) in run.iter().zip(staged.iter_mut()) {
if refused {
*r = Err(self
.check_writable()
.err()
.unwrap_or(VolumeError::ReadOnly(self.id)));
} else if let Ok(Some(offset)) = *r
&& let Err(e) = self.publish_write(&mut io, n, offset, *fsync)
{
*r = Err(e);
refused = *fsync;
}
}
}
if sync {
let io = self.io.read().unwrap();
if let Err(e) = self.flush_idx(&io) {
return staged
.into_iter()
.map(|r| r.and_then(|_| Err(run_error(&e))))
.collect();
}
if sync && let Err(e) = self.flush_idx(io) {
return staged
.into_iter()
.map(|r| r.and_then(|_| Err(run_error(&e))))
.collect();
}
let written = run
@@ -2718,9 +2630,7 @@ impl Volume {
// Sent one at a time, the last write to land would have cleared
// the streak of the appends before it, and the ones after it
// would have failed on top of that.
let _guard = self.data_file_access_control.write_lock();
let mut io = self.io.write().unwrap();
self.finish_write(&mut io, last_modified, sync, marks[last]);
self.finish_write(io, last_modified, sync, marks[last]);
}
run.iter()
@@ -2769,7 +2679,7 @@ impl Volume {
/// Flush the .dat, the first half of a durable write. The .idx is flushed
/// separately by flush_idx once the row is published; the two are split so
/// nothing is indexed before the bytes it points at are down.
pub(crate) fn flush_dat(&self, io: &VolumeIo) -> io::Result<()> {
fn flush_dat(&self, io: &VolumeIo) -> io::Result<()> {
#[cfg(test)]
self.dat_syncs_for_test.fetch_add(1, Ordering::Relaxed);
#[cfg(test)]
@@ -2821,7 +2731,7 @@ impl Volume {
}
/// Take an unflushed append back off the .dat after its sync failed.
pub(crate) fn truncate_dat(&self, io: &VolumeIo, len: u64) -> io::Result<()> {
fn truncate_dat(&self, io: &VolumeIo, len: u64) -> io::Result<()> {
#[cfg(test)]
if io.fail_truncate_for_test {
return Err(io::Error::other("injected truncate failure"));
@@ -3172,7 +3082,6 @@ impl Volume {
/// Delete a needle from the volume.
pub fn delete_needle(&self, n: &mut Needle) -> Result<Size, VolumeError> {
let _sync_guard = self.data_file_sync_lock.lock().unwrap();
let _guard = self.data_file_access_control.write_lock();
let mut io = self.io.write().unwrap();
if self.is_closed() {
@@ -3555,7 +3464,7 @@ impl Volume {
/// key-sorted .idx (e.g. legacy weed fix output) falls back to a single
/// linear scan for the maximum offset. Returns -1 when no entry has a
/// non-zero offset (a pre-allocated / all-zero index).
pub(crate) fn find_dat_tail_entry_offset(
fn find_dat_tail_entry_offset(
&self,
io: &VolumeIo,
idx_file: &mut File,
@@ -4072,7 +3981,6 @@ impl Volume {
offset: Offset,
size: Size,
) -> Result<(), VolumeError> {
let _sync_guard = self.data_file_sync_lock.lock().unwrap();
let _guard = self.data_file_access_control.write_lock();
let mut io = self.io.write().unwrap();
if let Some(ref mut nm) = io.nm {
@@ -4097,7 +4005,6 @@ impl Volume {
// The flag flip and the .vif save are one critical section with the idx
// attach: Go's markVolumeReadonly runs under dataFileAccessLock, so a
// racing mark-writable cannot interleave a save of half-applied state.
let _sync_guard = self.data_file_sync_lock.lock().unwrap();
let _guard = self.data_file_access_control.write_lock();
self.set_read_only_persist_held(can_delete, persist)
}
@@ -4182,13 +4089,10 @@ impl Volume {
if let Some(e) = self.unavailable_error() {
return Err(e);
}
let _sync_guard = self.data_file_sync_lock.lock().unwrap();
let _guard = self.data_file_access_control.write_lock();
let (was_no_write_or_delete, was_no_write_can_delete) = self.no_write_flags();
// A remote-tiered volume keeps the delete-only mode a load derives: its
// .dat cannot take appends, so "writable" resolves to can-delete rather
// than advertising writable with no local data file.
let no_write_can_delete = self.has_remote_file();
// Remote-tiered volumes must stay no_write_can_delete regardless of marks.
let no_write_can_delete = was_no_write_can_delete && self.has_remote_file();
self.no_write.store(
if no_write_can_delete {
NO_WRITE_CAN_DELETE
@@ -4283,7 +4187,6 @@ impl Volume {
&self,
f: impl FnOnce(&mut Vec<PbRemoteFile>),
) -> Result<(), VolumeError> {
let _sync_guard = self.data_file_sync_lock.lock().unwrap();
let _guard = self.data_file_access_control.write_lock();
self.update_remote_files_held(f)
}
@@ -4302,70 +4205,21 @@ impl Volume {
}
f(&mut self.volume_info.write().unwrap().files);
let mut io = self.io.write().unwrap();
self.refresh_remote_write_mode(&mut io, true)
}
/// Go's MarkTieredDeletable: a live tier-up lifts the upload-time
/// read-only mark, except on a replicated volume — the shell copies the
/// uploaded replica's .idx and .vif to the other replicas after the
/// upload, and a tombstone written before that copy would reach one
/// replica only — or under an I/O quarantine, which is not the tier
/// upload's to lift. A refused volume keeps its flags until the next
/// load, as the Go volume does.
fn mark_tiered_deletable(&self) -> bool {
if self
.super_block
.read()
.unwrap()
.replica_placement
.has_replication()
{
return false;
}
if !self.has_remote_file() {
return false;
}
// Go takes noWriteLock around the quarantine check and the flag
// change; here a compare-exchange gives the same ordering — a
// heartbeat refusal landing between the check and the swap fails the
// swap, and the retry sees the quarantine mark the heartbeat set
// first.
loop {
if self.should_quarantine() {
return false;
}
let flags = self.no_write.load(Ordering::Acquire);
let promoted = (flags & !NO_WRITE_OR_DELETE) | NO_WRITE_CAN_DELETE;
if self
.no_write
.compare_exchange(flags, promoted, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
return true;
}
}
self.refresh_remote_write_mode(&mut io)
}
/// Recompute the Go-style write/delete mode from the current remote tier
/// state, and bring the needle map in line with it — a volume that stops
/// being remote also stops using the read-only sorted index.
///
/// `live` is a tier transition on a running volume: the promotion then
/// goes through mark_tiered_deletable, Go's MarkTieredDeletable, while a
/// load promotes every tiered volume unconditionally as Go's load() does.
///
/// If the map cannot be rebuilt the volume is pinned read-only rather than
/// published as writable with an index that rejects every put; a restart
/// recovers it.
fn refresh_remote_write_mode(&self, io: &mut VolumeIo, live: bool) -> Result<(), VolumeError> {
fn refresh_remote_write_mode(&self, io: &mut VolumeIo) -> Result<(), VolumeError> {
let has_remote = self.has_remote_file();
if has_remote {
// remote-tiered: no writes, deletes allowed
if !live {
self.no_write.store(NO_WRITE_CAN_DELETE, Ordering::Release);
} else {
self.mark_tiered_deletable();
}
self.no_write.store(NO_WRITE_CAN_DELETE, Ordering::Release);
} else if !self.volume_info.read().unwrap().read_only_can_delete {
// only clear the remoteness-derived flag, not an operator mark
self.set_no_write_flag(NO_WRITE_CAN_DELETE, false);
@@ -4414,7 +4268,7 @@ impl Volume {
}
}
*self.volume_info.write().unwrap() = pb_info;
self.refresh_remote_write_mode(io, false)?;
self.refresh_remote_write_mode(io)?;
{
let mut vi = self.volume_info.write().unwrap();
if vi.version == 0 {
@@ -4445,7 +4299,7 @@ impl Volume {
self.set_no_write_flag(NO_WRITE_OR_DELETE, true);
}
*self.volume_info.write().unwrap() = pb_info;
self.refresh_remote_write_mode(io, false)?;
self.refresh_remote_write_mode(io)?;
{
let mut vi = self.volume_info.write().unwrap();
if vi.version == 0 {
@@ -4597,7 +4451,6 @@ impl Volume {
/// Change the replication placement and rewrite the super block.
pub fn set_replica_placement(&self, rp: ReplicaPlacement) -> Result<(), VolumeError> {
let _sync_guard = self.data_file_sync_lock.lock().unwrap();
let _guard = self.data_file_access_control.write_lock();
let bytes = {
let mut sb = self.super_block.write().unwrap();
@@ -4768,7 +4621,6 @@ impl Volume {
/// Write a raw needle blob at a specific offset in the .dat file.
pub fn write_needle_blob(&self, offset: i64, needle_blob: &[u8]) -> Result<(), VolumeError> {
let _sync_guard = self.data_file_sync_lock.lock().unwrap();
let _guard = self.data_file_access_control.write_lock();
if self.is_read_only() {
return Err(VolumeError::ReadOnly(self.id));
@@ -4800,7 +4652,6 @@ impl Volume {
needle_blob: &[u8],
size: Size,
) -> Result<(), VolumeError> {
let _sync_guard = self.data_file_sync_lock.lock().unwrap();
let _guard = self.data_file_access_control.write_lock();
// nm.put on a read-only volume fails only after the blob is appended to .dat.
if self.is_read_only() {
@@ -5105,16 +4956,6 @@ impl Volume {
// would stall every volume behind one slow client. The vacuum-commit
// RPC uses the lease-holding path instead — it waits with no store
// guard held, matching Go's blocking commit.
// The sync lock must come before the lease and be non-blocking like
// it: a durable write holds it across its fsync, and the commit must
// not swap files over an unpublished write — nor stall the store
// behind one.
let Ok(_sync_guard) = self.data_file_sync_lock.try_lock() else {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::WouldBlock,
COMMIT_WAITS_FOR_READS,
)));
};
let Some(_lease) = self.data_file_access_control.try_write_lock() else {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::WouldBlock,
@@ -5124,9 +4965,7 @@ impl Volume {
self.commit_compact_held()
}
/// `commit_compact` for callers already holding `data_file_sync_lock`
/// and the write lease — the swap must not land between a durable
/// write's append and its publish.
/// `commit_compact` for callers already holding the write lease.
pub(crate) fn commit_compact_held(&self) -> Result<(), VolumeError> {
// A handle detached between lookup and lease must not swap compacted
// files over the ones its remounted replacement now owns.
@@ -5517,7 +5356,6 @@ impl Volume {
/// Go's `Volume.Close`: the write lease excludes in-flight I/O — with a
/// shared handle, `&mut self` no longer provides that exclusion.
pub fn close(&self) {
let _sync_guard = self.data_file_sync_lock.lock().unwrap();
let _guard = self.data_file_access_control.write_lock();
self.closed.store(true, Ordering::Release);
self.close_locked();
@@ -5563,7 +5401,6 @@ impl Volume {
if self.is_compacting() {
return Err(self.compacting_error());
}
let _sync_guard = self.data_file_sync_lock.lock().unwrap();
let _guard = self.data_file_access_control.write_lock();
if self.dir_idx() == new_idx_dir {
@@ -5626,7 +5463,6 @@ impl Volume {
) -> Result<(), VolumeError> {
// Go's Destroy holds dataFileAccessLock: file unlinking must exclude
// in-flight I/O from a shared handle, not just borrow exclusivity.
let _sync_guard = self.data_file_sync_lock.lock().unwrap();
let _guard = self.data_file_access_control.write_lock();
// Either enabled check may pass: a volume with no live data qualifies
// whether it reads empty or as all garbage. Byte counters, not counts:
@@ -9752,72 +9588,6 @@ mod tests {
assert!(!v.is_no_write_or_delete());
}
/// Go's MarkTieredDeletable leaves a replicated volume's upload-time
/// read-only mark alone: the shell still has to copy the uploaded
/// replica's .idx to the others, and a tombstone written before that copy
/// would reach one replica only.
#[test]
fn test_update_remote_files_keeps_a_replicated_volume_refusing_deletes() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let v = make_test_volume(dir);
v.super_block.write().unwrap().replica_placement =
crate::storage::super_block::ReplicaPlacement::from_string("001").unwrap();
v.set_no_write_or_delete(true); // the shell's pre-upload mark
v.update_remote_files(|files| files.push(Default::default()))
.unwrap();
assert!(v.has_remote_file());
assert!(
v.is_no_write_or_delete(),
"a replicated volume keeps refusing deletes until its next load"
);
assert!(!v.is_no_write_can_delete());
}
/// An I/O quarantine is not the tier upload's to lift: a live tier-up
/// leaves a quarantined volume's read-only mark alone, as Go's
/// MarkTieredDeletable does.
#[test]
fn test_update_remote_files_keeps_an_io_quarantine() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let v = make_test_volume(dir);
v.set_no_write_or_delete(true);
v.mark_io_quarantined();
v.update_remote_files(|files| files.push(Default::default()))
.unwrap();
assert!(v.has_remote_file());
assert!(v.is_no_write_or_delete());
assert!(!v.is_no_write_can_delete());
}
/// A refused tiered volume keeps OR_DELETE without CAN_DELETE; marking it
/// writable must resolve to the delete-only mode a load derives — the
/// remote .dat cannot take appends — not drop every protection.
#[test]
fn test_set_writable_on_a_refused_tiered_volume_stays_delete_only() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let v = make_test_volume(dir);
v.super_block.write().unwrap().replica_placement =
crate::storage::super_block::ReplicaPlacement::from_string("001").unwrap();
v.set_no_write_or_delete(true);
v.update_remote_files(|files| files.push(Default::default()))
.unwrap();
assert!(v.is_no_write_or_delete());
v.set_writable().unwrap();
assert!(v.is_read_only());
assert!(!v.no_write_flags().0);
assert!(v.no_write_flags().1);
}
// Tier-down clears the remote mode and publishes the volume as writable. The
// map it booted with is the read-only sorted one, whose put always fails, so
// without a rebuild the first write appends to .dat and then cannot be
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -24,7 +24,7 @@ require (
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/klauspost/compress v1.18.7 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
+2 -2
View File
@@ -38,8 +38,8 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -5,6 +5,6 @@ go 1.24.7
require github.com/segmentio/kafka-go v0.4.49
require (
github.com/klauspost/compress v1.18.7 // indirect
github.com/klauspost/compress v1.15.9 // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect
)
@@ -1,7 +1,7 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+2 -2
View File
@@ -1,10 +1,10 @@
module simple-consumer
go 1.24
go 1.21
require github.com/segmentio/kafka-go v0.4.47
require (
github.com/klauspost/compress v1.18.7 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect
)
+2 -2
View File
@@ -2,8 +2,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+32 -49
View File
@@ -12,51 +12,41 @@ import (
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
)
// The very first write against a fresh cluster must succeed: the assign that
// triggers volume growth waits for it, and when growth outlasts that wait the
// master sheds with a retryable 503/Retry-After. A normal client honors the
// hint and retries, so the test does the same within a deadline.
// The very first write against a fresh cluster must succeed in one shot: the
// assign that triggers volume growth waits for it instead of shedding itself
// with "volume growth in progress". No client retries — a single request per
// assign, like a plain HTTP client.
func TestColdStartFirstWrite(t *testing.T) {
c := StartCluster(t, 3)
// Each subtest uses its own replication so its volume layout starts cold.
t.Run("http_assign_and_write", func(t *testing.T) {
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(c.MasterURL() + "/dir/assign?replication=002")
if err != nil {
t.Fatalf("dir/assign: %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("dir/assign returned %d: %s", resp.StatusCode, body)
}
var assign struct {
Fid string `json:"fid"`
Url string `json:"url"`
Error string `json:"error"`
}
deadline := time.Now().Add(60 * time.Second)
for {
resp, err := client.Get(c.MasterURL() + "/dir/assign?replication=002")
if err != nil {
t.Fatalf("dir/assign: %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusServiceUnavailable && time.Now().Before(deadline) {
time.Sleep(time.Second)
continue
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("dir/assign returned %d: %s", resp.StatusCode, body)
}
if err := json.Unmarshal(body, &assign); err != nil {
t.Fatalf("parse assign response %q: %v", body, err)
}
if assign.Error != "" || assign.Fid == "" {
t.Fatalf("assign failed: %s", body)
}
break
if err := json.Unmarshal(body, &assign); err != nil {
t.Fatalf("parse assign response %q: %v", body, err)
}
if assign.Error != "" || assign.Fid == "" {
t.Fatalf("assign failed: %s", body)
}
content := []byte("cold start write")
@@ -67,28 +57,21 @@ func TestColdStartFirstWrite(t *testing.T) {
t.Run("grpc_assign", func(t *testing.T) {
grpcDialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
var err error
for deadline := time.Now().Add(60 * time.Second); ; {
err = pb.WithMasterClient(context.Background(), false, pb.ServerAddress(c.MasterAddress()), grpcDialOption, false,
func(client master_pb.SeaweedClient) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
resp, err := client.Assign(ctx, &master_pb.AssignRequest{Count: 1, Replication: "001"})
if err != nil {
return err
}
if resp.Error != "" || resp.Fid == "" {
return fmt.Errorf("assign failed: %+v", resp)
}
return nil
})
if err == nil || status.Code(err) != codes.ResourceExhausted || time.Now().After(deadline) {
break
}
time.Sleep(time.Second)
}
err := pb.WithMasterClient(context.Background(), false, pb.ServerAddress(c.MasterAddress()), grpcDialOption, false,
func(client master_pb.SeaweedClient) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
resp, err := client.Assign(ctx, &master_pb.AssignRequest{Count: 1, Replication: "001"})
if err != nil {
return err
}
if resp.Error != "" || resp.Fid == "" {
return fmt.Errorf("assign failed: %+v", resp)
}
return nil
})
if err != nil {
t.Fatalf("grpc assign on cold cluster: %v", err)
t.Fatalf("single grpc assign on cold cluster: %v", err)
}
})
}
+3 -4
View File
@@ -3,7 +3,7 @@
An integration test for [OLake](https://github.com/datazip-inc/olake) against
SeaweedFS's Iceberg REST Catalog, in the same shape as `catalog_clickhouse`.
## Why OLake, given we already test other engines
## Why OLake, given we already test five engines
Two things here are covered by nothing else in this directory.
@@ -73,9 +73,8 @@ Nothing in the destination config is SeaweedFS-specific:
```
`catalog_type` is the generic `rest`, auth is the standard OAuth2
client-credentials flow, and the test config sets `s3_path_style` explicitly;
OLake also defaults to path style whenever `s3_endpoint` is non-empty, so
either form works. As of
client-credentials flow, and `s3_path_style` does not even need setting —
OLake turns it on by itself whenever `s3_endpoint` is non-empty. As of
OLake v0.10.1 this works with no change on either side.
## Running it
@@ -376,8 +376,8 @@ func (env *TestEnvironment) execSQL(t *testing.T, sqlText string) {
// writeOLakeConfigs writes the source and destination configs. The destination
// is the point of this test: nothing in it is SeaweedFS-specific. catalog_type
// is the generic "rest", auth is the standard OAuth2 client-credentials flow,
// and path-style access is set explicitly here; OLake also turns it on by
// itself whenever s3_endpoint is non-empty, so either form works.
// and path-style access is not even set here because OLake turns it on by
// itself whenever s3_endpoint is non-empty.
func (env *TestEnvironment) writeOLakeConfigs(t *testing.T) {
t.Helper()
+1 -1
View File
@@ -342,7 +342,7 @@ dataset = lance.dataset(table.location, storage_options=table.storage_options)`
}
<label class="btn btn-outline-primary w-100 text-start p-3" for="s3tablesBucketFormatIceberg">
<span class="d-block fw-semibold"><i class="bi bi-snow me-1"></i>Iceberg</span>
<span class="d-block small text-muted mt-1">Served over the Iceberg REST catalog. Spark, Trino, PyIceberg, DuckDB, OLake Go.</span>
<span class="d-block small text-muted mt-1">Served over the Iceberg REST catalog. Spark, Trino, PyIceberg, DuckDB.</span>
</label>
</div>
<div class="col-6">
@@ -636,7 +636,7 @@ dataset = lance.dataset(table.location, storage_options=table.storage_options)`)
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<label class=\"btn btn-outline-primary w-100 text-start p-3\" for=\"s3tablesBucketFormatIceberg\"><span class=\"d-block fw-semibold\"><i class=\"bi bi-snow me-1\"></i>Iceberg</span> <span class=\"d-block small text-muted mt-1\">Served over the Iceberg REST catalog. Spark, Trino, PyIceberg, DuckDB, OLake Go.</span></label></div><div class=\"col-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<label class=\"btn btn-outline-primary w-100 text-start p-3\" for=\"s3tablesBucketFormatIceberg\"><span class=\"d-block fw-semibold\"><i class=\"bi bi-snow me-1\"></i>Iceberg</span> <span class=\"d-block small text-muted mt-1\">Served over the Iceberg REST catalog. Spark, Trino, PyIceberg, DuckDB.</span></label></div><div class=\"col-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
-19
View File
@@ -384,16 +384,6 @@ func processUpdateEvent(
}
glog.V(0).Infof("never replicated, uploading %s", remote_storage.FormatLocation(dest))
}
if proto.Equal(oldDest, dest) && isDroppedLocalCopy(message.NewEntry) {
// The update dropped the local copy: remote.uncache, eviction under
// storage pressure, or remote.meta.sync pulling a newer remote
// version. Each clears the sync stamp, so shouldSendToRemote reads
// the entry as dirty when the remote mtime shares its second. The
// content is the object at this very key, and uploading the chunkless
// entry would replace it with zero bytes.
glog.V(2).Infof("skipping update of remote-only entry: %+v", resp)
return nil
}
glog.V(2).Infof("update: %+v", resp)
if !proto.Equal(oldDest, dest) {
// A renamed entry that holds no local data now (the snapshot was
@@ -498,15 +488,6 @@ func isRemoteOnly(entry *filer_pb.Entry) bool {
return entry != nil && !filer.HasData(entry) && entry.IsInRemoteOnly()
}
// isDroppedLocalCopy reports a remote-only entry whose size is still the
// remote object's: its local copy was dropped and the object describes it.
// Uncache and eviction keep the size, and remote.meta.sync sets it from the
// object. A truncate drops the chunks too but changes the size, and the
// truncated content has to reach the remote.
func isDroppedLocalCopy(entry *filer_pb.Entry) bool {
return isRemoteOnly(entry) && entry.GetAttributes().GetFileSize() == uint64(entry.RemoteEntry.RemoteSize)
}
// completeRemoteOnlyRename finishes a rename whose content exists only on the
// remote. When the destination object already holds what the entry's stamp
// describes (the rename ran before, its offset was not persisted, and
-200
View File
@@ -1722,206 +1722,6 @@ func TestMetadataOnlyUpdateRestampsSyncTs(t *testing.T) {
}
}
// The droppedCopy fixtures are a small synced file under a mount: a Delta log
// entry whose remote object was written in the second of its mtime, as a quick
// sync of a small file is.
const droppedCopyMtime = 1786096669
var (
droppedCopyDir = "/buckets/gold/sales/_delta_log"
droppedCopyName = "00000000000000000007.json"
droppedCopyMount = &remote_pb.RemoteStorageLocation{Name: "s3", Bucket: "gold", Path: "/"}
droppedCopyDest = &remote_pb.RemoteStorageLocation{Name: "s3", Bucket: "gold", Path: "/sales/_delta_log/" + droppedCopyName}
droppedCopySynced = &filer_pb.RemoteEntry{StorageName: "s3", RemoteETag: `"abc"`, RemoteSize: 1024, RemoteMtime: droppedCopyMtime, LastLocalSyncTsNs: droppedCopyMtime*1e9 + 500}
)
// droppedCopyStamp is the synced stamp with LastLocalSyncTsNs cleared, as
// remote.uncache and the eviction leave it.
func droppedCopyStamp() *filer_pb.RemoteEntry {
stamp := proto.Clone(droppedCopySynced).(*filer_pb.RemoteEntry)
stamp.LastLocalSyncTsNs = 0
return stamp
}
// droppedCopyEntry is the fixture file with the given stamp and chunks, sized
// as its remote object.
func droppedCopyEntry(stamp *filer_pb.RemoteEntry, chunks ...*filer_pb.FileChunk) *filer_pb.Entry {
entry := entryWith(droppedCopyName, stamp, chunks...)
entry.Attributes.FileSize = uint64(droppedCopySynced.RemoteSize)
return entry
}
func droppedCopyUpdate(oldEntry, newEntry *filer_pb.Entry) *filer_pb.SubscribeMetadataResponse {
return &filer_pb.SubscribeMetadataResponse{
Directory: droppedCopyDir,
EventNotification: &filer_pb.EventNotification{OldEntry: oldEntry, NewParentPath: droppedCopyDir, NewEntry: newEntry},
}
}
// TestDroppedLocalCopyIsNotUploaded: remote.uncache and the filer's eviction
// under storage pressure drop a synced entry's chunks and clear its sync
// stamp; remote.meta.sync does the same when it pulls a newer remote version.
// With the remote mtime in the entry's second, shouldSendToRemote falls back
// to the cleared stamp and reads the entry as dirty. The object at the same
// key is the entry's only content, so uploading the chunkless entry would
// replace it with a zero-byte object. The update is skipped, and the stamp
// stays cleared so remote.cache and eviction still see the entry as uncached.
func TestDroppedLocalCopyIsNotUploaded(t *testing.T) {
// the eviction's entry, built the way uncacheRemoteEntry builds it, and
// the event the way the filer's CreateEntry emits it
cached := &filer.Entry{
FullPath: util.NewFullPath(droppedCopyDir, droppedCopyName),
Attr: filer.Attr{Mtime: time.Unix(droppedCopyMtime, 0), Mode: 0660, FileSize: 1024},
Chunks: []*filer_pb.FileChunk{chunk("3,01", "e1")},
Remote: droppedCopySynced,
}
evicted := cached.ShallowClone()
evicted.Chunks = nil
evicted.Remote = proto.Clone(cached.Remote).(*filer_pb.RemoteEntry)
evicted.Remote.LastLocalSyncTsNs = 0
// remote.meta.sync found a newer object: its mtime becomes the entry's
newer := &filer_pb.RemoteEntry{StorageName: "s3", RemoteETag: `"def"`, RemoteSize: 4096, RemoteMtime: droppedCopyMtime + 60}
pulled := &filer_pb.Entry{Name: droppedCopyName, Attributes: &filer_pb.FuseAttributes{Mtime: droppedCopyMtime + 60, FileSize: 4096}, RemoteEntry: newer}
tests := []struct {
name string
oldEntry *filer_pb.Entry
newEntry *filer_pb.Entry
}{
{name: "remote.uncache", oldEntry: droppedCopyEntry(droppedCopySynced, chunk("3,01", "e1")), newEntry: droppedCopyEntry(droppedCopyStamp())},
{name: "eviction under storage pressure", oldEntry: cached.ToProtoEntry(), newEntry: evicted.ToProtoEntry()},
{name: "remote.meta.sync pulls a newer version", oldEntry: entryWith(droppedCopyName, droppedCopySynced, chunk("3,01", "e1")), newEntry: pulled},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp := droppedCopyUpdate(tt.oldEntry, tt.newEntry)
if !shouldSendToRemote(tt.newEntry) {
t.Fatal("precondition: the cleared stamp in the remote mtime's second reads as dirty, the bug's trigger")
}
if isMetadataOnlyUpdate(resp.Directory, resp.EventNotification) {
t.Fatal("precondition: the dropped chunks make the update a content change")
}
remote := &recordingRemote{}
filerClient := &stubFilerClient{entry: tt.newEntry}
if err := processUpdateEvent(filerClient, filerClient, "", remote, "/buckets/gold", droppedCopyMount, resp); err != nil {
t.Fatal(err)
}
if len(remote.writes) != 0 {
t.Errorf("writes = %+v, want none: the remote object is the entry's content and an upload would zero it", remote.writes)
}
if len(remote.deletes) != 0 || len(remote.metadataUpdates) != 0 {
t.Errorf("deletes = %+v, metadata updates = %+v, want the remote object left alone", remote.deletes, remote.metadataUpdates)
}
if filerClient.updates != 0 {
t.Errorf("stamped the entry %d times, want 0: a stamp would mark the uncached entry as cached", filerClient.updates)
}
})
}
t.Run("a metadata edit of a remote-only entry still reaches the remote", func(t *testing.T) {
tagged := droppedCopyEntry(droppedCopyStamp())
tagged.Extended = map[string][]byte{"X-Amz-Tagging-tier": []byte("gold")}
resp := droppedCopyUpdate(droppedCopyEntry(droppedCopyStamp()), tagged)
remote := &recordingRemote{}
filerClient := &stubFilerClient{entry: tagged}
if err := processUpdateEvent(filerClient, filerClient, "", remote, "/buckets/gold", droppedCopyMount, resp); err != nil {
t.Fatal(err)
}
if len(remote.metadataUpdates) != 1 || len(remote.writes) != 0 {
t.Errorf("metadata updates = %+v, writes = %+v, want one metadata update and no upload", remote.metadataUpdates, remote.writes)
}
})
}
// TestSameSecondRewriteIsStillUploaded: a rewrite that lands in the remote
// mtime's second holds local data, so the skip for dropped local copies does
// not apply and it is uploaded and restamped (#11715).
func TestSameSecondRewriteIsStillUploaded(t *testing.T) {
stale := proto.Clone(droppedCopySynced).(*filer_pb.RemoteEntry)
stale.LastLocalSyncTsNs = droppedCopyMtime*1e9 + 100
rewrite := func(stamp *filer_pb.RemoteEntry) *filer_pb.Entry {
entry := entryWith(droppedCopyName, stamp, chunk("4,02", "e2"))
entry.Attributes.MtimeNs = 337
return entry
}
tests := []struct {
name string
oldEntry *filer_pb.Entry
newEntry *filer_pb.Entry
}{
// Filer.UpdateEntry clears the stamp of a content change
{name: "synced entry, stamp cleared by the filer", oldEntry: entryWith(droppedCopyName, droppedCopySynced, chunk("3,01", "e1")), newEntry: rewrite(droppedCopyStamp())},
{name: "synced entry, stamp older than the rewrite", oldEntry: entryWith(droppedCopyName, stale, chunk("3,01", "e1")), newEntry: rewrite(stale)},
{name: "uncached entry", oldEntry: entryWith(droppedCopyName, droppedCopyStamp()), newEntry: rewrite(droppedCopyStamp())},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.newEntry.Attributes.Mtime != tt.newEntry.RemoteEntry.RemoteMtime {
t.Fatal("precondition: the rewrite lands in the remote mtime's second")
}
remote := &recordingRemote{}
filerClient := &stubFilerClient{entry: tt.newEntry}
if err := processUpdateEvent(filerClient, filerClient, "", remote, "/buckets/gold", droppedCopyMount, droppedCopyUpdate(tt.oldEntry, tt.newEntry)); err != nil {
t.Fatal(err)
}
if len(remote.writes) != 1 || !proto.Equal(remote.writes[0], droppedCopyDest) {
t.Fatalf("writes = %+v, want the rewrite uploaded to %s", remote.writes, remote_storage.FormatLocation(droppedCopyDest))
}
if !slices.Equal(remote.writtenChunks[0], []string{"4,02"}) {
t.Errorf("uploaded chunks %v, want the rewrite's [4,02]", remote.writtenChunks[0])
}
if filerClient.updates != 1 {
t.Errorf("stamped the entry %d times, want 1 after the upload", filerClient.updates)
}
})
}
}
// TestTruncateToZeroIsUploaded: a FUSE truncate of a cached file to zero
// (truncateEntry, or SetAttr with size 0) drops the chunks and keeps the
// sized RemoteEntry, so the entry looks remote-only. Its size is no longer
// the remote object's, and the empty file has to replace the object.
func TestTruncateToZeroIsUploaded(t *testing.T) {
truncated := func(mtime int64, mtimeNs int32) *filer_pb.Entry {
// the filer clears the stamp of a content change
entry := entryWith(droppedCopyName, droppedCopyStamp())
entry.Attributes.Mtime, entry.Attributes.MtimeNs = mtime, mtimeNs
return entry
}
tests := []struct {
name string
newEntry *filer_pb.Entry
}{
{name: "in a later second", newEntry: truncated(droppedCopyMtime+5, 0)},
{name: "in the remote mtime's second", newEntry: truncated(droppedCopyMtime, 337)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !isRemoteOnly(tt.newEntry) {
t.Fatal("precondition: the truncated entry looks remote-only")
}
remote := &recordingRemote{}
filerClient := &stubFilerClient{entry: tt.newEntry}
resp := droppedCopyUpdate(droppedCopyEntry(droppedCopySynced, chunk("3,01", "e1")), tt.newEntry)
if err := processUpdateEvent(filerClient, filerClient, "", remote, "/buckets/gold", droppedCopyMount, resp); err != nil {
t.Fatal(err)
}
if len(remote.writes) != 1 || !proto.Equal(remote.writes[0], droppedCopyDest) {
t.Fatalf("writes = %+v, want the empty file uploaded to %s", remote.writes, remote_storage.FormatLocation(droppedCopyDest))
}
if len(remote.writtenChunks[0]) != 0 {
t.Errorf("uploaded chunks %v, want none: a zero-byte object", remote.writtenChunks[0])
}
if filerClient.updates != 1 {
t.Errorf("stamped the entry %d times, want 1 after the upload", filerClient.updates)
}
})
}
}
func TestCompleteRemoteOnlyRenameKeepsAReusedOldKey(t *testing.T) {
// A was renamed to B, then D to A before the sync got to the first
// rename: the object at A is now D's, and the only copy of it
+4 -4
View File
@@ -762,8 +762,8 @@ type icebergCredentialVendor struct {
server *s3api.S3ApiServer
}
func (v icebergCredentialVendor) VendTableCredentials(ctx context.Context, principal, bucket, prefix string, readOnly bool) (*iceberg.VendedCredentials, error) {
credentials, err := v.server.VendTableCredentials(ctx, principal, bucket, prefix, readOnly)
func (v icebergCredentialVendor) VendTableCredentials(ctx context.Context, principal, bucket, prefix string) (*iceberg.VendedCredentials, error) {
credentials, err := v.server.VendTableCredentials(ctx, principal, bucket, prefix)
if err != nil || credentials == nil {
return nil, err
}
@@ -781,8 +781,8 @@ type lanceCredentialVendor struct {
server *s3api.S3ApiServer
}
func (v lanceCredentialVendor) VendTableCredentials(ctx context.Context, principal, bucket, prefix string, readOnly bool) (*lance.VendedCredentials, error) {
credentials, err := v.server.VendTableCredentials(ctx, principal, bucket, prefix, readOnly)
func (v lanceCredentialVendor) VendTableCredentials(ctx context.Context, principal, bucket, prefix string) (*lance.VendedCredentials, error) {
credentials, err := v.server.VendTableCredentials(ctx, principal, bucket, prefix)
if err != nil || credentials == nil {
return nil, err
}
+4 -8
View File
@@ -3,7 +3,6 @@ package command
import (
"fmt"
"os"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb"
@@ -13,11 +12,10 @@ import (
)
var (
shellOptions shell.ShellOptions
shellInitialFiler *string
shellCluster *string
shellDebug *bool
shellConnectTimeout *time.Duration
shellOptions shell.ShellOptions
shellInitialFiler *string
shellCluster *string
shellDebug *bool
)
func init() {
@@ -27,7 +25,6 @@ func init() {
shellInitialFiler = cmdShell.Flag.String("filer", "", "filer host and port for initial connection, e.g. localhost:8888")
shellCluster = cmdShell.Flag.String("cluster", "", "cluster defined in shell.toml")
shellDebug = cmdShell.Flag.Bool("debug", false, "print informational logs to stderr")
shellConnectTimeout = cmdShell.Flag.Duration("connectTimeout", 30*time.Second, "timeout for the initial master connection")
}
var cmdShell = &Command{
@@ -67,7 +64,6 @@ func runShell(command *Command, args []string) bool {
}
shellOptions.FilerAddress = pb.ServerAddress(filerAddress)
shellOptions.Debug = *shellDebug
shellOptions.ConnectTimeout = *shellConnectTimeout
if shellOptions.Debug {
fmt.Fprintf(os.Stderr, "master: %s filer: %s\n", *shellOptions.Masters, shellOptions.FilerAddress)
}
+1 -8
View File
@@ -120,20 +120,13 @@ func (store *IamGrpcStore) CreateAccessKey(ctx context.Context, username string,
}
func (store *IamGrpcStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
err := store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
return store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
_, err := client.DeleteAccessKey(ctx, &iam_pb.DeleteAccessKeyRequest{
Username: username,
AccessKey: accessKey,
})
return err
})
// The filer-side handler returns gRPC NotFound when the user or the key is
// absent; translate back to the sentinel so callers can use
// errors.Is(err, credential.ErrAccessKeyNotFound) uniformly across stores.
if err != nil && status.Code(err) == codes.NotFound {
return credential.ErrAccessKeyNotFound
}
return err
}
// AttachUserPolicy attaches a managed policy to a user by policy name
-48
View File
@@ -2,7 +2,6 @@ package grpc
import (
"context"
"errors"
"fmt"
"net"
"strings"
@@ -107,50 +106,3 @@ func TestIamGrpcStore_AdminBearerToken(t *testing.T) {
}
})
}
// TestIamGrpcStore_DeleteAccessKeyNotFound pins the not-found translation:
// the filer reports a missing user or key as gRPC NotFound, and the store must
// surface it as credential.ErrAccessKeyNotFound so repeat deletes stay a no-op
// for IAM dispatchers that tolerate the sentinel.
func TestIamGrpcStore_DeleteAccessKeyNotFound(t *testing.T) {
const goodKey = "iam-admin-itest-key"
cm, err := credential.NewCredentialManager(credential.StoreTypeMemory, nil, "")
if err != nil {
t.Fatalf("NewCredentialManager: %v", err)
}
defer cm.Shutdown()
iamSrv := weed_server.NewIamGrpcServer(cm, security.SigningKey(goodKey))
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
grpcSrv := grpc.NewServer()
iam_pb.RegisterSeaweedIdentityAccessManagementServer(grpcSrv, iamSrv)
go func() { _ = grpcSrv.Serve(lis) }()
t.Cleanup(grpcSrv.Stop)
_, portStr, err := net.SplitHostPort(lis.Addr().String())
if err != nil {
t.Fatalf("split listener addr: %v", err)
}
serverAddr := pb.ServerAddress(fmt.Sprintf("127.0.0.1:0.%s", portStr))
store := &IamGrpcStore{}
store.SetFilerAddressFunc(func() pb.ServerAddress { return serverAddr }, grpc.WithTransportCredentials(insecure.NewCredentials()))
store.SetAdminSigning(security.SigningKey(goodKey), 0)
ctx := context.Background()
if err := store.CreateUser(ctx, &iam_pb.Identity{Name: "alice"}); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := store.DeleteAccessKey(ctx, "alice", "AKIAMISSING0000000001"); !errors.Is(err, credential.ErrAccessKeyNotFound) {
t.Fatalf("DeleteAccessKey with missing key: got %v, want ErrAccessKeyNotFound", err)
}
if err := store.DeleteAccessKey(ctx, "ghost", "AKIAMISSING0000000001"); !errors.Is(err, credential.ErrAccessKeyNotFound) {
t.Fatalf("DeleteAccessKey with missing user: got %v, want ErrAccessKeyNotFound", err)
}
}
+2 -14
View File
@@ -142,14 +142,6 @@ func (store *MemoryStore) UpdateUser(ctx context.Context, username string, ident
return credential.ErrUserNotFound
}
if identity.Name != username {
// Rename: the destination name must not already be taken, matching
// RenameUser's collision check.
if _, clash := store.users[identity.Name]; clash {
return credential.ErrUserAlreadyExists
}
}
// Remove old access keys from index
for _, cred := range existingUser.Credentials {
delete(store.accessKeys, cred.AccessKey)
@@ -164,15 +156,11 @@ func (store *MemoryStore) UpdateUser(ctx context.Context, username string, ident
// Deep copy to avoid mutation issues
identityCopy := store.deepCopyIdentity(identity)
if identityCopy.Name != username {
// Rename: re-key the record, the way filer_etc does (delete old, save new).
delete(store.users, username)
}
store.users[identityCopy.Name] = identityCopy
store.users[username] = identityCopy
// Re-index access keys
for _, cred := range identity.Credentials {
store.accessKeys[cred.AccessKey] = identityCopy.Name
store.accessKeys[cred.AccessKey] = username
}
return nil
@@ -378,82 +378,3 @@ func TestMemoryStoreServiceAccountByAccessKey(t *testing.T) {
t.Errorf("Expected ErrAccessKeyNotFound after delete, got %v", err)
}
}
// TestMemoryStoreUpdateUserRename: UpdateUser with an identity whose Name
// differs from the key must re-key the record, the way filer_etc does
// (delete old, save new). The IAM rename path relies on this.
func TestMemoryStoreUpdateUserRename(t *testing.T) {
store := &MemoryStore{}
if err := store.Initialize(util.GetViper(), "credential."); err != nil {
t.Fatalf("Failed to initialize store: %v", err)
}
ctx := context.Background()
err := store.CreateUser(ctx, &iam_pb.Identity{
Name: "oldname",
Credentials: []*iam_pb.Credential{{AccessKey: "access123", SecretKey: "secret123"}},
})
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
err = store.UpdateUser(ctx, "oldname", &iam_pb.Identity{
Name: "newname",
Credentials: []*iam_pb.Credential{{AccessKey: "access123", SecretKey: "secret123"}},
})
if err != nil {
t.Fatalf("Failed to rename user via UpdateUser: %v", err)
}
if _, err := store.GetUser(ctx, "oldname"); err != credential.ErrUserNotFound {
t.Fatalf("Expected ErrUserNotFound for old name, got %v", err)
}
renamed, err := store.GetUser(ctx, "newname")
if err != nil {
t.Fatalf("Failed to get renamed user: %v", err)
}
if renamed.Name != "newname" {
t.Errorf("Expected 'newname', got '%s'", renamed.Name)
}
byKey, err := store.GetUserByAccessKey(ctx, "access123")
if err != nil {
t.Fatalf("Access key must resolve to the renamed user: %v", err)
}
if byKey.Name != "newname" {
t.Errorf("Expected access key owner 'newname', got '%s'", byKey.Name)
}
}
// TestMemoryStoreUpdateUserRenameCollision: a rename whose destination name is
// taken must fail rather than overwrite the existing user.
func TestMemoryStoreUpdateUserRenameCollision(t *testing.T) {
store := &MemoryStore{}
if err := store.Initialize(util.GetViper(), "credential."); err != nil {
t.Fatalf("Failed to initialize store: %v", err)
}
ctx := context.Background()
if err := store.CreateUser(ctx, &iam_pb.Identity{Name: "alice"}); err != nil {
t.Fatalf("Failed to create alice: %v", err)
}
if err := store.CreateUser(ctx, &iam_pb.Identity{
Name: "bob",
Credentials: []*iam_pb.Credential{{AccessKey: "bobkey123", SecretKey: "secret123"}},
}); err != nil {
t.Fatalf("Failed to create bob: %v", err)
}
err := store.UpdateUser(ctx, "alice", &iam_pb.Identity{Name: "bob"})
if err != credential.ErrUserAlreadyExists {
t.Fatalf("Expected ErrUserAlreadyExists, got %v", err)
}
// Both users must be intact.
if _, err := store.GetUser(ctx, "alice"); err != nil {
t.Fatalf("alice must still exist: %v", err)
}
bob, err := store.GetUserByAccessKey(ctx, "bobkey123")
if err != nil || bob.Name != "bob" {
t.Fatalf("bob's access key must still resolve to bob: %v", err)
}
}
+7 -9
View File
@@ -104,15 +104,13 @@ var baseS3ActionMap = map[string]string{
"DeleteObjectVersion": s3_constants.ACTION_WRITE,
"GetObjectVersion": s3_constants.ACTION_READ,
// Tagging operations
"GetObjectTagging": s3_constants.ACTION_TAGGING,
"GetObjectVersionTagging": s3_constants.ACTION_TAGGING,
"PutObjectTagging": s3_constants.ACTION_TAGGING,
"PutObjectVersionTagging": s3_constants.ACTION_TAGGING,
"DeleteObjectTagging": s3_constants.ACTION_TAGGING,
"DeleteObjectVersionTagging": s3_constants.ACTION_TAGGING,
"GetBucketTagging": s3_constants.ACTION_TAGGING,
"PutBucketTagging": s3_constants.ACTION_TAGGING,
"DeleteBucketTagging": s3_constants.ACTION_TAGGING,
"GetObjectTagging": s3_constants.ACTION_TAGGING,
"GetObjectVersionTagging": s3_constants.ACTION_TAGGING,
"PutObjectTagging": s3_constants.ACTION_TAGGING,
"DeleteObjectTagging": s3_constants.ACTION_TAGGING,
"GetBucketTagging": s3_constants.ACTION_TAGGING,
"PutBucketTagging": s3_constants.ACTION_TAGGING,
"DeleteBucketTagging": s3_constants.ACTION_TAGGING,
// ACL operations
"PutObjectAcl": s3_constants.ACTION_WRITE_ACP,
"GetObjectAcl": s3_constants.ACTION_READ_ACP,
-72
View File
@@ -2,16 +2,10 @@ package iamapi
import (
"context"
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
@@ -147,69 +141,3 @@ func TestIamManagementAllowedForAdmin(t *testing.T) {
"", "Action=CreateUser&UserName=new-user&Version=2010-05-08")
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
// The implicit UserName must come from the identity authentication verified,
// never from re-parsing the Authorization header: a request can carry presigned
// credentials for one user alongside a junk SigV4 header naming another.
func TestImplicitUserNameUsesVerifiedIdentity(t *testing.T) {
server := newAuthzTestServer(t)
expires := strconv.FormatInt(time.Now().Add(10*time.Minute).Unix(), 10)
sts := "POST\n\napplication/x-www-form-urlencoded\n" + expires + "\n/"
mac := hmac.New(sha1.New, []byte("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"))
mac.Write([]byte(sts))
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
q := url.Values{"AWSAccessKeyId": {"AKIAIOSFODNN7EXAMPLE"}, "Expires": {expires}, "Signature": {sig}}
post := func(t *testing.T, body string) (int, string) {
t.Helper()
req, err := http.NewRequest(http.MethodPost, server.URL+"/?"+q.Encode(), strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIATESTFAKEADMIN0001/20261021/us-east-1/iam/aws4_request, SignedHeaders=host, Signature=00")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
t.Logf("%d %s", resp.StatusCode, b)
return resp.StatusCode, string(b)
}
for _, action := range []string{"ListAccessKeys", "CreateAccessKey", "GetUser"} {
t.Run(action, func(t *testing.T) {
code, body := post(t, "Action="+action+"&Version=2010-05-08")
assert.Equal(t, http.StatusOK, code)
assert.NotContains(t, body, "iam_admin")
assert.Contains(t, body, "power_user")
})
}
// ListUserPolicies is not self-service: a caller without IAM rights is
// denied whichever name the implicit resolution lands on.
t.Run("ListUserPolicies", func(t *testing.T) {
code, body := post(t, "Action=ListUserPolicies&Version=2010-05-08")
assert.Equal(t, http.StatusForbidden, code)
assert.NotContains(t, body, "iam_admin")
})
// A write aimed at the header-named admin's key resolves to the verified
// caller instead: the key is not power_user's, so it is not found.
t.Run("UpdateAccessKey", func(t *testing.T) {
code, body := post(t, "Action=UpdateAccessKey&AccessKeyId=AKIATESTFAKEADMIN0001&Status=Inactive&Version=2010-05-08")
assert.Equal(t, http.StatusNotFound, code)
assert.Contains(t, body, "NoSuchEntity")
})
t.Run("DeleteAccessKey", func(t *testing.T) {
code, _ := post(t, "Action=DeleteAccessKey&AccessKeyId=AKIATESTFAKEADMIN0001&Version=2010-05-08")
assert.Equal(t, http.StatusOK, code)
})
// The admin key the junk header named is untouched: still present and active.
resp := postSignedIamAction(t, server.URL, "AKIATESTFAKEADMIN0001", "testAdminSecretFake0000000000000000000000",
"", "Action=ListAccessKeys&Version=2010-05-08")
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Contains(t, string(body), "AKIATESTFAKEADMIN0001")
assert.Contains(t, string(body), "<Status>Active</Status>")
}
+41 -210
View File
@@ -4,7 +4,6 @@ package iamapi
// Common IAM types and helpers are imported from the shared weed/iam package.
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -15,14 +14,12 @@ import (
"sync"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/glog"
iamlib "github.com/seaweedfs/seaweedfs/weed/iam"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
)
@@ -1266,128 +1263,53 @@ func (iama *IamApiServer) DeleteAccessKey(s3cfg *iam_pb.S3ApiConfiguration, valu
return resp
}
// handleImplicitUsername adds the name of the authenticated caller to values
// when 'UserName' is not specified, per AWS: "If you do not specify a user name,
// IAM determines the user name implicitly based on the Amazon Web Services
// access key ID signing the request." The name comes from the identity the
// authentication middleware verified, which the request context carries; the
// Authorization header itself is attacker-controlled and is never re-parsed.
// handleImplicitUsername adds username who signs the request to values if 'username' is not specified.
// According to AWS documentation: "If you do not specify a user name, IAM determines the user name
// implicitly based on the Amazon Web Services access key ID signing the request."
// This function extracts the AccessKeyId from the SigV4 credential and looks up the corresponding
// identity name in the credential store.
func (iama *IamApiServer) handleImplicitUsername(r *http.Request, values url.Values) {
if values.Get("UserName") != "" {
if len(r.Header["Authorization"]) == 0 || values.Get("UserName") != "" {
return
}
if name := s3_constants.GetIdentityNameFromContext(r); name != "" {
values.Set("UserName", name)
// Log presence of auth header without exposing sensitive signature material
glog.V(4).Infof("Authorization header present, extracting access key")
// Parse AWS SigV4 Authorization header format:
// "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/iam/aws4_request, ..."
s := strings.Split(r.Header["Authorization"][0], "Credential=")
if len(s) < 2 {
return
}
}
// targetedCredentialManager returns the credential manager through which a change to the named
// identity is written on its own instead of via SaveConfiguration, or nil when this server has
// none, or the identity is static: static identities live outside the store and keep the
// full-save path.
func (iama *IamApiServer) targetedCredentialManager(userName string) *credential.CredentialManager {
s = strings.Split(s[1], ",")
if len(s) < 1 {
return
}
s = strings.Split(s[0], "/")
if len(s) < 1 {
return
}
// s[0] is the AccessKeyId
accessKeyId := s[0]
if accessKeyId == "" {
return
}
// Nil-guard: ensure iam is initialized before lookup
if iama.iam == nil {
return nil
glog.V(4).Infof("IAM not initialized, cannot look up access key")
return
}
cm := iama.iam.GetCredentialManager()
if cm == nil || cm.IsStaticIdentity(userName) {
return nil
}
return cm
}
// findIdentityByName returns the identity named userName in s3cfg, or nil.
func findIdentityByName(s3cfg *iam_pb.S3ApiConfiguration, userName string) *iam_pb.Identity {
for _, ident := range s3cfg.Identities {
if ident.Name == userName {
return ident
// Look up the identity by access key to get the username
identity, _, found := iama.iam.LookupByAccessKey(accessKeyId)
if !found {
// Mask access key in logs - show only first 4 chars
maskedKey := accessKeyId
if len(accessKeyId) > 4 {
maskedKey = accessKeyId[:4] + "***"
}
glog.V(4).Infof("Access key %s not found in credential store", maskedKey)
return
}
return nil
}
// groupsWithMember returns the groups in s3cfg that list userName as a member. Taken before a
// handler renames or removes the user, it names the group records that handler will change.
func groupsWithMember(s3cfg *iam_pb.S3ApiConfiguration, userName string) []*iam_pb.Group {
var groups []*iam_pb.Group
for _, g := range s3cfg.Groups {
for _, m := range g.Members {
if m == userName {
groups = append(groups, g)
break
}
}
}
return groups
}
// serviceAccountsOf returns the service accounts in s3cfg whose parent is userName.
func serviceAccountsOf(s3cfg *iam_pb.S3ApiConfiguration, userName string) []*iam_pb.ServiceAccount {
var accounts []*iam_pb.ServiceAccount
for _, sa := range s3cfg.ServiceAccounts {
if sa.ParentUser == userName {
accounts = append(accounts, sa)
}
}
return accounts
}
// persistIdentity writes the one identity a handler changed in s3cfg through the credential
// manager. The store writes that identity's record and propagates it alone; the other
// identities are not rewritten.
func persistIdentity(ctx context.Context, cm *credential.CredentialManager, s3cfg *iam_pb.S3ApiConfiguration, userName string) error {
ident := findIdentityByName(s3cfg, userName)
if ident == nil {
return credential.ErrUserNotFound
}
return cm.UpdateUser(ctx, userName, ident)
}
// persistRenamedIdentity writes a user renamed from oldName to newName by UpdateUser through the
// credential manager: the identity, the groups that listed the old name and the service accounts
// it parented. UpdateUser under the old name moves the record in stores that treat a changed Name
// as a rename (filer_etc, memory), and the propagating store propagates the new identity and the
// old name's removal; UpdateUser already moved the inline policies through PutPolicies. A store
// that did not move the record still holds the user under the old name; the caller is told so
// (false) and keeps the full save, which is what renames such a store today.
func persistRenamedIdentity(ctx context.Context, cm *credential.CredentialManager, s3cfg *iam_pb.S3ApiConfiguration, oldName, newName string, groups []*iam_pb.Group, accounts []*iam_pb.ServiceAccount) (bool, error) {
ident := findIdentityByName(s3cfg, newName)
if ident == nil {
return false, credential.ErrUserNotFound
}
if err := cm.UpdateUser(ctx, oldName, ident); err != nil {
return false, err
}
if _, err := cm.GetUser(ctx, newName); err != nil {
return false, nil
}
for _, g := range groups {
if err := cm.UpdateGroup(ctx, g); err != nil {
return false, fmt.Errorf("update group %s: %w", g.Name, err)
}
}
for _, sa := range accounts {
if err := cm.UpdateServiceAccount(ctx, sa.Id, sa); err != nil {
return false, fmt.Errorf("update service account %s: %w", sa.Id, err)
}
}
return true, nil
}
// deleteIdentity removes a user deleted by DeleteUser through the credential manager: its record
// (the store propagates the one removal) and the groups that listed it, whose membership
// DeleteUser already changed in s3cfg. DeleteUser already removed the inline policies through
// PutPolicies.
func deleteIdentity(ctx context.Context, cm *credential.CredentialManager, userName string, groups []*iam_pb.Group) error {
if err := cm.DeleteUser(ctx, userName); err != nil {
return err
}
for _, g := range groups {
if err := cm.UpdateGroup(ctx, g); err != nil {
return fmt.Errorf("update group %s: %w", g.Name, err)
}
}
return nil
values.Set("UserName", identity.Name)
}
func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
@@ -1411,9 +1333,6 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
glog.V(4).Infof("DoActions: %+v", iamlib.RedactSensitiveFormValues(values))
var response iamlib.RequestIDSetter
changed := true
// persisted is set by the actions that wrote one identity through the credential manager
// instead of SaveConfiguration; the in-memory identity maps are reloaded after them too.
persisted := false
switch r.Form.Get("Action") {
case "ListUsers":
response = iama.ListUsers(s3cfg, values)
@@ -1437,7 +1356,6 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
}
}
case "GetUser":
iama.handleImplicitUsername(r, values)
userName := values.Get("UserName")
var err *IamError
response, err = iama.GetUser(s3cfg, userName)
@@ -1447,107 +1365,40 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
}
changed = false
case "UpdateUser":
userName, newUserName := values.Get("UserName"), values.Get("NewUserName")
// Taken before the rename: the records the handler changes besides the identity.
groups := groupsWithMember(s3cfg, userName)
accounts := serviceAccountsOf(s3cfg, userName)
var err *IamError
response, err = iama.UpdateUser(s3cfg, values)
if err != nil {
writeIamErrorResponse(w, r, reqID, err)
return
}
// Write the renamed identity, its groups and service accounts through the credential
// manager instead of rewriting every identity via SaveConfiguration. A request that
// renames nothing changed nothing, so there is nothing to write either way.
if cm := iama.targetedCredentialManager(userName); cm != nil {
renamed := true
if newUserName != "" && newUserName != userName {
var err error
renamed, err = persistRenamedIdentity(r.Context(), cm, s3cfg, userName, newUserName, groups, accounts)
if err != nil {
writeIamErrorResponse(w, r, reqID, &IamError{Code: s3api.CredentialErrToIamErrCode(err), Error: err})
return
}
}
changed, persisted = !renamed, renamed
}
case "DeleteUser":
userName := values.Get("UserName")
groups := groupsWithMember(s3cfg, userName)
var err *IamError
response, err = iama.DeleteUser(s3cfg, userName)
if err != nil {
writeIamErrorResponse(w, r, reqID, err)
return
}
// Targeted delete: one record removed and one removal propagated, plus the groups whose
// membership changed, instead of rewriting every remaining identity.
if cm := iama.targetedCredentialManager(userName); cm != nil {
if err := deleteIdentity(r.Context(), cm, userName, groups); err != nil {
writeIamErrorResponse(w, r, reqID, &IamError{Code: s3api.CredentialErrToIamErrCode(err), Error: err})
return
}
changed, persisted = false, true
}
case "CreateAccessKey":
iama.handleImplicitUsername(r, values)
userName := values.Get("UserName")
resp, err := iama.CreateAccessKey(s3cfg, values)
var err *IamError
response, err = iama.CreateAccessKey(s3cfg, values)
if err != nil {
glog.Errorf("CreateAccessKey: %+v", err.Error)
writeIamErrorResponse(w, r, reqID, err)
return
}
response = resp
// Targeted write: credentialManager.CreateAccessKey adds the key to the one identity and
// propagates that identity alone. The handler creates the identity for a user it does
// not know; that case still needs the full save.
if cm := iama.targetedCredentialManager(userName); cm != nil {
if _, known := cm.GetUser(r.Context(), userName); known == nil {
cred := &iam_pb.Credential{
AccessKey: *resp.CreateAccessKeyResult.AccessKey.AccessKeyId,
SecretKey: *resp.CreateAccessKeyResult.AccessKey.SecretAccessKey,
Status: accessKeyStatusActive,
}
if err := cm.CreateAccessKey(r.Context(), userName, cred); err != nil {
writeIamErrorResponse(w, r, reqID, &IamError{Code: s3api.CredentialErrToIamErrCode(err), Error: err})
return
}
changed, persisted = false, true
}
}
case "DeleteAccessKey":
iama.handleImplicitUsername(r, values)
userName, accessKeyId := values.Get("UserName"), values.Get("AccessKeyId")
response = iama.DeleteAccessKey(s3cfg, values)
// Targeted write: credentialManager.DeleteAccessKey removes the key from the one identity
// and propagates that identity alone. The handler answers 200 for a key or user it does
// not know, so a not-found from the store is the same no-op it always was.
if cm := iama.targetedCredentialManager(userName); cm != nil {
err := cm.DeleteAccessKey(r.Context(), userName, accessKeyId)
if err != nil && !errors.Is(err, credential.ErrAccessKeyNotFound) && !errors.Is(err, credential.ErrUserNotFound) {
writeIamErrorResponse(w, r, reqID, &IamError{Code: s3api.CredentialErrToIamErrCode(err), Error: err})
return
}
changed, persisted = false, true
}
case "UpdateAccessKey":
iama.handleImplicitUsername(r, values)
userName := values.Get("UserName")
var err *IamError
response, err = iama.UpdateAccessKey(s3cfg, values)
if err != nil {
writeIamErrorResponse(w, r, reqID, err)
return
}
if cm := iama.targetedCredentialManager(userName); cm != nil {
if err := persistIdentity(r.Context(), cm, s3cfg, userName); err != nil {
writeIamErrorResponse(w, r, reqID, &IamError{Code: s3api.CredentialErrToIamErrCode(err), Error: err})
return
}
changed, persisted = false, true
}
case "CreatePolicy":
var err *IamError
response, err = iama.CreatePolicy(s3cfg, values)
@@ -1559,7 +1410,6 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
// The `changed` flag is false because this does not modify the main s3cfg.Identities configuration.
changed = false
case "PutUserPolicy":
userName := values.Get("UserName")
var err *IamError
response, err = iama.PutUserPolicy(s3cfg, values)
if err != nil {
@@ -1567,15 +1417,6 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
writeIamErrorResponse(w, r, reqID, err)
return
}
// The policy document is already stored through PutPolicies; what remains is the
// identity's recomputed Actions, written and propagated for that identity alone.
if cm := iama.targetedCredentialManager(userName); cm != nil {
if err := persistIdentity(r.Context(), cm, s3cfg, userName); err != nil {
writeIamErrorResponse(w, r, reqID, &IamError{Code: s3api.CredentialErrToIamErrCode(err), Error: err})
return
}
changed, persisted = false, true
}
case "GetUserPolicy":
var err *IamError
response, err = iama.GetUserPolicy(s3cfg, values)
@@ -1585,20 +1426,12 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
}
changed = false
case "DeleteUserPolicy":
userName := values.Get("UserName")
var err *IamError
response, err = iama.DeleteUserPolicy(s3cfg, values)
if err != nil {
writeIamErrorResponse(w, r, reqID, err)
return
}
if cm := iama.targetedCredentialManager(userName); cm != nil {
if err := persistIdentity(r.Context(), cm, s3cfg, userName); err != nil {
writeIamErrorResponse(w, r, reqID, &IamError{Code: s3api.CredentialErrToIamErrCode(err), Error: err})
return
}
changed, persisted = false, true
}
case "ListUserPolicies":
iama.handleImplicitUsername(r, values)
var err *IamError
@@ -1809,8 +1642,6 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
writeIamErrorResponse(w, r, reqID, &iamError)
return
}
}
if changed || persisted {
// Reload in-memory identity maps so subsequent LookupByAccessKey calls
// can see newly created or deleted keys immediately
if iama.iam != nil {
-302
View File
@@ -1,302 +0,0 @@
package iamapi
// Targeted-write regression tests: single-identity IAM actions must write only
// the identity they changed through the credential manager, not every identity
// via PutS3ApiConfiguration/SaveConfiguration.
// See https://github.com/seaweedfs/seaweedfs/issues/11865.
import (
"context"
"net/http"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/credential/memory"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// targetedRecordingStore wraps a memory store and counts the write methods
// the dispatcher used, so a test can assert the single-identity path was
// taken. It embeds the concrete store (rather than the CredentialStore
// interface) so optional interfaces like InlinePolicyStore stay visible
// through the wrapper.
type targetedRecordingStore struct {
*memory.MemoryStore
saves int
createAccessKeys int
deleteAccessKeys int
updateUsers int
deleteUsers int
updateGroups int
}
func (r *targetedRecordingStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
r.saves++
return r.MemoryStore.SaveConfiguration(ctx, config)
}
func (r *targetedRecordingStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
r.createAccessKeys++
return r.MemoryStore.CreateAccessKey(ctx, username, cred)
}
func (r *targetedRecordingStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
r.deleteAccessKeys++
return r.MemoryStore.DeleteAccessKey(ctx, username, accessKey)
}
func (r *targetedRecordingStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
r.updateUsers++
return r.MemoryStore.UpdateUser(ctx, username, identity)
}
func (r *targetedRecordingStore) DeleteUser(ctx context.Context, username string) error {
r.deleteUsers++
return r.MemoryStore.DeleteUser(ctx, username)
}
func (r *targetedRecordingStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
r.updateGroups++
return r.MemoryStore.UpdateGroup(ctx, group)
}
func targetedIamApiServer() (*IamApiServer, *credential.CredentialManager, *countingConfigSaver, *targetedRecordingStore) {
srv, cm, counter := iamApiServerWithCredentialManager()
rec := &targetedRecordingStore{MemoryStore: cm.Store.(*memory.MemoryStore)}
cm.Store = rec
return srv, cm, counter, rec
}
const targetedTestPolicy = `{
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::bench/*"]}]
}`
func TestIamApiCreateAccessKeyTargeted(t *testing.T) {
srv, cm, counter, rec := targetedIamApiServer()
ctx := context.Background()
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "alice"}))
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "bob"}))
req, _ := iam.New(session.New()).CreateAccessKeyRequest(&iam.CreateAccessKeyInput{UserName: aws.String("alice")})
_ = req.Build()
out := CreateAccessKeyResponse{}
resp, err := executeRequestWith(srv, req.HTTPRequest, &out)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, 0, counter.putCalled, "CreateAccessKey must not PutS3ApiConfiguration")
assert.Equal(t, 0, rec.saves)
assert.Equal(t, 1, rec.createAccessKeys)
ident, err := cm.GetUser(ctx, "alice")
require.NoError(t, err)
require.Len(t, ident.Credentials, 1)
assert.Equal(t, *out.CreateAccessKeyResult.AccessKey.AccessKeyId, ident.Credentials[0].AccessKey)
}
// CreateAccessKey for a user the store does not know still needs the full save:
// the handler creates the identity in the config, so there is no existing
// record to write a key into.
func TestIamApiCreateAccessKeyUnknownUserFallsBack(t *testing.T) {
srv, cm, counter, _ := targetedIamApiServer()
ctx := context.Background()
req, _ := iam.New(session.New()).CreateAccessKeyRequest(&iam.CreateAccessKeyInput{UserName: aws.String("ghost")})
_ = req.Build()
out := CreateAccessKeyResponse{}
resp, err := executeRequestWith(srv, req.HTTPRequest, &out)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, 1, counter.putCalled)
ident, err := cm.GetUser(ctx, "ghost")
require.NoError(t, err)
assert.Len(t, ident.Credentials, 1)
}
func TestIamApiDeleteAccessKeyTargeted(t *testing.T) {
srv, cm, counter, rec := targetedIamApiServer()
ctx := context.Background()
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{
Name: "alice",
Credentials: []*iam_pb.Credential{{AccessKey: "AKIATESTFAKEKEY00001", SecretKey: "testsecretfake"}},
}))
req, _ := iam.New(session.New()).DeleteAccessKeyRequest(&iam.DeleteAccessKeyInput{
UserName: aws.String("alice"),
AccessKeyId: aws.String("AKIATESTFAKEKEY00001"),
})
_ = req.Build()
out := DeleteAccessKeyResponse{}
resp, err := executeRequestWith(srv, req.HTTPRequest, &out)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, 0, counter.putCalled, "DeleteAccessKey must not PutS3ApiConfiguration")
assert.Equal(t, 1, rec.deleteAccessKeys)
ident, err := cm.GetUser(ctx, "alice")
require.NoError(t, err)
assert.Empty(t, ident.Credentials)
// Deleting a key that does not exist still answers 200 and stays targeted.
req, _ = iam.New(session.New()).DeleteAccessKeyRequest(&iam.DeleteAccessKeyInput{
UserName: aws.String("alice"),
AccessKeyId: aws.String("AKIATESTFAKEKEY99999"),
})
_ = req.Build()
resp, err = executeRequestWith(srv, req.HTTPRequest, &out)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, 0, counter.putCalled)
}
func TestIamApiUpdateAccessKeyTargeted(t *testing.T) {
srv, cm, counter, rec := targetedIamApiServer()
ctx := context.Background()
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{
Name: "alice",
Credentials: []*iam_pb.Credential{{AccessKey: "AKIATESTFAKEKEY00001", SecretKey: "testsecretfake", Status: "Active"}},
}))
req, _ := iam.New(session.New()).UpdateAccessKeyRequest(&iam.UpdateAccessKeyInput{
UserName: aws.String("alice"),
AccessKeyId: aws.String("AKIATESTFAKEKEY00001"),
Status: aws.String(iam.StatusTypeInactive),
})
_ = req.Build()
out := UpdateAccessKeyResponse{}
resp, err := executeRequestWith(srv, req.HTTPRequest, &out)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, 0, counter.putCalled, "UpdateAccessKey must not PutS3ApiConfiguration")
assert.Equal(t, 1, rec.updateUsers)
ident, err := cm.GetUser(ctx, "alice")
require.NoError(t, err)
require.Len(t, ident.Credentials, 1)
assert.Equal(t, iam.StatusTypeInactive, ident.Credentials[0].Status)
}
func TestIamApiPutUserPolicyTargeted(t *testing.T) {
srv, cm, counter, rec := targetedIamApiServer()
ctx := context.Background()
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "alice"}))
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "bob"}))
req, _ := iam.New(session.New()).PutUserPolicyRequest(&iam.PutUserPolicyInput{
UserName: aws.String("alice"),
PolicyName: aws.String("read-bench"),
PolicyDocument: aws.String(targetedTestPolicy),
})
_ = req.Build()
out := PutUserPolicyResponse{}
resp, err := executeRequestWith(srv, req.HTTPRequest, &out)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, 0, counter.putCalled, "PutUserPolicy must not PutS3ApiConfiguration")
assert.Equal(t, 0, rec.saves)
assert.Equal(t, 1, rec.updateUsers)
ident, err := cm.GetUser(ctx, "alice")
require.NoError(t, err)
assert.NotEmpty(t, ident.Actions, "recomputed Actions must be persisted for the one identity")
}
func TestIamApiDeleteUserPolicyTargeted(t *testing.T) {
srv, cm, counter, rec := targetedIamApiServer()
ctx := context.Background()
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "alice"}))
putReq, _ := iam.New(session.New()).PutUserPolicyRequest(&iam.PutUserPolicyInput{
UserName: aws.String("alice"),
PolicyName: aws.String("read-bench"),
PolicyDocument: aws.String(targetedTestPolicy),
})
_ = putReq.Build()
putOut := PutUserPolicyResponse{}
resp, err := executeRequestWith(srv, putReq.HTTPRequest, &putOut)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.Code)
delReq, _ := iam.New(session.New()).DeleteUserPolicyRequest(&iam.DeleteUserPolicyInput{
UserName: aws.String("alice"),
PolicyName: aws.String("read-bench"),
})
_ = delReq.Build()
delOut := DeleteUserPolicyResponse{}
resp, err = executeRequestWith(srv, delReq.HTTPRequest, &delOut)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, 0, counter.putCalled, "DeleteUserPolicy must not PutS3ApiConfiguration")
assert.Equal(t, 2, rec.updateUsers)
policies := Policies{}
require.NoError(t, srv.s3ApiConfig.GetPolicies(&policies))
assert.Empty(t, policies.InlinePolicies["alice"])
}
func TestIamApiUpdateUserRenameTargeted(t *testing.T) {
srv, cm, counter, _ := targetedIamApiServer()
ctx := context.Background()
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "alice"}))
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "bob"}))
require.NoError(t, cm.CreateGroup(ctx, &iam_pb.Group{Name: "devs", Members: []string{"alice", "bob"}}))
req, _ := iam.New(session.New()).UpdateUserRequest(&iam.UpdateUserInput{
UserName: aws.String("alice"),
NewUserName: aws.String("alice-renamed"),
})
_ = req.Build()
out := UpdateUserResponse{}
resp, err := executeRequestWith(srv, req.HTTPRequest, &out)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, 0, counter.putCalled, "UpdateUser rename must not PutS3ApiConfiguration")
_, err = cm.GetUser(ctx, "alice")
require.ErrorIs(t, err, credential.ErrUserNotFound)
_, err = cm.GetUser(ctx, "alice-renamed")
require.NoError(t, err)
g, err := cm.GetGroup(ctx, "devs")
require.NoError(t, err)
assert.ElementsMatch(t, []string{"alice-renamed", "bob"}, g.Members)
}
func TestIamApiDeleteUserTargeted(t *testing.T) {
srv, cm, counter, rec := targetedIamApiServer()
ctx := context.Background()
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "alice"}))
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "bob"}))
require.NoError(t, cm.CreateGroup(ctx, &iam_pb.Group{Name: "devs", Members: []string{"alice", "bob"}}))
req, _ := iam.New(session.New()).DeleteUserRequest(&iam.DeleteUserInput{UserName: aws.String("alice")})
_ = req.Build()
out := DeleteUserResponse{}
resp, err := executeRequestWith(srv, req.HTTPRequest, &out)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, 0, counter.putCalled, "DeleteUser must not PutS3ApiConfiguration")
assert.Equal(t, 1, rec.deleteUsers)
assert.Equal(t, 1, rec.updateGroups)
_, err = cm.GetUser(ctx, "alice")
require.ErrorIs(t, err, credential.ErrUserNotFound)
g, err := cm.GetGroup(ctx, "devs")
require.NoError(t, err)
assert.ElementsMatch(t, []string{"bob"}, g.Members)
_, err = cm.GetUser(ctx, "bob")
require.NoError(t, err, "other identities must be untouched")
}
+32 -23
View File
@@ -17,11 +17,9 @@ import (
"github.com/jinzhu/copier"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/credential/memory"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -525,13 +523,32 @@ func executeRequest(req *http.Request, v interface{}) (*httptest.ResponseRecorde
}
func TestHandleImplicitUsername(t *testing.T) {
// The implicit name is the authenticated identity recorded in the request
// context by the auth middleware; request headers are never re-parsed.
iama := &IamApiServer{}
// Create a mock IamApiServer with credential store
// The handleImplicitUsername function now looks up the username from the
// credential store based on AccessKeyId, not from the region field in the auth header.
// Note: Using obviously fake access keys to avoid CI secret scanner false positives
withIdentity := func(name string) *http.Request {
r := &http.Request{Header: http.Header{}}
return r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), name))
// Create IAM directly as struct literal (same pattern as other tests)
iam := &s3api.IdentityAccessManagement{}
// Load test credentials - map access key to identity name
testConfig := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: "testuser1",
Credentials: []*iam_pb.Credential{
{AccessKey: "AKIATESTFAKEKEY000001", SecretKey: "testsecretfake"},
},
},
},
}
err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(t, testConfig))
if err != nil {
t.Fatalf("Failed to load test config: %v", err)
}
iama := &IamApiServer{
iam: iam,
}
var tests = []struct {
@@ -539,14 +556,14 @@ func TestHandleImplicitUsername(t *testing.T) {
values url.Values
userName string
}{
// No authenticated identity - should not set username
// No authorization header - should not set username
{&http.Request{}, url.Values{}, ""},
// Authenticated identity in context - sets its name
{withIdentity("testuser1"), url.Values{}, "testuser1"},
// A client-controlled Authorization header naming another user is ignored
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
// An explicit UserName is left alone
{withIdentity("testuser1"), url.Values{"UserName": {"other"}}, "other"},
// Valid auth header with known access key - should look up and find "testuser1"
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, "testuser1"},
// Malformed auth header (no Credential=) - should not set username
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 =AKIATESTFAKEKEY000001/20220420/test1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
// Unknown access key - should not set username
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTUNKNOWN000000/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
}
for i, test := range tests {
@@ -590,7 +607,6 @@ func iamApiServerWithCredentialManager() (*IamApiServer, *credential.CredentialM
type countingConfigSaver struct {
cm *credential.CredentialManager
putCalled int
policies Policies
}
func (c *countingConfigSaver) GetS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) error {
@@ -600,8 +616,6 @@ func (c *countingConfigSaver) GetS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfigura
}
s3cfg.Identities = config.Identities
s3cfg.Policies = config.Policies
s3cfg.Groups = config.Groups
s3cfg.ServiceAccounts = config.ServiceAccounts
return nil
}
@@ -611,15 +625,10 @@ func (c *countingConfigSaver) PutS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfigura
}
func (c *countingConfigSaver) GetPolicies(policies *Policies) error {
*policies = c.policies
if c.policies.Policies == nil && c.policies.InlinePolicies == nil {
return filer_pb.ErrNotFound
}
return nil
}
func (c *countingConfigSaver) PutPolicies(policies *Policies) error {
c.policies = *policies
return nil
}
-25
View File
@@ -244,31 +244,7 @@ func Mkdir(ctx context.Context, filerClient FilerClient, parentDirectoryPath str
})
}
// MkdirExclusive is Mkdir that never replaces an entry already at the path:
// the create is O_EXCL and a taken name answers ErrEntryAlreadyExists. The
// taken name leaves the callback as a result, not an error: failover counts a
// callback error against the filer and retries on the next one.
func MkdirExclusive(ctx context.Context, filerClient FilerClient, parentDirectoryPath string, dirName string, fn func(entry *Entry)) error {
var taken error
err := filerClient.WithFilerClient(false, func(client SeaweedFilerClient) error {
err := doMkdir(ctx, client, parentDirectoryPath, dirName, true, fn)
if errors.Is(err, ErrEntryAlreadyExists) {
taken = err
return nil
}
return err
})
if err == nil {
err = taken
}
return err
}
func DoMkdir(ctx context.Context, client SeaweedFilerClient, parentDirectoryPath string, dirName string, fn func(entry *Entry)) error {
return doMkdir(ctx, client, parentDirectoryPath, dirName, false, fn)
}
func doMkdir(ctx context.Context, client SeaweedFilerClient, parentDirectoryPath string, dirName string, exclusive bool, fn func(entry *Entry)) error {
entry := &Entry{
Name: dirName,
IsDirectory: true,
@@ -288,7 +264,6 @@ func doMkdir(ctx context.Context, client SeaweedFilerClient, parentDirectoryPath
request := &CreateEntryRequest{
Directory: parentDirectoryPath,
Entry: entry,
OExcl: exclusive,
}
glog.V(1).InfofCtx(ctx, "mkdir: %v", request)
-9
View File
@@ -59,8 +59,6 @@ service Seaweed {
}
rpc RaftLeadershipTransfer (RaftLeadershipTransferRequest) returns (RaftLeadershipTransferResponse) {
}
rpc ResumeVolumeGrowth (ResumeVolumeGrowthRequest) returns (ResumeVolumeGrowthResponse) {
}
rpc VolumeGrow (VolumeGrowRequest) returns (VolumeGrowResponse) {
}
rpc CollectionStatistics (CollectionStatisticsRequest) returns (CollectionStatisticsResponse) {
@@ -326,13 +324,6 @@ message VolumeGrowRequest {
float grow_avg_fill_threshold = 10;
}
message ResumeVolumeGrowthRequest {
}
message ResumeVolumeGrowthResponse {
bool was_frozen = 1;
}
message AssignResponse {
string fid = 1;
uint64 count = 4;
File diff suppressed because it is too large Load Diff
-38
View File
@@ -44,7 +44,6 @@ const (
Seaweed_RaftAddServer_FullMethodName = "/master_pb.Seaweed/RaftAddServer"
Seaweed_RaftRemoveServer_FullMethodName = "/master_pb.Seaweed/RaftRemoveServer"
Seaweed_RaftLeadershipTransfer_FullMethodName = "/master_pb.Seaweed/RaftLeadershipTransfer"
Seaweed_ResumeVolumeGrowth_FullMethodName = "/master_pb.Seaweed/ResumeVolumeGrowth"
Seaweed_VolumeGrow_FullMethodName = "/master_pb.Seaweed/VolumeGrow"
Seaweed_CollectionStatistics_FullMethodName = "/master_pb.Seaweed/CollectionStatistics"
)
@@ -78,7 +77,6 @@ type SeaweedClient interface {
RaftAddServer(ctx context.Context, in *RaftAddServerRequest, opts ...grpc.CallOption) (*RaftAddServerResponse, error)
RaftRemoveServer(ctx context.Context, in *RaftRemoveServerRequest, opts ...grpc.CallOption) (*RaftRemoveServerResponse, error)
RaftLeadershipTransfer(ctx context.Context, in *RaftLeadershipTransferRequest, opts ...grpc.CallOption) (*RaftLeadershipTransferResponse, error)
ResumeVolumeGrowth(ctx context.Context, in *ResumeVolumeGrowthRequest, opts ...grpc.CallOption) (*ResumeVolumeGrowthResponse, error)
VolumeGrow(ctx context.Context, in *VolumeGrowRequest, opts ...grpc.CallOption) (*VolumeGrowResponse, error)
CollectionStatistics(ctx context.Context, in *CollectionStatisticsRequest, opts ...grpc.CallOption) (*CollectionStatisticsResponse, error)
}
@@ -359,16 +357,6 @@ func (c *seaweedClient) RaftLeadershipTransfer(ctx context.Context, in *RaftLead
return out, nil
}
func (c *seaweedClient) ResumeVolumeGrowth(ctx context.Context, in *ResumeVolumeGrowthRequest, opts ...grpc.CallOption) (*ResumeVolumeGrowthResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ResumeVolumeGrowthResponse)
err := c.cc.Invoke(ctx, Seaweed_ResumeVolumeGrowth_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *seaweedClient) VolumeGrow(ctx context.Context, in *VolumeGrowRequest, opts ...grpc.CallOption) (*VolumeGrowResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(VolumeGrowResponse)
@@ -418,7 +406,6 @@ type SeaweedServer interface {
RaftAddServer(context.Context, *RaftAddServerRequest) (*RaftAddServerResponse, error)
RaftRemoveServer(context.Context, *RaftRemoveServerRequest) (*RaftRemoveServerResponse, error)
RaftLeadershipTransfer(context.Context, *RaftLeadershipTransferRequest) (*RaftLeadershipTransferResponse, error)
ResumeVolumeGrowth(context.Context, *ResumeVolumeGrowthRequest) (*ResumeVolumeGrowthResponse, error)
VolumeGrow(context.Context, *VolumeGrowRequest) (*VolumeGrowResponse, error)
CollectionStatistics(context.Context, *CollectionStatisticsRequest) (*CollectionStatisticsResponse, error)
mustEmbedUnimplementedSeaweedServer()
@@ -506,9 +493,6 @@ func (UnimplementedSeaweedServer) RaftRemoveServer(context.Context, *RaftRemoveS
func (UnimplementedSeaweedServer) RaftLeadershipTransfer(context.Context, *RaftLeadershipTransferRequest) (*RaftLeadershipTransferResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RaftLeadershipTransfer not implemented")
}
func (UnimplementedSeaweedServer) ResumeVolumeGrowth(context.Context, *ResumeVolumeGrowthRequest) (*ResumeVolumeGrowthResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ResumeVolumeGrowth not implemented")
}
func (UnimplementedSeaweedServer) VolumeGrow(context.Context, *VolumeGrowRequest) (*VolumeGrowResponse, error) {
return nil, status.Error(codes.Unimplemented, "method VolumeGrow not implemented")
}
@@ -946,24 +930,6 @@ func _Seaweed_RaftLeadershipTransfer_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _Seaweed_ResumeVolumeGrowth_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ResumeVolumeGrowthRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SeaweedServer).ResumeVolumeGrowth(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Seaweed_ResumeVolumeGrowth_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SeaweedServer).ResumeVolumeGrowth(ctx, req.(*ResumeVolumeGrowthRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Seaweed_VolumeGrow_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(VolumeGrowRequest)
if err := dec(in); err != nil {
@@ -1091,10 +1057,6 @@ var Seaweed_ServiceDesc = grpc.ServiceDesc{
MethodName: "RaftLeadershipTransfer",
Handler: _Seaweed_RaftLeadershipTransfer_Handler,
},
{
MethodName: "ResumeVolumeGrowth",
Handler: _Seaweed_ResumeVolumeGrowth_Handler,
},
{
MethodName: "VolumeGrow",
Handler: _Seaweed_VolumeGrow_Handler,
@@ -14,7 +14,6 @@ import (
"time"
"cloud.google.com/go/storage"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
@@ -349,16 +348,6 @@ func (gcs *gcsRemoteStorageClient) WriteFile(loc *remote_pb.RemoteStorageLocatio
wc.ContentType = entry.Attributes.Mime
}
wc.ContentEncoding = remote_storage.EntryContentEncoding(entry)
// A Writer reserves a buffer of ChunkSize bytes (16 MiB by default) so a
// failed request can be retried. An object below that size takes a buffer
// of its own size plus one 256 KiB unit, the SDK's granularity: a burst of
// small uploads in flight does not hold 16 MiB each, every upload keeps
// its retry buffer, and the object stays below ChunkSize so it goes in one
// request rather than a resumable session. The size counts the chunks,
// not only the attribute, which some write paths leave at zero.
if size := filer.FileSize(entry); size < uint64(googleapi.DefaultUploadChunkSize) {
wc.ChunkSize = int((size/googleapi.MinUploadChunkSize + 1) * googleapi.MinUploadChunkSize)
}
if _, err = io.Copy(wc, reader); err != nil {
return nil, fmt.Errorf("upload to gcs %s/%s%s: %v", loc.Name, loc.Bucket, loc.Path, err)
}
-36
View File
@@ -25,7 +25,6 @@ import (
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3bucket"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
@@ -1681,7 +1680,6 @@ func (iam *IdentityAccessManagement) Auth(f http.HandlerFunc, action Action) htt
// succeeds but authZ denies, so the denied request still audits its
// requester; a failed authN resolves no identity at all.
identity, errCode, _ := iam.authRequestWithAuthType(r, action)
errCode = refineDenialForBucketName(r, identity, errCode)
if errCode != s3err.ErrNone {
glog.V(3).Infof("auth error: %v", errCode)
}
@@ -1721,7 +1719,6 @@ func (iam *IdentityAccessManagement) AuthPostPolicy(f http.HandlerFunc, action A
}
}
errCode = refineDenialForBucketName(r, identity, errCode)
if errCode != s3err.ErrNone {
glog.V(3).Infof("auth error: %v", errCode)
}
@@ -1730,39 +1727,6 @@ func (iam *IdentityAccessManagement) AuthPostPolicy(f http.HandlerFunc, action A
}
}
// refineDenialForBucketName answers 400 InvalidBucketName, as AWS S3 does,
// when an authenticated caller is denied on a bucket whose name breaks the S3
// naming rules. The rules are public and the answer depends on the name alone,
// so it tells the caller nothing about which buckets exist. Authentication
// failures (no identity resolved) and every other answer are left unchanged.
func refineDenialForBucketName(r *http.Request, identity *Identity, errCode s3err.ErrorCode) s3err.ErrorCode {
if errCode != s3err.ErrAccessDenied || identity == nil {
return errCode
}
bucket, _ := s3_constants.GetBucketAndObject(r)
if bucket == "" {
return errCode
}
if !isASCIIBucketName(bucket) || s3bucket.VerifyS3BucketName(bucket) != nil {
return s3err.ErrInvalidBucketName
}
return errCode
}
// isASCIIBucketName reports whether name uses only the characters S3 allows
// in a bucket name. VerifyS3BucketName checks with unicode.IsLower and
// unicode.IsNumber, which also accept letters such as 'ü' and digits such as
// '²' that S3 refuses.
func isASCIIBucketName(name string) bool {
for i := 0; i < len(name); i++ {
c := name[i]
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '-') {
return false
}
}
return true
}
// recordIdentityInContext stores the authenticated identity, its name and its
// principal ARN in the request context. An STS session's name is only an opaque
// session subject, so the ARN is what carries the assumed role and session name
-250
View File
@@ -1,250 +0,0 @@
package s3api
import (
"bytes"
"encoding/base64"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
invalidNameTestAccessKey = "INVALIDNAMEKEY000001"
invalidNameTestSecretKey = "invalid-name-secret-for-tests-only"
invalidNameTestBucket = "readable-bucket"
)
// newInvalidNameIAM has one identity that may read invalidNameTestBucket only
// and a configured anonymous identity with no actions.
func newInvalidNameIAM(t *testing.T) *IdentityAccessManagement {
t.Helper()
iam := &IdentityAccessManagement{hashes: make(map[string]*sync.Pool), hashCounters: make(map[string]*int32)}
require.NoError(t, iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{Name: "reader", Actions: []string{"Read:" + invalidNameTestBucket},
Credentials: []*iam_pb.Credential{{AccessKey: invalidNameTestAccessKey, SecretKey: invalidNameTestSecretKey}}},
{Name: s3_constants.AccountAnonymousId},
},
}))
require.True(t, iam.isEnabled())
return iam
}
func invalidNameRequest(t *testing.T, bucket string, sign bool) *http.Request {
t.Helper()
req := mustNewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:8333/%s/key", bucket), 0, nil, t)
if sign {
require.NoError(t, signRequestV4(req, invalidNameTestAccessKey, invalidNameTestSecretKey))
}
return mux.SetURLVars(req, map[string]string{"bucket": bucket, "object": "key"})
}
func serveAuth(iam *IdentityAccessManagement, r *http.Request) (int, bool) {
ran := false
rec := httptest.NewRecorder()
iam.Auth(func(w http.ResponseWriter, r *http.Request) { ran = true; w.WriteHeader(http.StatusOK) }, s3_constants.ACTION_READ).ServeHTTP(rec, r)
return rec.Code, ran
}
// A denied request on a malformed bucket name answers 400 InvalidBucketName,
// as AWS S3 does; a denied request on a well-formed name stays 403.
func TestAuthDeniedInvalidBucketName(t *testing.T) {
iam := newInvalidNameIAM(t)
code, ran := serveAuth(iam, invalidNameRequest(t, invalidNameTestBucket, true))
assert.True(t, ran, "the identity's own bucket is allowed")
assert.Equal(t, http.StatusOK, code)
code, ran = serveAuth(iam, invalidNameRequest(t, "other-bucket", true))
assert.False(t, ran)
assert.Equal(t, http.StatusForbidden, code, "a well-formed name the caller may not read stays AccessDenied")
for _, bucket := range []string{"Bad_Bucket", "ab", "bucket..name", "bücket"} {
for _, sign := range []bool{true, false} {
code, ran = serveAuth(iam, invalidNameRequest(t, bucket, sign))
assert.False(t, ran, bucket)
assert.Equal(t, http.StatusBadRequest, code, "bucket %q signed=%v", bucket, sign)
}
}
}
func TestRefineDenialForBucketName(t *testing.T) {
reader := &Identity{Name: "reader"}
req := func(bucket string) *http.Request {
return mux.SetURLVars(httptest.NewRequest(http.MethodGet, "/", nil), map[string]string{"bucket": bucket})
}
cases := []struct {
name string
bucket string
identity *Identity
in, want s3err.ErrorCode
}{
{"space in the name", "bucket name", reader, s3err.ErrAccessDenied, s3err.ErrInvalidBucketName},
{"non-ASCII letter", "bücket", reader, s3err.ErrAccessDenied, s3err.ErrInvalidBucketName},
{"superscript digit", "b²cket", reader, s3err.ErrAccessDenied, s3err.ErrInvalidBucketName},
{"too short", "ab", reader, s3err.ErrAccessDenied, s3err.ErrInvalidBucketName},
{"well-formed name", "other-bucket", reader, s3err.ErrAccessDenied, s3err.ErrAccessDenied},
{"no bucket in the request", "", reader, s3err.ErrAccessDenied, s3err.ErrAccessDenied},
{"allowed", "Bad_Bucket", reader, s3err.ErrNone, s3err.ErrNone},
{"authentication failed", "Bad_Bucket", nil, s3err.ErrAccessDenied, s3err.ErrAccessDenied},
{"bad signature", "Bad_Bucket", nil, s3err.ErrSignatureDoesNotMatch, s3err.ErrSignatureDoesNotMatch},
}
for _, c := range cases {
assert.Equal(t, c.want, refineDenialForBucketName(req(c.bucket), c.identity, c.in), c.name)
}
}
// signedPostForm builds a multipart POST Object request for bucket, its policy
// signed with secret under postAuthzAccessKey.
func signedPostForm(t *testing.T, bucket, key, secret string) *http.Request {
t.Helper()
now := time.Now().UTC()
amzDate := now.Format(iso8601Format)
day := now.Format(yyyymmdd)
credential := fmt.Sprintf("%s/%s/%s/%s/aws4_request", postAuthzAccessKey, day, postAuthzRegion, postAuthzService)
policyJSON := fmt.Sprintf(`{"expiration":"%s","conditions":[["eq","$bucket","%s"],["eq","$key","%s"],`+
`["eq","$x-amz-credential","%s"],["eq","$x-amz-algorithm","AWS4-HMAC-SHA256"],["eq","$x-amz-date","%s"]]}`,
now.Add(time.Hour).Format("2006-01-02T15:04:05.000Z"), bucket, key, credential, amzDate)
encodedPolicy := base64.StdEncoding.EncodeToString([]byte(policyJSON))
signature := getSignature(getSigningKey(secret, day, postAuthzRegion, postAuthzService), encodedPolicy)
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
for _, field := range [][2]string{{"bucket", bucket}, {"key", key}, {"x-amz-credential", credential},
{"x-amz-algorithm", "AWS4-HMAC-SHA256"}, {"x-amz-date", amzDate}, {"policy", encodedPolicy}, {"x-amz-signature", signature}} {
require.NoError(t, writer.WriteField(field[0], field[1]))
}
filePart, err := writer.CreateFormFile("file", "payload.txt")
require.NoError(t, err)
_, err = filePart.Write([]byte("payload"))
require.NoError(t, err)
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/"+bucket, &buf)
req.Header.Set("Content-Type", writer.FormDataContentType())
return mux.SetURLVars(req, map[string]string{"bucket": bucket})
}
// A browser POST upload authenticates in the handler, after AuthPostPolicy has
// delegated it, so its denials are refined there: a verified form denied on a
// malformed bucket name answers 400, a bad signature or a well-formed name 403.
func TestPostPolicyDeniedInvalidBucketName(t *testing.T) {
s3a := newPostAuthzServer(t)
for _, bucket := range []string{"Bad_Bucket", "other-bucket"} {
s3a.bucketRegistry.setMetadataCache(&BucketMetaData{Name: bucket})
}
// A second identity may write Bad_Bucket, except under denied/.
require.NoError(t, s3a.iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{Name: "tester", Actions: []string{"Write:" + postAuthzBucket},
Credentials: []*iam_pb.Credential{{AccessKey: postAuthzAccessKey, SecretKey: postAuthzSecretKey}}},
},
}))
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy("Bad_Bucket", `{"Version":"2012-10-17","Statement":[`+
`{"Effect":"Deny","Principal":"*","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::Bad_Bucket/denied/*"]}]}`))
serve := func(r *http.Request) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
s3a.iam.AuthPostPolicy(s3a.PostPolicyBucketHandler, s3_constants.ACTION_WRITE).ServeHTTP(rec, r)
return rec
}
rec := serve(signedPostForm(t, "Bad_Bucket", "k", postAuthzSecretKey))
assert.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "InvalidBucketName")
rec = serve(signedPostForm(t, "Bad_Bucket", "k", "not-the-secret"))
assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "SignatureDoesNotMatch", "a form that fails verification is not refined")
rec = serve(signedPostForm(t, "other-bucket", "k", postAuthzSecretKey))
assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "AccessDenied")
// The per-key authorization after the coarse Write check is refined too.
require.NoError(t, s3a.iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{Name: "tester", Actions: []string{"Write:Bad_Bucket"},
Credentials: []*iam_pb.Credential{{AccessKey: postAuthzAccessKey, SecretKey: postAuthzSecretKey}}},
},
}))
rec = serve(signedPostForm(t, "Bad_Bucket", "denied/k", postAuthzSecretKey))
assert.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "InvalidBucketName")
}
// Unsigned GET/HEAD object reads are authorized inside the object handlers, not
// by Auth; their denial on a malformed bucket name answers 400 as well.
func TestAnonymousReadDeniedInvalidBucketName(t *testing.T) {
read := func(s3a *S3ApiServer, method, bucket string) int {
r := httptest.NewRequest(method, "/"+bucket+"/key", nil)
r = mux.SetURLVars(r, map[string]string{"bucket": bucket, "object": "key"})
handler := s3a.GetObjectHandler
if method == http.MethodHead {
handler = s3a.HeadObjectHandler
}
rr := httptest.NewRecorder()
s3a.AuthWithPublicRead(handler, s3_constants.ACTION_READ)(rr, r)
return rr.Code
}
for _, method := range []string{http.MethodGet, http.MethodHead} {
s3a, _ := newAnonymousReadTestServer(t)
s3a.iam.identityAnonymous = &Identity{Name: s3_constants.AccountAnonymousId}
assert.Equal(t, http.StatusBadRequest, read(s3a, method, "Bad_Bucket"), method)
assert.Equal(t, http.StatusForbidden, read(s3a, method, "other-bucket"), "%s: a well-formed missing bucket stays masked", method)
// No anonymous identity configured: no identity resolves, as in Auth.
s3a, _ = newAnonymousReadTestServer(t)
assert.Equal(t, http.StatusForbidden, read(s3a, method, "Bad_Bucket"), method)
}
}
// An existing private key and a missing key in the same malformed-name bucket
// must answer alike, or the status code tells an anonymous caller which keys
// exist.
func TestAnonymousReadDeniedInvalidBucketNameExistingKey(t *testing.T) {
const bucket = "bücket"
read := func(s3a *S3ApiServer, method, object string) int {
r := httptest.NewRequest(method, "/"+bucket+"/"+object, nil)
r = mux.SetURLVars(r, map[string]string{"bucket": bucket, "object": object})
handler := s3a.GetObjectHandler
if method == http.MethodHead {
handler = s3a.HeadObjectHandler
}
rr := httptest.NewRecorder()
s3a.AuthWithPublicRead(handler, s3_constants.ACTION_READ)(rr, r)
return rr.Code
}
setup := func(anonymous bool) *S3ApiServer {
s3a, f := newAnonymousReadTestServer(t)
if anonymous {
s3a.iam.identityAnonymous = &Identity{Name: s3_constants.AccountAnonymousId}
}
s3a.bucketConfigCache.Set(bucket, &BucketConfig{Name: bucket, Ownership: s3_constants.OwnershipObjectWriter})
f.entries["/buckets/"+bucket+"/private"] = anonymousReadEntry([]byte(`[]`))
f.entries["/buckets/"+bucket+"/public"] = anonymousReadEntry(anonymousReadGrant("READ"))
return s3a
}
for _, method := range []string{http.MethodGet, http.MethodHead} {
s3a := setup(true)
assert.Equal(t, http.StatusOK, read(s3a, method, "public"), "%s: a public key is still served", method)
assert.Equal(t, http.StatusBadRequest, read(s3a, method, "missing"), method)
assert.Equal(t, http.StatusBadRequest, read(s3a, method, "private"), "%s: an existing private key answers like a missing one", method)
// No anonymous identity configured: both stay 403, as in Auth.
s3a = setup(false)
assert.Equal(t, http.StatusForbidden, read(s3a, method, "missing"), method)
assert.Equal(t, http.StatusForbidden, read(s3a, method, "private"), method)
}
}
+6
View File
@@ -29,6 +29,7 @@ import (
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
@@ -94,6 +95,11 @@ func (iam *IdentityAccessManagement) doesPolicySignatureV2Match(formValues http.
return nil, s3err.ErrAccessDenied
}
bucket := formValues.Get("bucket")
if !identity.CanDo(s3_constants.ACTION_WRITE, bucket, "") {
return nil, s3err.ErrAccessDenied
}
policy := formValues.Get("Policy")
if policy == "" {
return nil, s3err.ErrMissingFields
+5 -6
View File
@@ -858,6 +858,11 @@ func (iam *IdentityAccessManagement) doesPolicySignatureV4Match(formValues http.
return nil, s3err.ErrAccessDenied
}
bucket := formValues.Get("bucket")
if !identity.CanDo(s3_constants.ACTION_WRITE, bucket, "") {
return nil, s3err.ErrAccessDenied
}
// Get signing key.
signingKey := getSigningKey(cred.SecretKey, credHeader.scope.date.Format(yyyymmdd), credHeader.scope.region, credHeader.scope.service)
@@ -954,12 +959,6 @@ func extractSignedHeaders(signedHeaders []string, r *http.Request, externalHost
extractedSignedHeaders[header] = []string{hostHeaderValue}
continue
}
// The Go server strips Content-Length into r.ContentLength; clients do
// sign it, so rebuild the value the same way the client saw it.
if strings.EqualFold(header, "content-length") && r.ContentLength >= 0 {
extractedSignedHeaders[header] = []string{strconv.FormatInt(r.ContentLength, 10)}
continue
}
// For all other headers we need to find them in the HTTP headers and copy them over.
// We skip non-existent headers to be compatible with AWS signatures.
if values, ok := reqHeaders[http.CanonicalHeaderKey(header)]; ok {
-16
View File
@@ -681,22 +681,6 @@ func TestExtractSignedHeadersCase(t *testing.T) {
}
}
// A client may sign content-length even when the request carries no body
// (aws-cli's checksum HEAD does). The Go server strips the header into
// r.ContentLength, so extraction rebuilds it rather than dropping it.
func TestExtractSignedHeadersContentLength(t *testing.T) {
r, _ := http.NewRequest("HEAD", "http://bucket.domain.com/bucket/key", nil)
r.Host = "bucket.domain.com"
r.ContentLength = 0
extracted, errCode := extractSignedHeaders([]string{"host", "content-length"}, r, "")
if errCode != s3err.ErrNone {
t.Fatalf("extractSignedHeaders failed: %v", errCode)
}
if got := getCanonicalHeaders(extracted); got != "content-length:0\nhost:bucket.domain.com\n" {
t.Fatalf("unexpected canonical headers %q", got)
}
}
// A router that internally rewrites the request path (the Iceberg catalog
// collapses a doubled /v1 prefix) must still verify the signature against
// the path the client signed, which is kept in the request context.
+3 -47
View File
@@ -235,15 +235,13 @@ func buildPreflightResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse
// Set exposed headers
if len(rule.ExposeHeaders) > 0 {
response.ExposeHeaders = formatExposeHeaders(rule.ExposeHeaders)
response.ExposeHeaders = strings.Join(rule.ExposeHeaders, ", ")
}
// Set max age
if rule.MaxAgeSeconds != nil {
response.MaxAge = strconv.Itoa(*rule.MaxAgeSeconds)
}
response.AllowCredentials = true
}
return response
@@ -252,8 +250,7 @@ func buildPreflightResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse
// buildResponse builds a CORS response from a matching rule
func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
response := &CORSResponse{
AllowOrigin: corsReq.Origin,
AllowCredentials: true,
AllowOrigin: corsReq.Origin,
}
// Set allowed methods
@@ -266,7 +263,7 @@ func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
// Set expose headers
if len(rule.ExposeHeaders) > 0 {
response.ExposeHeaders = formatExposeHeaders(rule.ExposeHeaders)
response.ExposeHeaders = strings.Join(rule.ExposeHeaders, ", ")
}
// Set max age
@@ -277,47 +274,6 @@ func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
return response
}
// defaultExposeHeaders are emitted alongside "*" so credentialed CORS requests
// can read standard response headers ("*" is treated literally when credentials
// are included, per the Fetch spec).
var defaultExposeHeaders = []string{
"ETag",
"Content-Length",
"Content-Type",
"Last-Modified",
"Content-Range",
"x-amz-request-id",
"x-amz-version-id",
"x-amz-tagging-count",
}
// formatExposeHeaders formats the exposed headers slice into a comma-separated string.
func formatExposeHeaders(exposeHeaders []string) string {
var headers []string
hasWildcard := false
seen := map[string]bool{}
for _, h := range exposeHeaders {
if h == "*" {
hasWildcard = true
continue
}
if lower := strings.ToLower(h); !seen[lower] {
seen[lower] = true
headers = append(headers, h)
}
}
if hasWildcard {
for _, h := range defaultExposeHeaders {
if lower := strings.ToLower(h); !seen[lower] {
seen[lower] = true
headers = append(headers, h)
}
}
headers = append(headers, "*")
}
return strings.Join(headers, ", ")
}
// Helper functions
// matchesOrigin checks if the request origin matches any allowed origin
+23 -51
View File
@@ -405,12 +405,11 @@ func TestEvaluateRequest(t *testing.T) {
Method: "GET",
},
want: &CORSResponse{
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
AllowCredentials: true,
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
},
wantErr: false,
},
@@ -422,9 +421,8 @@ func TestEvaluateRequest(t *testing.T) {
Method: "PUT",
},
want: &CORSResponse{
AllowOrigin: "http://other.com",
AllowMethods: "PUT",
AllowCredentials: true,
AllowOrigin: "http://other.com",
AllowMethods: "PUT",
},
wantErr: false,
},
@@ -449,12 +447,11 @@ func TestEvaluateRequest(t *testing.T) {
AccessControlRequestHeaders: []string{"Content-Type"},
},
want: &CORSResponse{
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
AllowCredentials: true,
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
},
wantErr: false,
},
@@ -470,30 +467,7 @@ func TestEvaluateRequest(t *testing.T) {
},
want: &CORSResponse{
AllowOrigin: "http://example.com",
// No AllowMethods, AllowHeaders, or AllowCredentials because the requested header is forbidden
},
wantErr: false,
},
{
name: "wildcard expose headers expands for credentialed responses",
config: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"http://example.com"},
AllowedMethods: []string{"GET"},
ExposeHeaders: []string{"*"},
},
},
},
corsReq: &CORSRequest{
Origin: "http://example.com",
Method: "GET",
},
want: &CORSResponse{
AllowOrigin: "http://example.com",
AllowMethods: "GET",
ExposeHeaders: "ETag, Content-Length, Content-Type, Last-Modified, Content-Range, x-amz-request-id, x-amz-version-id, x-amz-tagging-count, *",
AllowCredentials: true,
// No AllowMethods or AllowHeaders because the requested header is forbidden
},
wantErr: false,
},
@@ -537,20 +511,18 @@ func TestApplyHeaders(t *testing.T) {
{
name: "complete response",
corsResp: &CORSResponse{
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
AllowCredentials: true,
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
},
want: map[string]string{
"Access-Control-Allow-Origin": "http://example.com",
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Expose-Headers": "ETag",
"Access-Control-Max-Age": "3600",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Origin": "http://example.com",
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Expose-Headers": "ETag",
"Access-Control-Max-Age": "3600",
},
},
{
-18
View File
@@ -11,7 +11,6 @@ import (
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
@@ -23,23 +22,6 @@ func (s3a *S3ApiServer) mkdir(parentDirectoryPath string, dirName string, fn fun
}
// mkdirExclusive never replaces an entry already at the path: PutBucket uses
// it so a plain bucket cannot replace a table bucket created in its
// check-to-create window.
func (s3a *S3ApiServer) mkdirExclusive(parentDirectoryPath string, dirName string, fn func(entry *filer_pb.Entry)) error {
return filer_pb.MkdirExclusive(context.Background(), s3a, parentDirectoryPath, dirName, fn)
}
// concurrentEntryIsForeign: after PutBucket's exclusive mkdir met an entry,
// whether that entry must be answered BucketAlreadyExists without looking at
// its owner: a table bucket (it carries no S3 owner and must never read as
// "owned by you"), or an entry that could not be read.
func concurrentEntryIsForeign(entry *filer_pb.Entry, err error) bool {
return err != nil || entry == nil || s3tables.IsTableBucketEntry(entry)
}
func (s3a *S3ApiServer) mkFile(parentDirectoryPath string, fileName string, chunks []*filer_pb.FileChunk, fn func(entry *filer_pb.Entry)) error {
err := filer_pb.MkFile(context.Background(), s3a, parentDirectoryPath, fileName, chunks, fn)
+1 -1
View File
@@ -397,7 +397,7 @@ func (s *Server) handleUpdateTable(w http.ResponseWriter, r *http.Request) {
if cleanupErr := s.deleteMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName); cleanupErr != nil {
glog.V(1).Infof("Iceberg: failed to cleanup metadata file %s after update failure: %v", newMetadataLocation, cleanupErr)
}
logManagerError("CommitTable UpdateTable", err)
glog.Errorf("Iceberg: CommitTable UpdateTable error: %v", err)
writeManagerError(w, err)
return
}
+1 -1
View File
@@ -140,7 +140,7 @@ func (s *Server) handleCreateNamespace(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, "AlreadyExistsException", err.Error())
return
}
logManagerError("CreateNamespace", err)
glog.Errorf("Iceberg: CreateNamespace error: %v", err)
writeManagerError(w, err)
return
}
+7 -50
View File
@@ -294,10 +294,7 @@ func (s *Server) handleCreateTable(w http.ResponseWriter, r *http.Request) {
if markerErr := s.writeStageCreateMarker(r.Context(), bucketName, namespace, tableName, tableUUID, location, stagedMetadataLocation); markerErr != nil {
glog.V(1).Infof("Iceberg: failed to persist stage-create marker for %s.%s: %v", flattenNamespacePath(namespace), tableName, markerErr)
}
// The staged table is not registered yet, so there is nothing to
// authorize an update against; the create gate above is the write
// authority for the prefix the staged table will occupy.
config, storageCredentials := s.buildFileIOConfig(r, location, func() bool { return true })
config, storageCredentials := s.buildFileIOConfig(r, location)
result := LoadTableResult{
MetadataLocation: metadataLocation,
Metadata: metadata,
@@ -398,9 +395,7 @@ func (s *Server) handleCreateTable(w http.ResponseWriter, r *http.Request) {
glog.V(1).Infof("Iceberg: failed to cleanup stage-create markers for %s.%s after create: %v", flattenNamespacePath(namespace), tableName, markerErr)
}
config, storageCredentials := s.buildFileIOConfig(r, location, func() bool {
return s.mayUpdateTable(r, bucketName, namespace, req.Name)
})
config, storageCredentials := s.buildFileIOConfig(r, location)
result := LoadTableResult{
MetadataLocation: finalLocation,
Metadata: metadata,
@@ -595,9 +590,7 @@ func (s *Server) buildLoadTableResult(r *http.Request, getResp s3tables.GetTable
return LoadTableResult{}, fmt.Errorf("build metadata for %s: %w", tableName, err)
}
config, storageCredentials := s.buildFileIOConfig(r, location, func() bool {
return s.mayUpdateTable(r, bucketName, namespace, tableName)
})
config, storageCredentials := s.buildFileIOConfig(r, location)
return LoadTableResult{
MetadataLocation: getResp.MetadataLocation,
Metadata: metadata,
@@ -653,7 +646,7 @@ func applySnapshotsParam(r *http.Request, metadata table.Metadata) (table.Metada
// a region on attach don't need to be told it out-of-band. See issue #9103.
// It also returns, for a client that asked for credential vending, the
// credentials scoped to this table.
func (s *Server) buildFileIOConfig(r *http.Request, location string, canWrite func() bool) (iceberg.Properties, []StorageCredential) {
func (s *Server) buildFileIOConfig(r *http.Request, location string) (iceberg.Properties, []StorageCredential) {
config := make(iceberg.Properties)
if s.s3Endpoint == "" {
return config, nil
@@ -661,7 +654,7 @@ func (s *Server) buildFileIOConfig(r *http.Request, location string, canWrite fu
vended := iceberg.Properties(nil)
if wantsVendedCredentials(r) {
vended = s.vendCredentials(r, location, canWrite)
vended = s.vendCredentials(r, location)
// A client asking for vended credentials builds its storage credential
// out of whatever comes back here and stops using the one it was
// configured with. With nothing to vend, an endpoint on its own leaves
@@ -694,7 +687,7 @@ func (s *Server) buildFileIOConfig(r *http.Request, location string, canWrite fu
// nil when the deployment has no vending configured or the mint failed, which
// leaves the caller withholding the endpoint so the client keeps its own
// credentials rather than falling back to unsigned requests.
func (s *Server) vendCredentials(r *http.Request, location string, canWrite func() bool) iceberg.Properties {
func (s *Server) vendCredentials(r *http.Request, location string) iceberg.Properties {
if s.credentialVendor == nil || location == "" {
return nil
}
@@ -706,7 +699,7 @@ func (s *Server) vendCredentials(r *http.Request, location string, canWrite func
}
principal := s3_constants.GetIdentityNameFromContext(r)
credentials, err := s.credentialVendor.VendTableCredentials(r.Context(), principal, bucket, prefix, canWrite == nil || !canWrite())
credentials, err := s.credentialVendor.VendTableCredentials(r.Context(), principal, bucket, prefix)
if err != nil {
glog.Warningf("Iceberg: failed to vend credentials for %s: %v", location, err)
return nil
@@ -726,42 +719,6 @@ func (s *Server) vendCredentials(r *http.Request, location string, canWrite func
return vended
}
// mayUpdateTable reports whether the caller may commit to the table the
// credentials would cover. The session stays read-only when it may not, so
// the vend carries no more authority than the caller holds at the catalog.
func (s *Server) mayUpdateTable(r *http.Request, bucketName string, namespace []string, tableName string) bool {
updateReq := &s3tables.UpdateTableRequest{
TableBucketARN: buildTableBucketARN(bucketName),
Namespace: namespace,
Name: tableName,
}
identityName := s3_constants.GetIdentityNameFromContext(r)
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return s.tablesManager.AuthorizeUpdateTable(r.Context(), s3tables.NewManagerClient(client), updateReq, identityName)
})
if err != nil {
glog.V(1).Infof("Iceberg: UpdateTable check for %s/%s failed, vending read-only: %v", bucketName, tableName, err)
}
return err == nil
}
// mayUpdateView is mayUpdateTable for a view, whose commit path is UpdateView.
func (s *Server) mayUpdateView(r *http.Request, bucketName string, namespace []string, viewName string) bool {
updateReq := &s3tables.UpdateViewRequest{
TableBucketARN: buildTableBucketARN(bucketName),
Namespace: namespace,
Name: viewName,
}
identityName := s3_constants.GetIdentityNameFromContext(r)
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return s.tablesManager.AuthorizeUpdateView(r.Context(), s3tables.NewManagerClient(client), updateReq, identityName)
})
if err != nil {
glog.V(1).Infof("Iceberg: UpdateView check for %s/%s failed, vending read-only: %v", bucketName, viewName, err)
}
return err == nil
}
// handleTableExists checks if a table exists.
func (s *Server) handleTableExists(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
+1 -1
View File
@@ -109,7 +109,7 @@ func (s *Server) handleCommitTransaction(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusConflict, "CommitFailedException", "Version token mismatch")
return
}
logManagerError("CommitTransaction UpdateTable", err)
glog.Errorf("Iceberg: CommitTransaction UpdateTable error: %v", err)
writeManagerError(w, err)
return
}
+2 -6
View File
@@ -218,9 +218,7 @@ func (s *Server) handleCreateView(w http.ResponseWriter, r *http.Request) {
if finalLocation == "" {
finalLocation = metadataLocation
}
config, _ := s.buildFileIOConfig(r, location, func() bool {
return s.mayUpdateView(r, bucketName, namespace, req.Name)
})
config, _ := s.buildFileIOConfig(r, location)
writeLoadResult(w, http.StatusOK, ViewResponse{
MetadataLocation: finalLocation,
Metadata: metadata,
@@ -351,9 +349,7 @@ func (s *Server) buildViewResponse(r *http.Request, getResp s3tables.GetViewResp
if err != nil {
return ViewResponse{}, fmt.Errorf("failed to parse view metadata: %w", err)
}
config, _ := s.buildFileIOConfig(r, tableLocationFromMetadataLocation(getResp.MetadataLocation), func() bool {
return s.mayUpdateView(r, bucketName, namespace, viewName)
})
config, _ := s.buildFileIOConfig(r, tableLocationFromMetadataLocation(getResp.MetadataLocation))
return ViewResponse{
MetadataLocation: getResp.MetadataLocation,
Metadata: metadata,
+1 -1
View File
@@ -133,7 +133,7 @@ func (s *Server) handleUpdateView(w http.ResponseWriter, r *http.Request) {
return s.tablesManager.Execute(r.Context(), mgrClient, "UpdateView", updateReq, nil, identityName)
})
if err == nil {
config, _ := s.buildFileIOConfig(r, location, func() bool { return true })
config, _ := s.buildFileIOConfig(r, location)
writeLoadResult(w, http.StatusOK, ViewResponse{
MetadataLocation: newMetadataLocation,
Metadata: newMetadata,
@@ -43,12 +43,12 @@ func TestBuildFileIOConfigWithholdsEndpointFromCredentialVendingClients(t *testi
r := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
r.Header.Set(accessDelegationHeader, "vended-credentials")
if got, _ := s.buildFileIOConfig(r, "s3://warehouse/ns/t", nil); len(got) != 0 {
if got, _ := s.buildFileIOConfig(r, "s3://warehouse/ns/t"); len(got) != 0 {
t.Fatalf("buildFileIOConfig() = %v, want empty for a vended-credentials request", got)
}
plain := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
if got, _ := s.buildFileIOConfig(plain, "s3://warehouse/ns/t", nil); got["s3.endpoint"] != s.s3Endpoint {
if got, _ := s.buildFileIOConfig(plain, "s3://warehouse/ns/t"); got["s3.endpoint"] != s.s3Endpoint {
t.Fatalf("s3.endpoint = %q, want %q", got["s3.endpoint"], s.s3Endpoint)
}
}
@@ -14,11 +14,10 @@ type stubVendor struct {
gotBucket string
gotPrefix string
gotcaller string
gotReadOnly bool
}
func (v *stubVendor) VendTableCredentials(_ context.Context, principal, bucket, prefix string, readOnly bool) (*VendedCredentials, error) {
v.gotcaller, v.gotBucket, v.gotPrefix, v.gotReadOnly = principal, bucket, prefix, readOnly
func (v *stubVendor) VendTableCredentials(_ context.Context, principal, bucket, prefix string) (*VendedCredentials, error) {
v.gotcaller, v.gotBucket, v.gotPrefix = principal, bucket, prefix
return v.credentials, v.err
}
@@ -41,7 +40,7 @@ func TestBuildFileIOConfigVendingVendsScopedCredentials(t *testing.T) {
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
r.Header.Set(accessDelegationHeader, "vended-credentials")
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t", nil)
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t")
if vendor.gotBucket != "warehouse" || vendor.gotPrefix != "ns/t" {
t.Errorf("vendor called with bucket=%q prefix=%q, want warehouse and ns/t", vendor.gotBucket, vendor.gotPrefix)
@@ -71,7 +70,7 @@ func TestBuildFileIOConfigVendingWithoutVendorSaysNothing(t *testing.T) {
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
r.Header.Set(accessDelegationHeader, "vended-credentials")
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t", nil)
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t")
if len(config) != 0 || storageCredentials != nil {
t.Errorf("config = %v, storage-credentials = %v, want both empty", config, storageCredentials)
}
@@ -82,35 +81,12 @@ func TestBuildFileIOConfigVendingFallsBackWhenVendingFails(t *testing.T) {
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
r.Header.Set(accessDelegationHeader, "vended-credentials")
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t", nil)
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t")
if len(config) != 0 || storageCredentials != nil {
t.Errorf("config = %v, storage-credentials = %v, want both empty", config, storageCredentials)
}
}
// The vend stays read-only unless the caller proved write authority: a nil
// or false canWrite must reach the vendor as readOnly.
func TestBuildFileIOConfigVendingReadOnlyUnlessCanWrite(t *testing.T) {
vendor := &stubVendor{credentials: &VendedCredentials{AccessKeyID: "ASIAEXAMPLE"}}
s := vendingServer(vendor)
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
r.Header.Set(accessDelegationHeader, "vended-credentials")
s.buildFileIOConfig(r, "s3://warehouse/ns/t", nil)
if !vendor.gotReadOnly {
t.Error("vendCredentials() passed readOnly=false for a nil canWrite, want readOnly=true")
}
s.buildFileIOConfig(r, "s3://warehouse/ns/t", func() bool { return false })
if !vendor.gotReadOnly {
t.Error("vendCredentials() passed readOnly=false for canWrite=false, want readOnly=true")
}
s.buildFileIOConfig(r, "s3://warehouse/ns/t", func() bool { return true })
if vendor.gotReadOnly {
t.Error("vendCredentials() passed readOnly=true for canWrite=true, want readOnly=false")
}
}
// A client that did not ask for delegation keeps the plain endpoint config and
// must never be handed credentials.
func TestBuildFileIOConfigVendingWithoutDelegationHeader(t *testing.T) {
@@ -118,7 +94,7 @@ func TestBuildFileIOConfigVendingWithoutDelegationHeader(t *testing.T) {
s := vendingServer(vendor)
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t", nil)
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t")
if _, vended := config["s3.access-key-id"]; vended {
t.Errorf("credentials vended without the delegation header: %v", config)
@@ -61,7 +61,7 @@ func TestBuildFileIOConfig(t *testing.T) {
t.Run("no endpoint configured yields empty config", func(t *testing.T) {
s := &Server{}
got, _ := s.buildFileIOConfig(loadTable(), "s3://warehouse/ns/t", nil)
got, _ := s.buildFileIOConfig(loadTable(), "s3://warehouse/ns/t")
if len(got) != 0 {
t.Fatalf("buildFileIOConfig() = %v, want empty", got)
}
@@ -69,7 +69,7 @@ func TestBuildFileIOConfig(t *testing.T) {
t.Run("endpoint is advertised with path-style-access and region", func(t *testing.T) {
s := &Server{s3Endpoint: "http://seaweed.example:8333"}
got, _ := s.buildFileIOConfig(loadTable(), "s3://warehouse/ns/t", nil)
got, _ := s.buildFileIOConfig(loadTable(), "s3://warehouse/ns/t")
if got["s3.endpoint"] != "http://seaweed.example:8333" {
t.Fatalf("s3.endpoint = %q, want %q", got["s3.endpoint"], "http://seaweed.example:8333")
}
@@ -1,36 +0,0 @@
package iceberg
import (
"errors"
"fmt"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
func TestLogManagerErrorRefusalsAreNotErrors(t *testing.T) {
var refusals, faults []string
oldR, oldF := logManagerRefusal, logManagerFault
logManagerRefusal = func(f string, a ...interface{}) { refusals = append(refusals, fmt.Sprintf(f, a...)) }
logManagerFault = func(f string, a ...interface{}) { faults = append(faults, fmt.Sprintf(f, a...)) }
defer func() { logManagerRefusal, logManagerFault = oldR, oldF }()
for _, c := range []struct {
err error
fault bool
}{
{&s3tables.S3TablesError{Type: s3tables.ErrCodeAccessDenied, Message: "not authorized to create namespace in this bucket"}, false},
{&s3tables.S3TablesError{Type: s3tables.ErrCodeNoSuchNamespace, Message: "no such namespace"}, false},
{&s3tables.S3TablesError{Type: s3tables.ErrCodeNamespaceAlreadyExists, Message: "exists"}, false},
{errors.New("filer: connection refused"), true}, // positive control: a real fault stays at ERROR
} {
refusals, faults = nil, nil
logManagerError("CreateNamespace", c.err)
wantFaults, wantRefusals := 0, 1
if c.fault {
wantFaults, wantRefusals = 1, 0
}
if len(faults) != wantFaults || len(refusals) != wantRefusals {
t.Errorf("%v: %d ERROR and %d refusal log(s), want %d and %d (refusals %v, faults %v)", c.err, len(faults), len(refusals), wantFaults, wantRefusals, refusals, faults)
}
}
}
+3 -4
View File
@@ -45,11 +45,10 @@ type VendedCredentials struct {
}
// CredentialVendor mints credentials limited to a single table's prefix for a
// caller the catalog has already authenticated and authorized, read-only when
// readOnly is set. A nil result with no error means the deployment has vending
// switched off.
// caller the catalog has already authenticated and authorized. A nil result
// with no error means the deployment has vending switched off.
type CredentialVendor interface {
VendTableCredentials(ctx context.Context, principal, bucket, prefix string, readOnly bool) (*VendedCredentials, error)
VendTableCredentials(ctx context.Context, principal, bucket, prefix string) (*VendedCredentials, error)
}
// Server implements the Iceberg REST Catalog API.
+16 -35
View File
@@ -158,9 +158,7 @@ func nameValidationError(err error) bool {
// errors (400) and must carry the reason -- a variant field without
// format-version 3 otherwise reads as a bare 500 with "variant is not supported
// until v3" only in the log. Anything else is a server fault (500).
// classifyManagerError maps a storage-layer error to the HTTP status, the
// Iceberg error type and the message the catalog answers with.
func classifyManagerError(err error) (int, string, string) {
func writeManagerError(w http.ResponseWriter, err error) {
switch {
case nameValidationError(err),
errors.Is(err, iceberg.ErrInvalidSchema),
@@ -168,7 +166,8 @@ func classifyManagerError(err error) (int, string, string) {
errors.Is(err, iceberg.ErrInvalidTypeString),
errors.Is(err, iceberg.ErrInvalidTransform),
errors.Is(err, iceberg.ErrInvalidArgument):
return http.StatusBadRequest, "BadRequestException", err.Error()
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
}
// Storage-layer failures are mostly the client's, not the server's. Reporting
// a missing namespace or a name conflict as a 500 makes the catalog look
@@ -180,49 +179,31 @@ func classifyManagerError(err error) (int, string, string) {
// The storage-layer message names the resolved bucket, which for a
// client that sent no warehouse at all is the default one it never
// asked for, so say how to select a real table bucket.
return http.StatusNotFound, "NoSuchNamespaceException",
fmt.Sprintf("%s: each table bucket is a separate catalog, select one with warehouse=s3://<table-bucket>/ or /v1/<table-bucket>/", tableErr.Message)
writeError(w, http.StatusNotFound, "NoSuchNamespaceException",
fmt.Sprintf("%s: each table bucket is a separate catalog, select one with warehouse=s3://<table-bucket>/ or /v1/<table-bucket>/", tableErr.Message))
case s3tables.ErrCodeNoSuchNamespace:
return http.StatusNotFound, "NoSuchNamespaceException", tableErr.Message
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", tableErr.Message)
case s3tables.ErrCodeNoSuchTable:
return http.StatusNotFound, "NoSuchTableException", tableErr.Message
writeError(w, http.StatusNotFound, "NoSuchTableException", tableErr.Message)
case s3tables.ErrCodeNoSuchView:
return http.StatusNotFound, "NoSuchViewException", tableErr.Message
writeError(w, http.StatusNotFound, "NoSuchViewException", tableErr.Message)
case s3tables.ErrCodeNamespaceAlreadyExists, s3tables.ErrCodeBucketAlreadyExists,
s3tables.ErrCodeTableAlreadyExists, s3tables.ErrCodeViewAlreadyExists:
return http.StatusConflict, "AlreadyExistsException", tableErr.Message
writeError(w, http.StatusConflict, "AlreadyExistsException", tableErr.Message)
case s3tables.ErrCodeNamespaceNotEmpty, s3tables.ErrCodeBucketNotEmpty:
return http.StatusConflict, "AlreadyExistsException", tableErr.Message
writeError(w, http.StatusConflict, "AlreadyExistsException", tableErr.Message)
case s3tables.ErrCodeConflict:
return http.StatusConflict, "CommitFailedException", tableErr.Message
writeError(w, http.StatusConflict, "CommitFailedException", tableErr.Message)
case s3tables.ErrCodeAccessDenied:
return http.StatusForbidden, "ForbiddenException", tableErr.Message
writeError(w, http.StatusForbidden, "ForbiddenException", tableErr.Message)
case s3tables.ErrCodeInvalidRequest, s3tables.ErrCodeInvalidIcebergLayout:
return http.StatusBadRequest, "BadRequestException", tableErr.Message
writeError(w, http.StatusBadRequest, "BadRequestException", tableErr.Message)
default:
writeError(w, http.StatusInternalServerError, "InternalServerError", err.Error())
}
}
return http.StatusInternalServerError, "InternalServerError", err.Error()
}
func writeManagerError(w http.ResponseWriter, err error) {
status, typ, msg := classifyManagerError(err)
writeError(w, status, typ, msg)
}
// A storage-layer error the catalog answers with a 4xx (access denied, not
// found, already exists, not empty, a conflict) is the API refusing a request
// as intended, not a server fault; it is logged at V(1). A 5xx stays at ERROR.
var (
logManagerRefusal = func(format string, args ...interface{}) { glog.V(1).Infof(format, args...) }
logManagerFault = glog.Errorf
)
func logManagerError(op string, err error) {
if status, _, _ := classifyManagerError(err); status >= 400 && status < 500 {
logManagerRefusal("Iceberg: %s refused (%d): %v", op, status, err)
return
}
logManagerFault("Iceberg: %s error: %v", op, err)
writeError(w, http.StatusInternalServerError, "InternalServerError", err.Error())
}
// resolveWarehouseBucket maps a client-supplied warehouse value to a table
+2 -6
View File
@@ -150,9 +150,7 @@ func (s *Server) handleDeclareTable(w http.ResponseWriter, r *http.Request) {
glog.V(2).Infof("lance: could not clear %s for %s: %v", deregisteredMarker, location, err)
}
// A Lance commit is a direct S3 write that never revisits the catalog, so
// the create authorization above is the write evidence for the dataset.
options, err := s.storageOptions(r, bucket, location, wants(r, "vend_credentials", req.VendCredentials), func() bool { return true })
options, err := s.storageOptions(r, bucket, location, wants(r, "vend_credentials", req.VendCredentials))
if err != nil {
writeError(w, r, http.StatusInternalServerError, codeInternal, err.Error())
return
@@ -212,9 +210,7 @@ func (s *Server) handleDescribeTable(w http.ResponseWriter, r *http.Request) {
return
}
options, err := s.storageOptions(r, bucket, location, wants(r, "vend_credentials", req.VendCredentials), func() bool {
return s.mayUpdateTable(r, bucket, ns, name)
})
options, err := s.storageOptions(r, bucket, location, wants(r, "vend_credentials", req.VendCredentials))
if err != nil {
writeError(w, r, http.StatusInternalServerError, codeInternal, err.Error())
return
+3 -4
View File
@@ -45,11 +45,10 @@ type VendedCredentials struct {
}
// CredentialVendor mints credentials limited to a single table's prefix for a
// caller the catalog has already authenticated and authorized, read-only when
// readOnly is set. A nil result with no error means the deployment has vending
// switched off.
// caller the catalog has already authenticated and authorized. A nil result with
// no error means the deployment has vending switched off.
type CredentialVendor interface {
VendTableCredentials(ctx context.Context, principal, bucket, prefix string, readOnly bool) (*VendedCredentials, error)
VendTableCredentials(ctx context.Context, principal, bucket, prefix string) (*VendedCredentials, error)
}
// Server implements the Lance Namespace REST spec.
+2 -22
View File
@@ -9,7 +9,6 @@ import (
"strings"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
@@ -136,7 +135,7 @@ func confineLocation(bucket, location string) error {
// storageOptions builds the object_store settings a Lance client needs to reach
// the dataset. The key names are the aws_-prefixed forms Lance clients pass
// through to object_store.
func (s *Server) storageOptions(r *http.Request, bucket, location string, vend bool, canWrite func() bool) (map[string]string, error) {
func (s *Server) storageOptions(r *http.Request, bucket, location string, vend bool) (map[string]string, error) {
options := map[string]string{}
if s.s3Endpoint != "" {
options["aws_endpoint"] = s.s3Endpoint
@@ -155,7 +154,7 @@ func (s *Server) storageOptions(r *http.Request, bucket, location string, vend b
}
principal := s3_constants.GetIdentityNameFromContext(r)
credentials, err := s.credentialVendor.VendTableCredentials(r.Context(), principal, bucket, locationPrefix(location), canWrite == nil || !canWrite())
credentials, err := s.credentialVendor.VendTableCredentials(r.Context(), principal, bucket, locationPrefix(location))
if err != nil {
return nil, err
}
@@ -173,25 +172,6 @@ func (s *Server) storageOptions(r *http.Request, bucket, location string, vend b
return options, nil
}
// mayUpdateTable reports whether the caller may commit to the table the
// credentials would cover, the same gate the Iceberg catalog applies so a
// read-only caller never gets write-capable storage_options.
func (s *Server) mayUpdateTable(r *http.Request, bucket string, ns []string, name string) bool {
req := &s3tables.UpdateTableRequest{
TableBucketARN: bucketARN(bucket),
Namespace: ns,
Name: name,
}
identityName := s3_constants.GetIdentityNameFromContext(r)
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return s.tablesManager.AuthorizeUpdateTable(r.Context(), s3tables.NewManagerClient(client), req, identityName)
})
if err != nil {
glog.V(1).Infof("lance: UpdateTable check for %s/%s failed, vending read-only: %v", bucket, name, err)
}
return err == nil
}
// locationPrefix strips the s3://bucket/ part of a location, leaving the key
// prefix a credential is scoped to.
func locationPrefix(location string) string {
+1 -14
View File
@@ -182,33 +182,20 @@ func resolveFromQueryParameters(query url.Values, method string, hasObject bool)
}
}
// Tagging operations. The routes match ?tagging whenever the key is
// present (gorilla/mux does not compare the value), so any value counts.
// A versionId retargets the request at a specific version, so the
// version-tagging action applies, not the current-object one.
// Tagging operations
if query.Has("tagging") {
versioned := hasObject && query.Get("versionId") != ""
switch method {
case http.MethodGet:
if versioned {
return s3_constants.S3_ACTION_GET_OBJECT_VERSION_TAGGING
}
if hasObject {
return s3_constants.S3_ACTION_GET_OBJECT_TAGGING
}
return s3_constants.S3_ACTION_GET_BUCKET_TAGGING
case http.MethodPut:
if versioned {
return s3_constants.S3_ACTION_PUT_OBJECT_VERSION_TAGGING
}
if hasObject {
return s3_constants.S3_ACTION_PUT_OBJECT_TAGGING
}
return s3_constants.S3_ACTION_PUT_BUCKET_TAGGING
case http.MethodDelete:
if versioned {
return s3_constants.S3_ACTION_DELETE_OBJECT_VERSION_TAGGING
}
if hasObject {
return s3_constants.S3_ACTION_DELETE_OBJECT_TAGGING
}
-32
View File
@@ -114,38 +114,6 @@ func TestResolveS3Action_AttributesBeforeVersionId(t *testing.T) {
}
}
// A tagging request carrying versionId targets a specific version and must
// resolve to the version-tagging action so a grant limited to current-object
// tagging cannot reach historical versions.
func TestResolveS3Action_VersionTagging(t *testing.T) {
tests := []struct {
name string
method string
object string
query string
want string
}{
{"get current tags", http.MethodGet, "key", "tagging", s3_constants.S3_ACTION_GET_OBJECT_TAGGING},
{"put current tags", http.MethodPut, "key", "tagging", s3_constants.S3_ACTION_PUT_OBJECT_TAGGING},
{"delete current tags", http.MethodDelete, "key", "tagging", s3_constants.S3_ACTION_DELETE_OBJECT_TAGGING},
{"get version tags", http.MethodGet, "key", "tagging&versionId=v1", s3_constants.S3_ACTION_GET_OBJECT_VERSION_TAGGING},
{"put version tags", http.MethodPut, "key", "tagging&versionId=v1", s3_constants.S3_ACTION_PUT_OBJECT_VERSION_TAGGING},
{"delete version tags", http.MethodDelete, "key", "tagging&versionId=v1", s3_constants.S3_ACTION_DELETE_OBJECT_VERSION_TAGGING},
{"empty versionId stays current", http.MethodGet, "key", "tagging&versionId=", s3_constants.S3_ACTION_GET_OBJECT_TAGGING},
{"bucket tagging ignores versionId", http.MethodGet, "", "tagging&versionId=v1", s3_constants.S3_ACTION_GET_BUCKET_TAGGING},
{"non-empty tagging is still tagging", http.MethodGet, "key", "tagging=x&versionId=v1", s3_constants.S3_ACTION_GET_OBJECT_VERSION_TAGGING},
{"non-empty tagging plain", http.MethodGet, "key", "tagging=x", s3_constants.S3_ACTION_GET_OBJECT_TAGGING},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, _ := http.NewRequest(tt.method, "http://localhost/bucket/"+tt.object+"?"+tt.query, nil)
if got := ResolveS3Action(r, s3_constants.ACTION_READ, "bucket", tt.object); got != tt.want {
t.Errorf("ResolveS3Action() = %q, want %q", got, tt.want)
}
})
}
}
// Bucket subresources registered with ACTION_ADMIN must resolve to their own
// S3 actions so a policy granting one of them does not need s3:*, and so no
// broader grant sweeps them in.
@@ -20,11 +20,6 @@ const (
S3_ACTION_PUT_OBJECT_TAGGING = "s3:PutObjectTagging"
S3_ACTION_DELETE_OBJECT_TAGGING = "s3:DeleteObjectTagging"
// Version-specific tagging operations (?tagging&versionId)
S3_ACTION_GET_OBJECT_VERSION_TAGGING = "s3:GetObjectVersionTagging"
S3_ACTION_PUT_OBJECT_VERSION_TAGGING = "s3:PutObjectVersionTagging"
S3_ACTION_DELETE_OBJECT_VERSION_TAGGING = "s3:DeleteObjectVersionTagging"
// Object retention and legal hold
S3_ACTION_GET_OBJECT_RETENTION = "s3:GetObjectRetention"
S3_ACTION_PUT_OBJECT_RETENTION = "s3:PutObjectRetention"
+16 -35
View File
@@ -330,7 +330,7 @@ func (s3a *S3ApiServer) PutBucketHandler(w http.ResponseWriter, r *http.Request)
// This ensures Object Lock configuration is set in the same CreateEntry call,
// preventing race conditions where the bucket exists without Object Lock enabled
var bucketCrtime int64
if err := s3a.mkdirExclusive(s3a.option.BucketsPath, bucket, func(entry *filer_pb.Entry) {
if err := s3a.mkdir(s3a.option.BucketsPath, bucket, func(entry *filer_pb.Entry) {
bucketCrtime = entry.Attributes.Crtime
// Set bucket owner
@@ -373,16 +373,6 @@ func (s3a *S3ApiServer) PutBucketHandler(w http.ResponseWriter, r *http.Request)
// return the appropriate already-exists error instead of InternalError.
if exist, checkErr := s3a.exists(s3a.option.BucketsPath, bucket, true); checkErr == nil && exist {
glog.V(3).Infof("PutBucketHandler: bucket %s was created concurrently", bucket)
// From the entry itself, not the bucket registry: the pre-check
// above has just cached this name as not found, so in the race this
// branch exists for the registry would still say "no table bucket".
if concurrentEntryIsForeign(s3a.getEntry(s3a.option.BucketsPath, bucket)) {
s3err.WriteErrorResponse(w, r, s3err.ErrBucketAlreadyExists)
return
}
if s3a.bucketConfigCache != nil {
s3a.bucketConfigCache.RemoveNegativeCache(bucket)
}
errCode := s3a.existingBucketError(r, bucket, currentIdentityId, requestHasACL)
if errCode == s3err.ErrBucketAlreadyOwnedByYou {
s3a.healBucketOwnerIndex(bucket, currentIdentityId)
@@ -754,7 +744,7 @@ func (s3a *S3ApiServer) autoCreateBucket(r *http.Request, bucket string) error {
identityId := s3_constants.GetIdentityNameFromContext(r)
var bucketCrtime int64
if err := s3a.mkdirExclusive(s3a.option.BucketsPath, bucket, func(entry *filer_pb.Entry) {
if err := s3a.mkdir(s3a.option.BucketsPath, bucket, func(entry *filer_pb.Entry) {
bucketCrtime = entry.Attributes.Crtime
setBucketOwner(r)(entry)
}); err != nil {
@@ -764,35 +754,26 @@ func (s3a *S3ApiServer) autoCreateBucket(r *http.Request, bucket string) error {
glog.Warningf("autoCreateBucket: failed to check existence for bucket %s: %v", bucket, err2)
return fmt.Errorf("failed to auto-create bucket %s: %w", bucket, errors.Join(err, err2))
} else if exist {
entry, getErr := s3a.getEntry(s3a.option.BucketsPath, bucket)
if getErr != nil {
glog.Warningf("autoCreateBucket: failed to get entry for existing bucket %s: %v", bucket, getErr)
return fmt.Errorf("failed to auto-create bucket %s: %w", bucket, errors.Join(err, getErr))
}
if concurrentEntryIsForeign(entry, nil) {
// A table bucket carries no S3 owner and must not gain one —
// or a write intended for a missing plain bucket.
if s3a.bucketConfigCache != nil {
s3a.bucketConfigCache.RemoveNegativeCache(bucket)
}
return fmt.Errorf("auto-create bucket %s: name taken by a table bucket: %w", bucket, err)
}
// The bucket exists, which is fine. However, we should ensure it has an owner.
// If it was created by a concurrent request that didn't set an owner,
// we'll set it here to ensure consistency.
if entry.Extended == nil || len(entry.Extended[s3_constants.AmzIdentityId]) == 0 {
// No owner set, assign current admin as owner
setBucketOwner(r)(entry)
if updateErr := s3a.updateEntry(s3a.option.BucketsPath, entry); updateErr != nil {
glog.Warningf("autoCreateBucket: failed to set owner for existing bucket %s: %v", bucket, updateErr)
} else {
glog.V(1).Infof("Set owner for existing bucket %s (created by concurrent request)", bucket)
if owner := bucketEntryOwner(entry); owner != "" {
if indexErr := s3a.addBucketToOwnerIndex(owner, bucket, entry.Attributes.Crtime); indexErr != nil {
glog.Warningf("autoCreateBucket: owner index add %s/%s: %v", owner, bucket, indexErr)
if entry, getErr := s3a.getEntry(s3a.option.BucketsPath, bucket); getErr == nil {
if entry.Extended == nil || len(entry.Extended[s3_constants.AmzIdentityId]) == 0 {
// No owner set, assign current admin as owner
setBucketOwner(r)(entry)
if updateErr := s3a.updateEntry(s3a.option.BucketsPath, entry); updateErr != nil {
glog.Warningf("autoCreateBucket: failed to set owner for existing bucket %s: %v", bucket, updateErr)
} else {
glog.V(1).Infof("Set owner for existing bucket %s (created by concurrent request)", bucket)
if owner := bucketEntryOwner(entry); owner != "" {
if indexErr := s3a.addBucketToOwnerIndex(owner, bucket, entry.Attributes.Crtime); indexErr != nil {
glog.Warningf("autoCreateBucket: owner index add %s/%s: %v", owner, bucket, indexErr)
}
}
}
}
} else {
glog.Warningf("autoCreateBucket: failed to get entry for existing bucket %s: %v", bucket, getErr)
}
// Remove bucket from negative cache — it exists now
if s3a.bucketConfigCache != nil {
+46 -224
View File
@@ -67,136 +67,6 @@ func (e *EmbeddedIamApi) refreshIAMConfiguration() error {
return nil
}
// canPersistIdentity reports whether a change to the named identity can be written through the
// credential manager's single-identity methods instead of SaveConfiguration. It is false when
// there is no credential manager, when the caller asked for no persistent write (skipPersist),
// and for static identities, which live outside the store: those keep the full-save path.
func (e *EmbeddedIamApi) canPersistIdentity(skipPersist bool, userName string) bool {
return e.credentialManager != nil && !skipPersist && !e.credentialManager.IsStaticIdentity(userName)
}
// groupsWithMember returns the groups in s3cfg that list userName as a member. Taken before a
// handler renames or removes the user, it names the group records that handler will change.
func groupsWithMember(s3cfg *iam_pb.S3ApiConfiguration, userName string) []*iam_pb.Group {
var groups []*iam_pb.Group
for _, g := range s3cfg.Groups {
for _, m := range g.Members {
if m == userName {
groups = append(groups, g)
break
}
}
}
return groups
}
// serviceAccountsOf returns the service accounts in s3cfg whose parent is userName.
func serviceAccountsOf(s3cfg *iam_pb.S3ApiConfiguration, userName string) []*iam_pb.ServiceAccount {
var accounts []*iam_pb.ServiceAccount
for _, sa := range s3cfg.ServiceAccounts {
if sa.ParentUser == userName {
accounts = append(accounts, sa)
}
}
return accounts
}
// persistIdentity writes the one identity a handler changed in s3cfg through the credential
// manager. The store writes that identity's record and propagates it alone; the other
// identities are not rewritten.
func (e *EmbeddedIamApi) persistIdentity(ctx context.Context, s3cfg *iam_pb.S3ApiConfiguration, userName string) error {
ident := findIdentityByName(s3cfg, userName)
if ident == nil {
return credential.ErrUserNotFound
}
return e.credentialManager.UpdateUser(ctx, userName, ident)
}
// persistRenamedIdentity writes a user renamed from oldName to newName by UpdateUser through the
// credential manager: the identity, the groups that listed the old name and the service accounts
// it parented. Where the store has RenameUser the handler already moved the record and its inline
// policies, and the identity is written under the new name. Otherwise UpdateUser under the old
// name moves the record in stores that treat a changed Name as a rename (filer_etc, memory), the
// propagating store propagates the new identity and the old name's removal, and the inline policy
// copies the handler left under the old name are removed. A store that did neither still holds
// the user under the old name; the caller is told so (false) and keeps the full save, which is
// what renames such a store today.
func (e *EmbeddedIamApi) persistRenamedIdentity(ctx context.Context, s3cfg *iam_pb.S3ApiConfiguration, oldName, newName string, groups []*iam_pb.Group, accounts []*iam_pb.ServiceAccount) (bool, error) {
ident := findIdentityByName(s3cfg, newName)
if ident == nil {
return false, credential.ErrUserNotFound
}
storedAs := oldName
if _, err := e.credentialManager.GetUser(ctx, newName); err == nil {
storedAs = newName
}
if err := e.credentialManager.UpdateUser(ctx, storedAs, ident); err != nil {
return false, err
}
if storedAs == oldName {
if _, err := e.credentialManager.GetUser(ctx, newName); err != nil {
return false, nil
}
policyNames, err := e.credentialManager.ListUserInlinePolicies(ctx, oldName)
if err != nil {
return false, fmt.Errorf("list inline policies for %s: %w", oldName, err)
}
for _, policyName := range policyNames {
if err := e.credentialManager.DeleteUserInlinePolicy(ctx, oldName, policyName); err != nil {
return false, fmt.Errorf("remove inline policy %s from %s: %w", policyName, oldName, err)
}
}
}
for _, g := range groups {
if err := e.credentialManager.UpdateGroup(ctx, g); err != nil {
return false, fmt.Errorf("update group %s: %w", g.Name, err)
}
}
for _, sa := range accounts {
if err := e.credentialManager.UpdateServiceAccount(ctx, sa.Id, sa); err != nil {
return false, fmt.Errorf("update service account %s: %w", sa.Id, err)
}
}
return true, nil
}
// deleteIdentity removes a user deleted by DeleteUser through the credential manager: its inline
// policies, its record (the store propagates the one removal) and the groups that listed it,
// whose membership DeleteUser already changed in s3cfg.
func (e *EmbeddedIamApi) deleteIdentity(ctx context.Context, userName string, groups []*iam_pb.Group) error {
policyNames, err := e.credentialManager.ListUserInlinePolicies(ctx, userName)
if err != nil {
return fmt.Errorf("list inline policies for %s: %w", userName, err)
}
for _, policyName := range policyNames {
if err := e.credentialManager.DeleteUserInlinePolicy(ctx, userName, policyName); err != nil {
return fmt.Errorf("remove inline policy %s from %s: %w", policyName, userName, err)
}
}
if err := e.credentialManager.DeleteUser(ctx, userName); err != nil {
return err
}
for _, g := range groups {
if err := e.credentialManager.UpdateGroup(ctx, g); err != nil {
return fmt.Errorf("update group %s: %w", g.Name, err)
}
}
return nil
}
// reloadsAfterTargetedWrite names the actions that persist through the credential manager
// rather than SaveConfiguration; the in-memory identity maps are refreshed after them the way
// they are after a full save.
func reloadsAfterTargetedWrite(action string) bool {
switch action {
case "AttachUserPolicy", "DetachUserPolicy", "CreatePolicy", "CreatePolicyVersion", "DeletePolicy",
"CreateUser", "UpdateUser", "DeleteUser", "CreateAccessKey", "DeleteAccessKey", "UpdateAccessKey",
"SetUserStatus", "PutUserPolicy", "DeleteUserPolicy", "PutGroupPolicy", "DeleteGroupPolicy":
return true
}
return false
}
// Constants for service account identifiers
const (
ServiceAccountIDLength = 12 // Length of the service account ID
@@ -2594,19 +2464,53 @@ func (e *EmbeddedIamApi) ListGroupPolicies(s3cfg *iam_pb.S3ApiConfiguration, val
return resp, nil
}
// handleImplicitUsername adds the name of the authenticated caller to values
// when 'UserName' is not specified, per AWS: "If you do not specify a user name,
// IAM determines the user name implicitly based on the Amazon Web Services
// access key ID signing the request." The name comes from the identity the
// authentication middleware verified, which the request context carries; the
// Authorization header itself is attacker-controlled and is never re-parsed.
// handleImplicitUsername adds username who signs the request to values if 'username' is not specified.
// According to AWS documentation: "If you do not specify a user name, IAM determines the user name
// implicitly based on the Amazon Web Services access key ID signing the request."
// This function extracts the AccessKeyId from the SigV4 credential and looks up the corresponding
// identity name in the credential store.
func (e *EmbeddedIamApi) handleImplicitUsername(r *http.Request, values url.Values) {
if values.Get("UserName") != "" {
if len(r.Header["Authorization"]) == 0 || values.Get("UserName") != "" {
return
}
if name := s3_constants.GetIdentityNameFromContext(r); name != "" {
values.Set("UserName", name)
// Log presence of auth header without exposing sensitive signature material
glog.V(4).Infof("Authorization header present, extracting access key")
// Parse AWS SigV4 Authorization header format:
// "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/iam/aws4_request, ..."
s := strings.Split(r.Header["Authorization"][0], "Credential=")
if len(s) < 2 {
return
}
s = strings.Split(s[1], ",")
if len(s) < 1 {
return
}
s = strings.Split(s[0], "/")
if len(s) < 1 {
return
}
// s[0] is the AccessKeyId
accessKeyId := s[0]
if accessKeyId == "" {
return
}
// Nil-guard: ensure iam is initialized before lookup
if e.iam == nil {
glog.V(4).Infof("IAM not initialized, cannot look up access key")
return
}
// Look up the identity by access key to get the username
identity, _, found := e.iam.LookupByAccessKey(accessKeyId)
if !found {
// Mask access key in logs - show only first 4 chars
maskedKey := accessKeyId
if len(accessKeyId) > 4 {
maskedKey = accessKeyId[:4] + "***"
}
glog.V(4).Infof("Access key %s not found in credential store", maskedKey)
return
}
values.Set("UserName", identity.Name)
}
// iamSelfServiceActions are actions that users can perform on their own resources without admin rights.
@@ -2831,79 +2735,27 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s
}
changed = false
case "UpdateUser":
userName, newUserName := values.Get("UserName"), values.Get("NewUserName")
// Taken before the rename: the records the handler changes besides the identity.
groups := groupsWithMember(s3cfg, userName)
accounts := serviceAccountsOf(s3cfg, userName)
var iamErr *iamError
response, iamErr = e.UpdateUser(ctx, s3cfg, values)
if iamErr != nil {
return nil, iamErr
}
// Write the renamed identity, its groups and service accounts through the credential
// manager instead of rewriting every identity via SaveConfiguration. A request that
// renames nothing changed nothing, so there is nothing to write either way.
if e.canPersistIdentity(skipPersist, userName) {
renamed := true
if newUserName != "" && newUserName != userName {
var err error
renamed, err = e.persistRenamedIdentity(ctx, s3cfg, userName, newUserName, groups, accounts)
if err != nil {
return nil, &iamError{Code: CredentialErrToIamErrCode(err), Error: err}
}
}
changed = !renamed
}
case "DeleteUser":
userName := values.Get("UserName")
groups := groupsWithMember(s3cfg, userName)
var iamErr *iamError
response, iamErr = e.DeleteUser(s3cfg, userName)
if iamErr != nil {
return nil, iamErr
}
// Targeted delete: one record removed and one removal propagated, plus the groups whose
// membership changed, instead of rewriting every remaining identity.
if e.canPersistIdentity(skipPersist, userName) {
if err := e.deleteIdentity(ctx, userName, groups); err != nil {
return nil, &iamError{Code: CredentialErrToIamErrCode(err), Error: err}
}
changed = false
}
case "CreateAccessKey":
userName := values.Get("UserName")
resp, iamErr := e.CreateAccessKey(s3cfg, values)
var iamErr *iamError
response, iamErr = e.CreateAccessKey(s3cfg, values)
if iamErr != nil {
glog.Errorf("CreateAccessKey: %+v", iamErr.Error)
return nil, iamErr
}
response = resp
// Targeted write: credentialManager.CreateAccessKey adds the key to the one identity
// and propagates that identity alone.
if e.canPersistIdentity(skipPersist, userName) {
cred := &iam_pb.Credential{
AccessKey: *resp.CreateAccessKeyResult.AccessKey.AccessKeyId,
SecretKey: *resp.CreateAccessKeyResult.AccessKey.SecretAccessKey,
Status: iamAccessKeyStatusActive,
}
if err := e.credentialManager.CreateAccessKey(ctx, userName, cred); err != nil {
return nil, &iamError{Code: CredentialErrToIamErrCode(err), Error: err}
}
changed = false
}
case "DeleteAccessKey":
userName, accessKeyId := values.Get("UserName"), values.Get("AccessKeyId")
response = e.DeleteAccessKey(s3cfg, values)
// Targeted write: credentialManager.DeleteAccessKey removes the key from the one identity
// and propagates that identity alone. The handler answers 200 for a key or user it does
// not know, so a not-found from the store is the same no-op it always was.
if e.canPersistIdentity(skipPersist, userName) {
err := e.credentialManager.DeleteAccessKey(ctx, userName, accessKeyId)
if err != nil && !errors.Is(err, credential.ErrAccessKeyNotFound) && !errors.Is(err, credential.ErrUserNotFound) {
return nil, &iamError{Code: CredentialErrToIamErrCode(err), Error: err}
}
changed = false
}
case "CreatePolicy":
var iamErr *iamError
response, iamErr = e.CreatePolicy(ctx, values)
@@ -2921,21 +2773,12 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s
}
changed = false
case "PutUserPolicy":
userName := values.Get("UserName")
var iamErr *iamError
response, iamErr = e.PutUserPolicy(s3cfg, values)
if iamErr != nil {
glog.Errorf("PutUserPolicy: %+v", iamErr.Error)
return nil, iamErr
}
// The policy document is already stored; what remains is the identity's recomputed
// Actions, written and propagated for that identity alone.
if e.canPersistIdentity(skipPersist, userName) {
if err := e.persistIdentity(ctx, s3cfg, userName); err != nil {
return nil, &iamError{Code: CredentialErrToIamErrCode(err), Error: err}
}
changed = false
}
case "GetUserPolicy":
var iamErr *iamError
response, iamErr = e.GetUserPolicy(s3cfg, values)
@@ -2944,18 +2787,11 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s
}
changed = false
case "DeleteUserPolicy":
userName := values.Get("UserName")
var iamErr *iamError
response, iamErr = e.DeleteUserPolicy(s3cfg, values)
if iamErr != nil {
return nil, iamErr
}
if e.canPersistIdentity(skipPersist, userName) {
if err := e.persistIdentity(ctx, s3cfg, userName); err != nil {
return nil, &iamError{Code: CredentialErrToIamErrCode(err), Error: err}
}
changed = false
}
case "ListUserPolicies":
var iamErr *iamError
response, iamErr = e.ListUserPolicies(s3cfg, values)
@@ -3047,31 +2883,17 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s
}
changed = false
case "SetUserStatus":
userName := values.Get("UserName")
var iamErr *iamError
response, iamErr = e.SetUserStatus(s3cfg, values)
if iamErr != nil {
return nil, iamErr
}
if e.canPersistIdentity(skipPersist, userName) {
if err := e.persistIdentity(ctx, s3cfg, userName); err != nil {
return nil, &iamError{Code: CredentialErrToIamErrCode(err), Error: err}
}
changed = false
}
case "UpdateAccessKey":
userName := values.Get("UserName")
var iamErr *iamError
response, iamErr = e.UpdateAccessKey(s3cfg, values)
if iamErr != nil {
return nil, iamErr
}
if e.canPersistIdentity(skipPersist, userName) {
if err := e.persistIdentity(ctx, s3cfg, userName); err != nil {
return nil, &iamError{Code: CredentialErrToIamErrCode(err), Error: err}
}
changed = false
}
// Service Account actions
case "CreateServiceAccount":
createdBy := values.Get("CreatedBy")
@@ -3212,7 +3034,7 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s
glog.Errorf("Failed to reload IAM configuration after mutation: %v", err)
// Don't fail the request since the persistent save succeeded
}
} else if reloadsAfterTargetedWrite(action) {
} else if action == "AttachUserPolicy" || action == "DetachUserPolicy" || action == "CreatePolicy" || action == "CreatePolicyVersion" || action == "DeletePolicy" || action == "CreateUser" || action == "PutGroupPolicy" || action == "DeleteGroupPolicy" {
// Even if changed=false (persisted via credentialManager), we should still reload
// if we are utilizing the local in-memory cache for speed
if err := e.ReloadConfiguration(); err != nil {
@@ -1,362 +0,0 @@
package s3api
// Targeted-write regression tests: single-identity IAM actions must write and
// propagate only the identity they changed, not every identity via
// SaveConfiguration. See https://github.com/seaweedfs/seaweedfs/issues/11865.
import (
"context"
"encoding/json"
"net/url"
"testing"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/credential/memory"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// recordingStore wraps a memory store and counts the write methods the
// dispatcher used, so a test can assert the single-identity path was taken and
// SaveConfiguration was not. It embeds the concrete store (rather than the
// CredentialStore interface) so optional interfaces like InlinePolicyStore and
// UserRenamer stay visible through the wrapper.
type recordingStore struct {
*memory.MemoryStore
saves int
createAccessKeys int
deleteAccessKeys int
updateUsers int
deleteUsers int
updateGroups int
}
func (r *recordingStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
r.saves++
return r.MemoryStore.SaveConfiguration(ctx, config)
}
func (r *recordingStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
r.createAccessKeys++
return r.MemoryStore.CreateAccessKey(ctx, username, cred)
}
func (r *recordingStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
r.deleteAccessKeys++
return r.MemoryStore.DeleteAccessKey(ctx, username, accessKey)
}
func (r *recordingStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
r.updateUsers++
return r.MemoryStore.UpdateUser(ctx, username, identity)
}
func (r *recordingStore) DeleteUser(ctx context.Context, username string) error {
r.deleteUsers++
return r.MemoryStore.DeleteUser(ctx, username)
}
func (r *recordingStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
r.updateGroups++
return r.MemoryStore.UpdateGroup(ctx, group)
}
// newTargetedWriteTestApi seeds cfg into the store and wraps the store in a
// recordingStore. The initial sync save happens before the recorder is
// installed so it does not count.
func newTargetedWriteTestApi(t *testing.T, cfg *iam_pb.S3ApiConfiguration) (*EmbeddedIamApiForTest, *recordingStore) {
t.Helper()
api := NewEmbeddedIamApiForTest()
api.mockConfig = cfg
// Trigger the one-time mockConfig -> store seed through a read action.
_, iamErr := api.ExecuteAction(context.Background(), url.Values{"Action": {"ListUsers"}}, false, "")
require.Nil(t, iamErr)
rec := &recordingStore{MemoryStore: api.credentialManager.Store.(*memory.MemoryStore)}
api.credentialManager.Store = rec
return api, rec
}
func executeAction(t *testing.T, api *EmbeddedIamApiForTest, vals url.Values) {
t.Helper()
_, iamErr := api.ExecuteAction(context.Background(), vals, false, "")
require.Nil(t, iamErr)
}
func TestEmbeddedIamCreateAccessKeyTargeted(t *testing.T) {
api, rec := newTargetedWriteTestApi(t, &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}, {Name: "bob"}},
})
ctx := context.Background()
resp, iamErr := api.ExecuteAction(ctx, url.Values{
"Action": {"CreateAccessKey"},
"UserName": {"alice"},
}, false, "")
require.Nil(t, iamErr)
require.Equal(t, 0, rec.saves, "CreateAccessKey must not SaveConfiguration")
require.Equal(t, 1, rec.createAccessKeys)
out := resp.(*iamCreateAccessKeyResponse)
ident, err := api.credentialManager.GetUser(ctx, "alice")
require.NoError(t, err)
require.Len(t, ident.Credentials, 1)
assert.Equal(t, *out.CreateAccessKeyResult.AccessKey.AccessKeyId, ident.Credentials[0].AccessKey)
// The new key resolves in the store.
byKey, err := api.credentialManager.GetUserByAccessKey(ctx, *out.CreateAccessKeyResult.AccessKey.AccessKeyId)
require.NoError(t, err)
assert.Equal(t, "alice", byKey.Name)
}
func TestEmbeddedIamDeleteAccessKeyTargeted(t *testing.T) {
api, rec := newTargetedWriteTestApi(t, &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{
Name: "alice",
Credentials: []*iam_pb.Credential{{AccessKey: "AKIATESTFAKEKEY00001", SecretKey: "testsecretfake"}},
}},
})
ctx := context.Background()
executeAction(t, api, url.Values{
"Action": {"DeleteAccessKey"},
"UserName": {"alice"},
"AccessKeyId": {"AKIATESTFAKEKEY00001"},
})
require.Equal(t, 0, rec.saves, "DeleteAccessKey must not SaveConfiguration")
require.Equal(t, 1, rec.deleteAccessKeys)
ident, err := api.credentialManager.GetUser(ctx, "alice")
require.NoError(t, err)
assert.Empty(t, ident.Credentials)
_, err = api.credentialManager.GetUserByAccessKey(ctx, "AKIATESTFAKEKEY00001")
require.ErrorIs(t, err, credential.ErrAccessKeyNotFound)
}
func TestEmbeddedIamUpdateAccessKeyTargeted(t *testing.T) {
api, rec := newTargetedWriteTestApi(t, &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{
Name: "alice",
Credentials: []*iam_pb.Credential{
{AccessKey: "AKIATESTFAKEKEY00001", SecretKey: "testsecretfake", Status: iamAccessKeyStatusActive},
},
}},
})
ctx := context.Background()
executeAction(t, api, url.Values{
"Action": {"UpdateAccessKey"},
"UserName": {"alice"},
"AccessKeyId": {"AKIATESTFAKEKEY00001"},
"Status": {iamAccessKeyStatusInactive},
})
require.Equal(t, 0, rec.saves, "UpdateAccessKey must not SaveConfiguration")
require.Equal(t, 1, rec.updateUsers)
ident, err := api.credentialManager.GetUser(ctx, "alice")
require.NoError(t, err)
require.Len(t, ident.Credentials, 1)
assert.Equal(t, iamAccessKeyStatusInactive, ident.Credentials[0].Status)
}
const iamTargetedTestPolicy = `{
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::bench/*"]}]
}`
func TestEmbeddedIamPutUserPolicyTargeted(t *testing.T) {
api, rec := newTargetedWriteTestApi(t, &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}, {Name: "bob"}},
})
ctx := context.Background()
executeAction(t, api, url.Values{
"Action": {"PutUserPolicy"},
"UserName": {"alice"},
"PolicyName": {"read-bench"},
"PolicyDocument": {iamTargetedTestPolicy},
})
require.Equal(t, 0, rec.saves, "PutUserPolicy must not SaveConfiguration")
require.Equal(t, 1, rec.updateUsers)
ident, err := api.credentialManager.GetUser(ctx, "alice")
require.NoError(t, err)
assert.NotEmpty(t, ident.Actions, "identity Actions must be recomputed and persisted")
doc, err := api.credentialManager.GetUserInlinePolicy(ctx, "alice", "read-bench")
require.NoError(t, err)
require.NotNil(t, doc)
}
func TestEmbeddedIamDeleteUserPolicyTargeted(t *testing.T) {
api, rec := newTargetedWriteTestApi(t, &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}},
})
ctx := context.Background()
executeAction(t, api, url.Values{
"Action": {"PutUserPolicy"},
"UserName": {"alice"},
"PolicyName": {"read-bench"},
"PolicyDocument": {iamTargetedTestPolicy},
})
require.Equal(t, 1, rec.updateUsers)
executeAction(t, api, url.Values{
"Action": {"DeleteUserPolicy"},
"UserName": {"alice"},
"PolicyName": {"read-bench"},
})
require.Equal(t, 0, rec.saves, "DeleteUserPolicy must not SaveConfiguration")
require.Equal(t, 2, rec.updateUsers)
policies, err := api.credentialManager.ListUserInlinePolicies(ctx, "alice")
require.NoError(t, err)
assert.Empty(t, policies)
ident, err := api.credentialManager.GetUser(ctx, "alice")
require.NoError(t, err)
assert.Empty(t, ident.Actions)
}
func TestEmbeddedIamSetUserStatusTargeted(t *testing.T) {
api, rec := newTargetedWriteTestApi(t, &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}},
})
ctx := context.Background()
executeAction(t, api, url.Values{
"Action": {"SetUserStatus"},
"UserName": {"alice"},
"Status": {iamAccessKeyStatusInactive},
})
require.Equal(t, 0, rec.saves, "SetUserStatus must not SaveConfiguration")
require.Equal(t, 1, rec.updateUsers)
ident, err := api.credentialManager.GetUser(ctx, "alice")
require.NoError(t, err)
assert.True(t, ident.Disabled)
}
func TestEmbeddedIamUpdateUserRenameTargeted(t *testing.T) {
api, rec := newTargetedWriteTestApi(t, &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}, {Name: "bob"}},
Groups: []*iam_pb.Group{
{Name: "devs", Members: []string{"alice", "bob"}},
{Name: "ops", Members: []string{"bob"}},
},
})
ctx := context.Background()
// Groups in the seed config must also be created in the store directly:
// the memory store only surfaces groups written via CreateGroup.
require.NoError(t, api.credentialManager.CreateGroup(ctx, &iam_pb.Group{Name: "devs", Members: []string{"alice", "bob"}}))
require.NoError(t, api.credentialManager.CreateGroup(ctx, &iam_pb.Group{Name: "ops", Members: []string{"bob"}}))
require.NoError(t, api.credentialManager.PutUserInlinePolicy(ctx, "alice", "p1", mustParseIamTargetedTestPolicy(t)))
executeAction(t, api, url.Values{
"Action": {"UpdateUser"},
"UserName": {"alice"},
"NewUserName": {"alice-renamed"},
})
require.Equal(t, 0, rec.saves, "UpdateUser rename must not SaveConfiguration")
_, err := api.credentialManager.GetUser(ctx, "alice")
require.ErrorIs(t, err, credential.ErrUserNotFound)
_, err = api.credentialManager.GetUser(ctx, "alice-renamed")
require.NoError(t, err)
// Group membership and inline policies follow the rename.
g, err := api.credentialManager.GetGroup(ctx, "devs")
require.NoError(t, err)
assert.ElementsMatch(t, []string{"alice-renamed", "bob"}, g.Members)
g, err = api.credentialManager.GetGroup(ctx, "ops")
require.NoError(t, err)
assert.ElementsMatch(t, []string{"bob"}, g.Members)
policies, err := api.credentialManager.ListUserInlinePolicies(ctx, "alice-renamed")
require.NoError(t, err)
assert.Equal(t, []string{"p1"}, policies)
policies, err = api.credentialManager.ListUserInlinePolicies(ctx, "alice")
require.NoError(t, err)
assert.Empty(t, policies)
}
func TestEmbeddedIamDeleteUserTargeted(t *testing.T) {
api, rec := newTargetedWriteTestApi(t, &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}, {Name: "bob"}},
Groups: []*iam_pb.Group{{Name: "devs", Members: []string{"alice", "bob"}}},
})
ctx := context.Background()
require.NoError(t, api.credentialManager.CreateGroup(ctx, &iam_pb.Group{Name: "devs", Members: []string{"alice", "bob"}}))
executeAction(t, api, url.Values{
"Action": {"DeleteUser"},
"UserName": {"alice"},
})
require.Equal(t, 0, rec.saves, "DeleteUser must not SaveConfiguration")
require.Equal(t, 1, rec.deleteUsers)
require.Equal(t, 1, rec.updateGroups)
_, err := api.credentialManager.GetUser(ctx, "alice")
require.ErrorIs(t, err, credential.ErrUserNotFound)
g, err := api.credentialManager.GetGroup(ctx, "devs")
require.NoError(t, err)
assert.ElementsMatch(t, []string{"bob"}, g.Members)
_, err = api.credentialManager.GetUser(ctx, "bob")
require.NoError(t, err, "other identities must be untouched")
}
// TestEmbeddedIamTargetedSkipPersist: skipPersist must suppress the targeted
// write exactly like it suppresses the full save.
func TestEmbeddedIamTargetedSkipPersist(t *testing.T) {
api, rec := newTargetedWriteTestApi(t, &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}},
})
ctx := context.Background()
for _, vals := range []url.Values{
{"Action": {"CreateAccessKey"}, "UserName": {"alice"}},
{"Action": {"SetUserStatus"}, "UserName": {"alice"}, "Status": {iamAccessKeyStatusInactive}},
{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"p"}, "PolicyDocument": {iamTargetedTestPolicy}},
} {
_, iamErr := api.ExecuteAction(ctx, vals, true /*skipPersist*/, "")
require.Nil(t, iamErr, "action %v", vals.Get("Action"))
}
assert.Equal(t, 0, rec.saves)
assert.Equal(t, 0, rec.createAccessKeys)
assert.Equal(t, 0, rec.updateUsers)
ident, err := api.credentialManager.GetUser(ctx, "alice")
require.NoError(t, err)
assert.Empty(t, ident.Credentials)
assert.False(t, ident.Disabled)
}
// TestEmbeddedIamStaticIdentityKeepsFullSave: static identities live outside
// the store, so a change to one still takes the full-save path.
func TestEmbeddedIamStaticIdentityKeepsFullSave(t *testing.T) {
api := NewEmbeddedIamApiForTest()
api.mockConfig = &iam_pb.S3ApiConfiguration{}
api.credentialManager.SetStaticIdentities([]*iam_pb.Identity{{Name: "static-admin"}})
rec := &recordingStore{MemoryStore: api.credentialManager.Store.(*memory.MemoryStore)}
api.credentialManager.Store = rec
// Seed via a read action; static identities are merged into the loaded config.
_, iamErr := api.ExecuteAction(context.Background(), url.Values{"Action": {"ListUsers"}}, false, "")
require.Nil(t, iamErr)
rec.saves = 0
executeAction(t, api, url.Values{
"Action": {"SetUserStatus"},
"UserName": {"static-admin"},
"Status": {iamAccessKeyStatusInactive},
})
assert.Equal(t, 1, rec.saves, "static identity changes keep the full-save path")
assert.Equal(t, 0, rec.updateUsers)
}
func mustParseIamTargetedTestPolicy(t *testing.T) (doc policy_engine.PolicyDocument) {
t.Helper()
require.NoError(t, json.Unmarshal([]byte(iamTargetedTestPolicy), &doc))
return doc
}
+31 -17
View File
@@ -372,13 +372,13 @@ func TestEmbeddedIamGetUserImplicitUsername(t *testing.T) {
t.Fatalf("failed to load iam config: %v", err)
}
// GetUser request with NO UserName, carrying the identity authentication
// verified for accessKey, the way the middleware records it.
// GetUser request with NO UserName, but signed by accessKey.
form := url.Values{}
form.Set("Action", "GetUser")
req, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(SetIdentityNameInContext(req.Context(), "TestUser"))
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+accessKey+
"/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesig")
out := iamGetUserResponse{}
rr, err := executeEmbeddedIamRequest(api, req, &out)
@@ -1578,15 +1578,29 @@ func TestEmbeddedIamDeleteAccessKey(t *testing.T) {
assert.Len(t, api.mockConfig.Identities[0].Credentials, 0)
}
// TestEmbeddedIamHandleImplicitUsername checks that the implicit user name is
// taken from the authenticated identity in the request context, never from
// re-parsing the Authorization header.
// TestEmbeddedIamHandleImplicitUsername tests implicit username extraction from authorization header
func TestEmbeddedIamHandleImplicitUsername(t *testing.T) {
embeddedApi := &EmbeddedIamApi{}
// Create IAM with test credentials - the handleImplicitUsername function now looks
// up the username from the credential store based on AccessKeyId
// Note: Using obviously fake access keys to avoid secret scanner false positives
iam := &IdentityAccessManagement{}
testConfig := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: "testuser1",
Credentials: []*iam_pb.Credential{
{AccessKey: UserAccessKeyPrefix + "TESTFAKEKEY000001", SecretKey: "testsecretfake"},
},
},
},
}
err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(testConfig))
if err != nil {
t.Fatalf("Failed to load test config: %v", err)
}
withIdentity := func(name string) *http.Request {
r := &http.Request{Header: http.Header{}}
return r.WithContext(SetIdentityNameInContext(r.Context(), name))
embeddedApi := &EmbeddedIamApi{
iam: iam,
}
var tests = []struct {
@@ -1594,14 +1608,14 @@ func TestEmbeddedIamHandleImplicitUsername(t *testing.T) {
values url.Values
userName string
}{
// No authenticated identity - should not set username
// No authorization header - should not set username
{&http.Request{}, url.Values{}, ""},
// Authenticated identity in context - sets its name
{withIdentity("testuser1"), url.Values{}, "testuser1"},
// A client-controlled Authorization header naming another user is ignored
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=" + UserAccessKeyPrefix + "TESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
// An explicit UserName is left alone
{withIdentity("testuser1"), url.Values{"UserName": {"other"}}, "other"},
// Valid auth header with known access key - should look up and find "testuser1"
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=" + UserAccessKeyPrefix + "TESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, "testuser1"},
// Malformed auth header (no Credential=) - should not set username
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 =" + UserAccessKeyPrefix + "TESTFAKEKEY000001/20220420/test1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
// Unknown access key - should not set username
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=" + UserAccessKeyPrefix + "TESTUNKNOWN000000/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
}
for i, test := range tests {
+9 -16
View File
@@ -29,12 +29,11 @@ type VendedCredentials struct {
Expiration time.Time
}
// VendTableCredentials mints credentials that can read exactly one table's
// prefix, and write it too unless readOnly, for a caller the Iceberg catalog
// has already authenticated and authorized. Returns nil when no role is
// configured, which leaves the catalog telling the client to keep using its
// own credentials.
func (s3a *S3ApiServer) VendTableCredentials(ctx context.Context, principal, bucket, prefix string, readOnly bool) (*VendedCredentials, error) {
// VendTableCredentials mints credentials that can read and write exactly one
// table's prefix, for a caller the Iceberg catalog has already authenticated
// and authorized. Returns nil when no role is configured, which leaves the
// catalog telling the client to keep using its own credentials.
func (s3a *S3ApiServer) VendTableCredentials(ctx context.Context, principal, bucket, prefix string) (*VendedCredentials, error) {
if s3a.icebergCredentialRole == "" {
return nil, nil
}
@@ -50,7 +49,7 @@ func (s3a *S3ApiServer) VendTableCredentials(ctx context.Context, principal, buc
return nil, fmt.Errorf("STS is not configured")
}
policy, err := tablePrefixSessionPolicy(bucket, prefix, readOnly)
policy, err := tablePrefixSessionPolicy(bucket, prefix)
if err != nil {
return nil, err
}
@@ -99,9 +98,8 @@ func (s3a *S3ApiServer) stsService() *sts.STSService {
}
// tablePrefixSessionPolicy narrows a session to one table's files plus the
// bucket listing that clients need to resolve them. A read-only session drops
// the write verbs: the catalog vends no more authority than the caller holds.
func tablePrefixSessionPolicy(bucket, prefix string, readOnly bool) (string, error) {
// bucket listing that clients need to resolve them.
func tablePrefixSessionPolicy(bucket, prefix string) (string, error) {
prefix = strings.Trim(prefix, "/")
// A resource pattern is matched with wildcards, so a location carrying one
// would widen the session to sibling tables. Refuse rather than escape it:
@@ -116,17 +114,12 @@ func tablePrefixSessionPolicy(bucket, prefix string, readOnly bool) (string, err
return "", fmt.Errorf("refusing to scope credentials to a whole bucket: s3://%s", bucket)
}
objectActions := []string{"s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"}
if readOnly {
objectActions = []string{"s3:GetObject"}
}
policy := map[string]interface{}{
"Version": "2012-10-17",
"Statement": []map[string]interface{}{
{
"Effect": "Allow",
"Action": objectActions,
"Action": []string{"s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"},
"Resource": []string{fmt.Sprintf("arn:aws:s3:::%s/%s/*", bucket, prefix)},
},
{
+9 -39
View File
@@ -8,7 +8,7 @@ import (
)
func TestTablePrefixSessionPolicyScopesToTheTable(t *testing.T) {
raw, err := tablePrefixSessionPolicy("warehouse", "analytics/events", false)
raw, err := tablePrefixSessionPolicy("warehouse", "analytics/events")
if err != nil {
t.Fatalf("tablePrefixSessionPolicy() error = %v", err)
}
@@ -42,43 +42,13 @@ func TestTablePrefixSessionPolicyScopesToTheTable(t *testing.T) {
}
}
// A caller the catalog would not let commit gets a session that can read the
// table's files and nothing more.
func TestTablePrefixSessionPolicyReadOnly(t *testing.T) {
raw, err := tablePrefixSessionPolicy("warehouse", "analytics/events", true)
if err != nil {
t.Fatalf("tablePrefixSessionPolicy() error = %v", err)
}
var policy struct {
Statement []struct {
Action []string `json:"Action"`
} `json:"Statement"`
}
if err := json.Unmarshal([]byte(raw), &policy); err != nil {
t.Fatalf("policy is not valid JSON: %v", err)
}
for _, statement := range policy.Statement {
for _, action := range statement.Action {
switch action {
case "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts":
t.Errorf("read-only session policy grants %s", action)
}
}
}
if got := policy.Statement[0].Action; len(got) != 1 || got[0] != "s3:GetObject" {
t.Errorf("object actions = %v, want [s3:GetObject]", got)
}
}
// A table at the bucket root has no prefix to scope to, and vending would hand
// out read and write over every other table in the bucket.
func TestTablePrefixSessionPolicyRefusesAWholeBucket(t *testing.T) {
if _, err := tablePrefixSessionPolicy("warehouse", "", false); err == nil {
if _, err := tablePrefixSessionPolicy("warehouse", ""); err == nil {
t.Error("tablePrefixSessionPolicy() error = nil, want a refusal for an empty prefix")
}
if _, err := tablePrefixSessionPolicy("warehouse", "/", false); err == nil {
if _, err := tablePrefixSessionPolicy("warehouse", "/"); err == nil {
t.Error("a prefix of only separators was accepted")
}
}
@@ -86,7 +56,7 @@ func TestTablePrefixSessionPolicyRefusesAWholeBucket(t *testing.T) {
// GetBucketLocation carries no prefix, so conditioning it would deny location
// discovery to every vended credential.
func TestTablePrefixSessionPolicyLeavesBucketLocationUnconditioned(t *testing.T) {
raw, err := tablePrefixSessionPolicy("warehouse", "analytics/events", false)
raw, err := tablePrefixSessionPolicy("warehouse", "analytics/events")
if err != nil {
t.Fatalf("tablePrefixSessionPolicy() error = %v", err)
}
@@ -131,7 +101,7 @@ func TestIcebergSessionNameIsBounded(t *testing.T) {
// telling clients to use their own credentials.
func TestVendTableCredentialsDisabledWithoutRole(t *testing.T) {
s3a := &S3ApiServer{}
credentials, err := s3a.VendTableCredentials(context.Background(), "admin", "warehouse", "ns/t", false)
credentials, err := s3a.VendTableCredentials(context.Background(), "admin", "warehouse", "ns/t")
if err != nil {
t.Fatalf("VendTableCredentials() error = %v, want nil", err)
}
@@ -144,7 +114,7 @@ func TestVendTableCredentialsNeedsSTS(t *testing.T) {
s3a := &S3ApiServer{}
s3a.SetIcebergCredentialRole("arn:aws:iam::role/IcebergTableAccess", 3600)
if _, err := s3a.VendTableCredentials(context.Background(), "admin", "warehouse", "ns/t", false); err == nil {
if _, err := s3a.VendTableCredentials(context.Background(), "admin", "warehouse", "ns/t"); err == nil {
t.Error("VendTableCredentials() error = nil, want an error when STS is not configured")
}
}
@@ -152,7 +122,7 @@ func TestVendTableCredentialsNeedsSTS(t *testing.T) {
// Listing is granted on the bucket, so the prefix condition is what keeps a
// vended credential from enumerating other tables.
func TestTablePrefixSessionPolicyRestrictsListing(t *testing.T) {
raw, err := tablePrefixSessionPolicy("warehouse", "analytics/events", false)
raw, err := tablePrefixSessionPolicy("warehouse", "analytics/events")
if err != nil {
t.Fatalf("tablePrefixSessionPolicy() error = %v", err)
}
@@ -198,11 +168,11 @@ func TestTablePrefixSessionPolicyRestrictsListing(t *testing.T) {
// tables, so it is refused instead of vended.
func TestTablePrefixSessionPolicyRefusesWildcards(t *testing.T) {
for _, prefix := range []string{"analytics/*", "analytics/ev?nts", "*"} {
if _, err := tablePrefixSessionPolicy("warehouse", prefix, false); err == nil {
if _, err := tablePrefixSessionPolicy("warehouse", prefix); err == nil {
t.Errorf("tablePrefixSessionPolicy(%q) error = nil, want a refusal", prefix)
}
}
if _, err := tablePrefixSessionPolicy("ware*house", "analytics/events", false); err == nil {
if _, err := tablePrefixSessionPolicy("ware*house", "analytics/events"); err == nil {
t.Error("a wildcard in the bucket name was accepted")
}
}
@@ -174,7 +174,7 @@ func TestAnonymousObjectACLVersionedEmptyDirectoryMarker(t *testing.T) {
require.Equal(t, "null", rr.Header().Get("x-amz-version-id"))
}
rr := serveAnonymousRead(s3a, method, "folder", "", nil)
require.Equal(t, 403, rr.Code, "the bare prefix is not an object, and the miss stays masked for a non-listable bucket")
require.Equal(t, 404, rr.Code, "the bare prefix is still not an object")
marker.Extended[s3_constants.ExtAmzAclKey] = []byte(`[]`)
rr = serveAnonymousRead(s3a, method, "folder/", "", nil)
require.Equal(t, 403, rr.Code, rr.Body.String())
@@ -238,12 +238,8 @@ func TestAnonymousObjectACLVersionedDirectoryIdentity(t *testing.T) {
f.entries["/buckets/b/folder/.versions"] = &filer_pb.Entry{IsDirectory: true, Extended: map[string][]byte{s3_constants.ExtLatestVersionIdKey: []byte("")}}
}
// Equivalent filer-path spellings must not recover a different object's data.
assertMissing(serveAnonymousRead(s3a, method, "folder/", route.query, headers))
// Spellings the policy's folder/ grant does not cover are
// masked: a miss must be indistinguishable from a denied read.
for _, object := range []string{"folder//", "folder\\"} {
rr := serveAnonymousRead(s3a, method, object, route.query, headers)
require.Equal(t, http.StatusForbidden, rr.Code, rr.Body.String())
for _, object := range []string{"folder/", "folder//", "folder\\"} {
assertMissing(serveAnonymousRead(s3a, method, object, route.query, headers))
}
if kind == "regular file" || kind == "prefix object" || kind == "empty prefix object" {
// A slash-only policy must not authorize data belonging to the bare key.
-71
View File
@@ -4,12 +4,10 @@ import (
"context"
"encoding/json"
"net/http"
"net/url"
"path"
"strings"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
@@ -112,72 +110,3 @@ func (s3a *S3ApiServer) authorizeAnonymousObjectRead(r *http.Request, bucket, ob
}
return s3err.ErrAccessDenied
}
// authorizeAnonymousBucketList reports whether an anonymous caller may learn
// which keys exist in the bucket: bucket listing via policy or identity, or a
// public-read bucket. Anything else masks the miss as AccessDenied.
func (s3a *S3ApiServer) authorizeAnonymousBucketList(r *http.Request, bucket string) s3err.ErrorCode {
identity, _ := s3_constants.GetIdentityFromContext(r).(*Identity)
object := mux.Vars(r)["object"]
// The caller's query parameters would otherwise retarget these checks
// (?policy resolves to GetBucketPolicy) or satisfy a prefix condition on an
// unrelated subtree, so each runs on a request with a fixed shape: a
// ListBucket scoped to the probed key, and a plain read of the key itself.
// A caller that could learn the key's existence either way — by listing or
// by getting it — gets the real miss.
listReq := r.Clone(r.Context())
listReq.URL = &url.URL{Path: "/" + bucket, RawQuery: url.Values{"prefix": {object}}.Encode()}
// versionId must survive: a read of a specific version resolves to
// s3:GetObjectVersion, and dropping it would let a GetObject grant answer
// for versions the caller may not read.
readQuery := url.Values{}
if v := r.URL.Query().Get("versionId"); v != "" {
readQuery.Set("versionId", v)
}
readReq := r.Clone(r.Context())
readReq.URL = &url.URL{Path: "/" + bucket + "/" + object, RawQuery: readQuery.Encode()}
principal := buildPrincipalARN(identity, r)
policyAllowed := false
code, allowed := s3a.checkPolicyWithEntry(listReq, bucket, "", string(s3_constants.ACTION_LIST), principal, nil)
if code != s3err.ErrNone {
return code
}
policyAllowed = allowed
if !policyAllowed {
code, allowed = s3a.checkPolicyWithEntry(readReq, bucket, object, string(s3_constants.ACTION_READ), principal, nil)
if code != s3err.ErrNone {
return code
}
policyAllowed = allowed
}
if s3a.iam.isActionExplicitlyDeniedByApplicablePolicies(listReq, identity, s3_constants.ACTION_LIST, bucket, "") ||
s3a.iam.isActionExplicitlyDeniedByApplicablePolicies(readReq, identity, s3_constants.ACTION_READ, bucket, object) {
return s3err.ErrAccessDenied
}
if policyAllowed {
return s3err.ErrNone
}
if identity != nil &&
(s3a.iam.VerifyActionPermission(listReq, identity, s3_constants.ACTION_LIST, bucket, "") == s3err.ErrNone ||
s3a.iam.VerifyActionPermission(readReq, identity, s3_constants.ACTION_READ, bucket, object) == s3err.ErrNone) {
return s3err.ErrNone
}
if config, code := s3a.getBucketConfig(bucket); code == s3err.ErrNone && config.IsPublicRead {
return s3err.ErrNone
}
return s3err.ErrAccessDenied
}
// writeAnonymousAwareNotFound keeps missing-bucket and missing-key misses
// indistinguishable from denied reads for anonymous callers: a 404 only
// reaches the wire when the bucket is anonymously listable. The denial is
// refined like Auth's, so a malformed bucket name answers InvalidBucketName.
func (s3a *S3ApiServer) writeAnonymousAwareNotFound(w http.ResponseWriter, r *http.Request, bucket string, code s3err.ErrorCode) {
if isAnonymousObjectRead(r) && s3a.authorizeAnonymousBucketList(r, bucket) != s3err.ErrNone {
identity, _ := s3_constants.GetIdentityFromContext(r).(*Identity)
s3err.WriteErrorResponse(w, r, refineDenialForBucketName(r, identity, s3err.ErrAccessDenied))
return
}
s3err.WriteErrorResponse(w, r, code)
}
@@ -299,9 +299,7 @@ func TestAnonymousObjectACLConfiguredIdentity(t *testing.T) {
}{
{"legacy anonymous permission", false, []Action{s3_constants.ACTION_READ}, "", false, 200},
{"identity policy allow", false, nil, `{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}`, false, 200},
// The fixture bucket "b" is shorter than S3's three-character minimum,
// so a denial for a resolved identity answers InvalidBucketName.
{"identity deny beats object ACL", true, nil, `{"Effect":"Deny","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}`, false, 400},
{"identity deny beats object ACL", true, nil, `{"Effect":"Deny","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}`, false, 403},
{"disabled identity cannot grant", false, []Action{s3_constants.ACTION_READ}, "", true, 403},
{"public ACL does not require identity", true, nil, "", true, 200},
} {
+23 -58
View File
@@ -181,10 +181,8 @@ func (s3a *S3ApiServer) parseAndValidateRange(w http.ResponseWriter, r *http.Req
return 0, totalSize, false, nil
}
// Empty directory: 404. A file promoted to a directory keeps its data and
// stays retrievable, and a stored directory-key marker is an object, not a
// missing key — an unsatisfiable range answers 416.
if entry.IsDirectory && totalSize == 0 && !entry.IsDirectoryKeyObject() {
// Empty directory: 404. A file promoted to a directory keeps its data and stays retrievable.
if entry.IsDirectory && totalSize == 0 {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return 0, 0, false, newStreamErrorWithResponse(fmt.Errorf("directory object %s/%s cannot be retrieved", bucket, object))
}
@@ -535,24 +533,6 @@ func (s3a *S3ApiServer) serveDirectoryContent(w http.ResponseWriter, r *http.Req
if r.Method == http.MethodHead && len(entry.Chunks) > 0 {
contentLength = int64(entry.Attributes.FileSize)
}
statusCode := http.StatusOK
if r.Header.Get("Range") != "" && requestedPartNumber(r) == 0 {
// A ranged request answers like the object path does: 206 with its
// Content-Range and the range's length, served on GET, reported on HEAD.
totalSize := contentLength
offset, size, isRangeRequest, rangeErr := s3a.parseAndValidateRange(w, r, entry, totalSize, bucket, object)
if rangeErr != nil {
return
}
if isRangeRequest {
contentLength = size
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, totalSize))
statusCode = http.StatusPartialContent
if r.Method != http.MethodHead {
content = content[offset : offset+size]
}
}
}
w.Header().Set("Content-Length", strconv.FormatInt(contentLength, 10))
// Set last modified
@@ -565,12 +545,12 @@ func (s3a *S3ApiServer) serveDirectoryContent(w http.ResponseWriter, r *http.Req
// For HEAD requests, don't write body
if r.Method == http.MethodHead {
w.WriteHeader(statusCode)
w.WriteHeader(http.StatusOK)
return
}
// Write content
w.WriteHeader(statusCode)
w.WriteHeader(http.StatusOK)
if len(content) > 0 {
if _, err := w.Write(content); err != nil {
glog.Errorf("serveDirectoryContent: failed to write response: %v", err)
@@ -683,7 +663,7 @@ func (s3a *S3ApiServer) handleDirectoryObjectRequest(w http.ResponseWriter, r *h
} else if isDirectoryObject {
// Directory object but doesn't exist
glog.V(2).Infof("%s: directory object %s/%s not found", handlerName, bucket, object)
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchKey)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return true // Request was handled (with not found)
}
@@ -790,11 +770,7 @@ func (s3a *S3ApiServer) processConditionalHeaders(w http.ResponseWriter, r *http
w.Header().Set("ETag", result.ETag)
}
if result.ErrorCode == s3err.ErrNoSuchKey || result.ErrorCode == s3err.ErrNoSuchBucket {
s3a.writeAnonymousAwareNotFound(w, r, bucket, result.ErrorCode)
} else {
s3err.WriteErrorResponse(w, r, result.ErrorCode)
}
s3err.WriteErrorResponse(w, r, result.ErrorCode)
return result, true // request handled
}
return result, false // request not handled
@@ -865,7 +841,7 @@ func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request)
versioningConfigured, err = s3a.isVersioningConfigured(bucket)
if err != nil {
if err == filer_pb.ErrNotFound {
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchBucket)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
return
}
glog.Errorf("Error checking versioning status for bucket %s: %v", bucket, err)
@@ -884,7 +860,7 @@ func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request)
if err != nil {
glog.Errorf("Failed to get specific version %s: %v", versionId, err)
if errors.Is(err, filer_pb.ErrNotFound) {
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchVersion)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchVersion)
} else {
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
}
@@ -910,7 +886,7 @@ func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request)
entry, err = s3a.getLatestObjectVersion(bucket, object)
if err != nil {
glog.Errorf("GetObject: Failed to get latest version for %s/%s: %v", bucket, object, err)
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchKey)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return
}
} else if errors.Is(versionsErr, filer_pb.ErrNotFound) {
@@ -924,7 +900,7 @@ func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request)
} else {
// No object at regular path either - object doesn't exist
glog.V(3).Infof("GetObject: object not found at regular path or .versions for %s/%s", bucket, object)
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchKey)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return
}
} else {
@@ -933,7 +909,7 @@ func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request)
entry, err = s3a.getLatestObjectVersion(bucket, object)
if err != nil {
glog.Errorf("GetObject: Failed to get latest version for %s/%s: %v", bucket, object, err)
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchKey)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return
}
}
@@ -954,7 +930,7 @@ func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request)
// Check if this is a delete marker
if entry.Extended != nil {
if deleteMarker, exists := entry.Extended[s3_constants.ExtDeleteMarkerKey]; exists && string(deleteMarker) == "true" {
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchKey)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return
}
}
@@ -999,7 +975,7 @@ func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request)
return
}
if objectEntryForSSE == nil {
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchKey)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return
}
}
@@ -1016,7 +992,7 @@ func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request)
// A bare directory path is not an object: 404 like AWS instead of an empty 200,
// so Hadoop-style clients fall back to LIST discovery. HEAD and ranged GET already 404.
if !strings.HasSuffix(object, "/") && isBareDirectory(objectEntryForSSE) {
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchKey)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return
}
@@ -2542,7 +2518,7 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
versioningConfigured, err = s3a.isVersioningConfigured(bucket)
if err != nil {
if err == filer_pb.ErrNotFound {
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchBucket)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
return
}
glog.Errorf("Error checking versioning status for bucket %s: %v", bucket, err)
@@ -2560,7 +2536,7 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
if err != nil {
glog.Errorf("Failed to get specific version %s for %s/%s: %v", versionId, bucket, object, err)
if errors.Is(err, filer_pb.ErrNotFound) {
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchVersion)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchVersion)
} else {
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
}
@@ -2586,7 +2562,7 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
entry, err = s3a.getLatestObjectVersion(bucket, object)
if err != nil {
glog.Errorf("HeadObject: Failed to get latest version for %s/%s: %v", bucket, object, err)
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchKey)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return
}
} else if errors.Is(versionsErr, filer_pb.ErrNotFound) {
@@ -2600,7 +2576,7 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
} else {
// No object at regular path either - object doesn't exist
glog.V(3).Infof("HeadObject: object not found at regular path or .versions for %s/%s", bucket, object)
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchKey)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return
}
} else {
@@ -2609,7 +2585,7 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
entry, err = s3a.getLatestObjectVersion(bucket, object)
if err != nil {
glog.Errorf("HeadObject: Failed to get latest version for %s/%s: %v", bucket, object, err)
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchKey)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return
}
}
@@ -2630,7 +2606,7 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
// Check if this is a delete marker
if entry.Extended != nil {
if deleteMarker, exists := entry.Extended[s3_constants.ExtDeleteMarkerKey]; exists && string(deleteMarker) == "true" {
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchKey)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return
}
}
@@ -2668,7 +2644,7 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
return
}
if objectEntryForSSE == nil {
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchKey)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return
}
}
@@ -2734,7 +2710,7 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
isZeroByteFile := objectEntryForSSE.Attributes.FileSize == 0 && !objectEntryForSSE.IsDirectory
// A directory with data (a promoted file) is retrievable; empty directories 404 for LIST fallback.
if isBareDirectory(objectEntryForSSE) {
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchKey)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return
}
if isZeroByteFile {
@@ -2742,7 +2718,7 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
if s3a.hasChildren(r.Context(), bucket, object) {
// This is an implicit directory with children
// Return 404 to force clients (like s3fs) to use LIST-based discovery
s3a.writeAnonymousAwareNotFound(w, r, bucket, s3err.ErrNoSuchKey)
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return
}
}
@@ -2765,17 +2741,6 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", startOffset, endOffset, totalSize))
statusCode = http.StatusPartialContent
glog.V(3).Infof("HeadObject: part %d is bytes %d-%d/%d", partNumber, startOffset, endOffset, totalSize)
} else if r.Header.Get("Range") != "" {
// A ranged HEAD reports the range like GET does: 206 with its Content-Range and length
offset, size, isRangeRequest, rangeErr := s3a.parseAndValidateRange(w, r, objectEntryForSSE, totalSize, bucket, object)
if rangeErr != nil {
return
}
if isRangeRequest {
responseSize = size
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, totalSize))
statusCode = http.StatusPartialContent
}
}
s3a.setResponseHeaders(w, r, objectEntryForSSE, responseSize)
@@ -1,58 +0,0 @@
package s3api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// A conditional write with If-Match has nothing to compare against when the key has
// no current version; AWS answers 404 there, not 412.
func TestValidateConditionalHeadersMissingObject(t *testing.T) {
s3a := &S3ApiServer{}
existing := &filer_pb.Entry{
Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix()},
Extended: map[string][]byte{s3_constants.ExtETagKey: []byte("d41d8cd98f00b204e9800998ecf8427e")},
}
deleteMarker := &filer_pb.Entry{
Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix()},
Extended: map[string][]byte{s3_constants.ExtDeleteMarkerKey: []byte("true")},
}
testCases := []struct {
name string
header string
value string
entry *filer_pb.Entry
want s3err.ErrorCode
}{
{"if-match on missing object", s3_constants.IfMatch, "d41d8cd98f00b204e9800998ecf8427e", nil, s3err.ErrNoSuchKey},
{"if-match star on missing object", s3_constants.IfMatch, "*", nil, s3err.ErrNoSuchKey},
{"if-match on delete marker", s3_constants.IfMatch, "d41d8cd98f00b204e9800998ecf8427e", deleteMarker, s3err.ErrNoSuchKey},
{"if-match mismatch on existing object", s3_constants.IfMatch, "0000", existing, s3err.ErrPreconditionFailed},
{"if-match hit on existing object", s3_constants.IfMatch, "d41d8cd98f00b204e9800998ecf8427e", existing, s3err.ErrNone},
{"if-none-match star on missing object", s3_constants.IfNoneMatch, "*", nil, s3err.ErrNone},
{"if-none-match star on delete marker", s3_constants.IfNoneMatch, "*", deleteMarker, s3err.ErrNone},
{"if-none-match star on existing object", s3_constants.IfNoneMatch, "*", existing, s3err.ErrPreconditionFailed},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPut, "/bucket/object", nil)
r.Header.Set(tc.header, tc.value)
headers, errCode := parseConditionalHeaders(r)
if errCode != s3err.ErrNone {
t.Fatalf("parseConditionalHeaders: %v", errCode)
}
if got := s3a.validateConditionalHeaders(r, headers, tc.entry, "bucket", "object"); got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
+8 -142
View File
@@ -2,21 +2,17 @@ package s3api
import (
"bytes"
"cmp"
"context"
"crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"hash"
"io"
"net/http"
"net/url"
"os"
"reflect"
"slices"
"strconv"
"strings"
"time"
@@ -1150,9 +1146,7 @@ func (s3a *S3ApiServer) CopyObjectPartHandler(w http.ResponseWriter, r *http.Req
return
}
// Inline-Content sources have no chunks for the raw-byte path to copy;
// the plaintext stream serves their range instead.
if uploadEntryHasSSE(uploadEntry) || sourceEntryIsEncrypted(entry) || uploadEntryHasChecksum(uploadEntry) || len(entry.Content) > 0 {
if uploadEntryHasSSE(uploadEntry) || sourceEntryIsEncrypted(entry) || uploadEntryHasChecksum(uploadEntry) {
etag, sseMetadata, errCode := s3a.copyObjectPartViaReencryption(r, entry, startOffset, endOffset, dstBucket, dstObject, uploadID, partID, uploadEntry)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
@@ -1210,24 +1204,15 @@ func (s3a *S3ApiServer) CopyObjectPartHandler(w http.ResponseWriter, r *http.Req
// For zero-size files or invalid ranges, create an empty part with size 0
dstEntry.Attributes.FileSize = 0
dstEntry.Chunks = nil
dstEntry.Attributes.Md5 = md5.New().Sum(nil)
} else {
// Copy chunks that overlap with the range
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, startOffset, endOffset, dstAssignPath)
dstChunks, err := s3a.copyChunksForRange(entry, startOffset, endOffset, dstAssignPath)
if err != nil {
glog.Errorf("CopyObjectPartHandler copy chunks error: %v", err)
s3err.WriteErrorResponse(w, r, s3a.mapCopyErrorToS3Error(err))
return
}
dstEntry.Chunks = dstChunks
dstEntry.Attributes.Md5 = partMd5
}
// Mirror putToFiler: the part entry carries the MD5 of its bytes so
// CopyPartResult, ListParts and complete's composite ETag all describe
// the copied range rather than the source chunks.
if len(dstEntry.Attributes.Md5) > 0 {
dstEntry.Extended[s3_constants.ExtETagKey] = []byte(fmt.Sprintf("%x", dstEntry.Attributes.Md5))
}
// Save the part entry to the multipart uploads folder
@@ -1494,7 +1479,7 @@ func (s3a *S3ApiServer) copySingleChunk(chunk *filer_pb.FileChunk, dstPath strin
// the source volume for compressed bytes, so a gzipped chunk is
// forwarded to the destination without anyone having to decompress.
if canStreamCopyChunk(chunk) {
if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, 0, int64(chunk.Size), true /*isFullChunk*/, assignResult, nil); err != nil {
if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, 0, int64(chunk.Size), true /*isFullChunk*/, assignResult); err != nil {
return nil, fmt.Errorf("stream chunk: %w", err)
}
return dstChunk, nil
@@ -1549,22 +1534,9 @@ func (s3a *S3ApiServer) copySingleChunkForRange(originalChunk, rangeChunk *filer
// part-size = chunk-size assemble pattern hits this branch.
if canStreamCopyChunk(originalChunk) {
isFullChunk := offsetInChunk == 0 && rangeChunk.Size == originalChunk.Size
// A partial range stores different bytes than the source chunk, so
// its ETag has to be recomputed; a whole-chunk copy reuses it.
var digest hash.Hash
if !isFullChunk {
digest = md5.New()
}
if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, offsetInChunk, int64(rangeChunk.Size), isFullChunk, assignResult, digest); err != nil {
if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, offsetInChunk, int64(rangeChunk.Size), isFullChunk, assignResult); err != nil {
return nil, fmt.Errorf("stream chunk range: %w", err)
}
if digest != nil {
dstChunk.ETag = base64.StdEncoding.EncodeToString(digest.Sum(nil))
// The range fetch returns raw bytes and the destination stores
// them uncompressed, so the source chunk's gzip flag must not
// carry over onto a partial copy.
dstChunk.IsCompressed = false
}
return dstChunk, nil
}
@@ -1637,16 +1609,8 @@ func parseRangeHeader(rangeHeader string, fileSize int64) (startOffset, endOffse
return startOffset, endOffset, nil
}
// partMd5Result carries the whole-part MD5 the copiedRangeMd5 goroutine
// computes beside the per-chunk copies.
type partMd5Result struct {
sum []byte
err error
}
// copyChunksForRange copies chunks that overlap with the specified range and
// returns the MD5 of the copied bytes for the part entry's Attributes.Md5.
func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, endOffset int64, dstPath string) ([]*filer_pb.FileChunk, []byte, error) {
// copyChunksForRange copies chunks that overlap with the specified range
func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, endOffset int64, dstPath string) ([]*filer_pb.FileChunk, error) {
var relevantChunks []*filer_pb.FileChunk
var originalChunks []*filer_pb.FileChunk
@@ -1677,36 +1641,6 @@ func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, e
}
}
// The part's Attributes.Md5 must cover the copied range in byte order,
// and md5 cannot combine per-chunk digests. A single chunk's own digest
// is the answer only when it supplies the whole range; anything else —
// several chunks, or gaps a sparse source zero-fills — needs an ordered
// pass the concurrent copies cannot feed, so a second stream of the
// source range hashes beside them.
partSize := endOffset - startOffset + 1
singleChunkCovers := len(relevantChunks) == 1 &&
relevantChunks[0].Offset == 0 && int64(relevantChunks[0].Size) == partSize
md5Ctx, cancelMd5 := context.WithCancel(context.Background())
defer cancelMd5()
var partMd5 []byte
var partMd5Ch chan partMd5Result
switch {
case singleChunkCovers:
// resolved below from the copied chunk's own digest
case len(entry.GetChunks()) == 0:
// no chunks to read at all: the part is partSize zero bytes (the
// stream helper returns empty for a chunkless entry, not zero-fill)
partMd5 = md5Zeros(partSize)
default:
partMd5Ch = make(chan partMd5Result, 1)
go func() {
sum, err := s3a.copiedRangeMd5(md5Ctx, entry, startOffset, endOffset)
partMd5Ch <- partMd5Result{sum, err}
}()
}
// Copy the relevant chunks using a specialized method for range copies
dstChunks := make([]*filer_pb.FileChunk, len(relevantChunks))
const defaultChunkCopyConcurrency = 4
@@ -1730,79 +1664,11 @@ func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, e
// Wait for all operations to complete and check for errors
for i := 0; i < len(relevantChunks); i++ {
if err := <-errChan; err != nil {
return nil, nil, err
return nil, err
}
}
// Reads resolve overlapping chunks by ModifiedTsNs, and the concurrent
// copies stamped arbitrary finish times: rank the destination chunks by
// the source's timestamps so the part resolves overlaps the way the
// source did (and the way copiedRangeMd5 hashed it).
if len(dstChunks) > 1 {
order := make([]int, len(dstChunks))
for i := range order {
order[i] = i
}
slices.SortStableFunc(order, func(a, b int) int {
return cmp.Compare(originalChunks[a].ModifiedTsNs, originalChunks[b].ModifiedTsNs)
})
now := time.Now().UnixNano()
for rank, i := range order {
dstChunks[i].ModifiedTsNs = now + int64(rank)
}
}
if partMd5Ch != nil {
if res := <-partMd5Ch; res.err != nil {
return nil, nil, fmt.Errorf("md5 of copied range: %w", res.err)
} else {
partMd5 = res.sum
}
} else if singleChunkCovers {
if partMd5 = util.Base64Md5ToBytes(dstChunks[0].ETag); len(partMd5) != md5.Size {
// TUS and mount writes leave chunks without an ETag; hash the
// copied range when the inherited digest is missing or malformed.
sum, err := s3a.copiedRangeMd5(md5Ctx, entry, startOffset, endOffset)
if err != nil {
return nil, nil, fmt.Errorf("md5 of copied range: %w", err)
}
partMd5 = sum
}
}
return dstChunks, partMd5, nil
}
// md5Zeros hashes size zero bytes without a volume read — a copied range
// that overlaps no source chunk assembles to all zeros.
func md5Zeros(size int64) []byte {
h := md5.New()
var zeros [1 << 16]byte
for size > 0 {
n := min(size, int64(len(zeros)))
h.Write(zeros[:n])
size -= n
}
return h.Sum(nil)
}
// copiedRangeMd5 streams the source range once and hashes its plaintext —
// exactly the bytes the non-SSE part copy assembles on the destination.
func (s3a *S3ApiServer) copiedRangeMd5(ctx context.Context, entry *filer_pb.Entry, startOffset, endOffset int64) ([]byte, error) {
size := endOffset - startOffset + 1
// The copy walks the chunks, so the hash must too: on an entry that also
// carries inline Content the stream helper would prefer Content instead.
chunked := &filer_pb.Entry{Chunks: entry.GetChunks()}
reader, err := s3a.getEncryptedStreamFromVolumes(ctx, chunked, startOffset, size)
if err != nil {
return nil, err
}
defer reader.Close()
h := md5.New()
if _, err := io.Copy(h, reader); err != nil {
return nil, err
}
return h.Sum(nil), nil
return dstChunks, nil
}
// Helper methods for copy operations to avoid code duplication
@@ -160,7 +160,7 @@ func BenchmarkCopyChunk_Streamed(b *testing.B) {
for i := 0; i < b.N; i++ {
if err := s3a.streamCopyChunkRange(context.Background(),
env.srcSrv.URL, env.assign.FileId, 0, int64(size),
true /*isFullChunk*/, env.assign, nil); err != nil {
true /*isFullChunk*/, env.assign); err != nil {
b.Fatalf("stream: %v", err)
}
}
@@ -1,21 +1,14 @@
package s3api
import (
"bytes"
"crypto/md5"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
func TestCopyEntryETagPrefersStoredExtendedETag(t *testing.T) {
@@ -152,349 +145,3 @@ func TestParseRangeHeaderRejectsUnsatisfiableRange(t *testing.T) {
})
}
}
// A part copied with x-amz-copy-source-range must report the MD5 of the copied
// bytes: per destination chunk for a partial-chunk copy, and the whole-part MD5
// the part entry's Attributes.Md5 carries for CopyPartResult / ListParts /
// complete's composite ETag.
func TestCopyChunksForRangeCopiedETags(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
const chunkSize = 4 << 20
payload := make([]byte, 2*chunkSize)
for i := range payload {
payload[i] = byte(i*31 + 7)
}
b64md5 := func(data []byte) string {
sum := md5.Sum(data)
return base64.StdEncoding.EncodeToString(sum[:])
}
fid0 := fmt.Sprintf("3,%016x%08x", 101, 202)
fid1 := fmt.Sprintf("3,%016x%08x", 103, 204)
volume.stored[fid0] = payload[:chunkSize]
volume.stored[fid1] = payload[chunkSize:]
entry := &filer_pb.Entry{
Name: "src",
Attributes: &filer_pb.FuseAttributes{
FileSize: uint64(len(payload)),
},
Chunks: []*filer_pb.FileChunk{
{FileId: fid0, Offset: 0, Size: chunkSize, ETag: b64md5(payload[:chunkSize])},
{FileId: fid1, Offset: chunkSize, Size: chunkSize, ETag: b64md5(payload[chunkSize:])},
},
}
// A range spanning the first chunk fully plus 1 MiB of the second.
multiEnd := int64(chunkSize + 1<<20 - 1)
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, 0, multiEnd, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange: %v", err)
}
if len(dstChunks) != 2 {
t.Fatalf("copyChunksForRange returned %d chunks, want 2", len(dstChunks))
}
// A whole-chunk copy forwards the stored bytes, so the source ETag stays valid.
if got, want := dstChunks[0].ETag, entry.Chunks[0].ETag; got != want {
t.Errorf("whole-chunk copy ETag = %q, want source chunk ETag %q", got, want)
}
// A partial copy stores only the range bytes; its ETag must be their MD5.
if got, want := dstChunks[1].ETag, b64md5(payload[chunkSize:multiEnd+1]); got != want {
t.Errorf("partial-chunk copy ETag = %q, want copied-bytes MD5 %q", got, want)
}
// The copied bytes landed on the destination volume (inside the multipart framing).
if stored := volume.stored[dstChunks[1].GetFileIdString()]; !bytes.Contains(stored, payload[chunkSize:multiEnd+1]) {
t.Error("destination volume does not hold the copied range bytes")
}
// The whole-part MD5 hashes every copied byte once, in range order.
wantPartMd5 := md5.Sum(payload[:multiEnd+1])
if !bytes.Equal(partMd5, wantPartMd5[:]) {
t.Errorf("part Md5 = %x, want %x", partMd5, wantPartMd5)
}
partEntry := &filer_pb.Entry{
Attributes: &filer_pb.FuseAttributes{Md5: partMd5},
Chunks: dstChunks,
}
if got, want := copyEntryETag(partEntry), fmt.Sprintf("%x", wantPartMd5); got != want {
t.Errorf("copyEntryETag = %q, want %q", got, want)
}
// A range inside one chunk: the part's ETag is the MD5 of those bytes.
innerChunks, innerMd5, err := s3a.copyChunksForRange(entry, 100, 199, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange inner range: %v", err)
}
if len(innerChunks) != 1 {
t.Fatalf("inner range returned %d chunks, want 1", len(innerChunks))
}
if got, want := innerChunks[0].ETag, b64md5(payload[100:200]); got != want {
t.Errorf("inner-range chunk ETag = %q, want copied-bytes MD5 %q", got, want)
}
wantInnerMd5 := md5.Sum(payload[100:200])
if !bytes.Equal(innerMd5, wantInnerMd5[:]) {
t.Errorf("inner-range part Md5 = %x, want %x", innerMd5, wantInnerMd5)
}
}
// A range over a sparse source zero-fills the gaps, so the part MD5 has to
// hash the logical range — not just the one chunk that overlaps it.
func TestCopyChunksForRangeSparseETags(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
fid := fmt.Sprintf("3,%016x%08x", 105, 206)
volume.stored[fid] = []byte("abc")
sum := md5.Sum([]byte("abc"))
entry := &filer_pb.Entry{
Name: "sparse",
Attributes: &filer_pb.FuseAttributes{
FileSize: 6,
},
Chunks: []*filer_pb.FileChunk{
{FileId: fid, Offset: 3, Size: 3, ETag: base64.StdEncoding.EncodeToString(sum[:])},
},
}
// bytes 0-5 read as "\x00\x00\x00abc"; the part MD5 must cover the zeros too.
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, 0, 5, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange sparse: %v", err)
}
if len(dstChunks) != 1 || dstChunks[0].Offset != 3 {
t.Fatalf("sparse copy chunks = %+v, want one chunk at offset 3", dstChunks)
}
wantMd5 := md5.Sum([]byte("\x00\x00\x00abc"))
if !bytes.Equal(partMd5, wantMd5[:]) {
t.Errorf("sparse part Md5 = %x, want %x (zero-filled range)", partMd5, wantMd5)
}
// A range over a pure hole copies nothing but still reads as zeros.
holeChunks, holeMd5, err := s3a.copyChunksForRange(entry, 0, 2, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange hole: %v", err)
}
if len(holeChunks) != 0 {
t.Fatalf("hole copy returned %d chunks, want 0", len(holeChunks))
}
wantHoleMd5 := md5.Sum([]byte{0, 0, 0})
if !bytes.Equal(holeMd5, wantHoleMd5[:]) {
t.Errorf("hole part Md5 = %x, want %x (all zeros)", holeMd5, wantHoleMd5)
}
}
// Destination chunks must resolve overlaps the way the source did: reads
// pick winners by ModifiedTsNs, so the copied chunks keep the source's
// precedence even though they were uploaded concurrently.
func TestCopyChunksForRangePreservesSourcePrecedence(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
etagOf := func(b []byte) string {
sum := md5.Sum(b)
return base64.StdEncoding.EncodeToString(sum[:])
}
fidA := fmt.Sprintf("3,%016x%08x", 107, 208)
fidB := fmt.Sprintf("3,%016x%08x", 109, 210)
volume.stored[fidA] = []byte("aaaa")
volume.stored[fidB] = []byte("bbbb")
// A covers [0,4), B covers [2,6); B's range is copied into a chunk that
// overlaps A's copy on [2,4), and the source says A wins there.
entry := &filer_pb.Entry{
Name: "overlap",
Attributes: &filer_pb.FuseAttributes{FileSize: 6},
Chunks: []*filer_pb.FileChunk{
{FileId: fidA, Offset: 0, Size: 4, ModifiedTsNs: 20, ETag: etagOf([]byte("aaaa"))},
{FileId: fidB, Offset: 2, Size: 4, ModifiedTsNs: 10, ETag: etagOf([]byte("bbbb"))},
},
}
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, 0, 5, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange overlap: %v", err)
}
if len(dstChunks) != 2 {
t.Fatalf("overlap copy returned %d chunks, want 2", len(dstChunks))
}
if dstChunks[0].ModifiedTsNs <= dstChunks[1].ModifiedTsNs {
t.Errorf("destination precedence inverted: chunk from ts=20 got %d, chunk from ts=10 got %d",
dstChunks[0].ModifiedTsNs, dstChunks[1].ModifiedTsNs)
}
// The source resolves to "aaaa"+"bb"; the part MD5 must match that view.
wantMd5 := md5.Sum([]byte("aaaabb"))
if !bytes.Equal(partMd5, wantMd5[:]) {
t.Errorf("overlap part Md5 = %x, want %x", partMd5, wantMd5)
}
}
// An inline-Content source has no chunks for the raw-byte copy; the plaintext
// stream path must write the range's real bytes and tag the part with their
// MD5, not a zero-fill digest.
func TestCopyObjectPartInlineContent(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, apply: true, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
content := []byte("inline-content")
entry := &filer_pb.Entry{
Name: "src",
Attributes: &filer_pb.FuseAttributes{FileSize: uint64(len(content))},
Content: content,
}
r := httptest.NewRequest(http.MethodPut, "/b/dst?partNumber=1&uploadId=u1", nil)
etag, _, code := s3a.copyObjectPartViaReencryption(r, entry, 2, 5, "b", "dst", "u1", 1, &filer_pb.Entry{})
if code != s3err.ErrNone {
t.Fatalf("copyObjectPartViaReencryption: %v", code)
}
wantMd5 := md5.Sum(content[2:6])
if got := strings.Trim(etag, `"`); got != fmt.Sprintf("%x", wantMd5) {
t.Errorf("etag = %q, want %x", got, wantMd5)
}
var partEntry *filer_pb.Entry
for key, e := range filerImpl.entries {
if strings.Contains(key, "u1") {
partEntry = e
}
}
if partEntry == nil || len(partEntry.Chunks) == 0 {
t.Fatalf("no part entry with chunks stored: %+v", filerImpl.entries)
}
found := false
for _, stored := range volume.stored {
if bytes.Contains(stored, content[2:6]) {
found = true
}
}
if !found {
t.Errorf("destination volume does not hold the copied content bytes")
}
}
// The handler must route an inline-Content source away from the raw chunk
// copy — CopyObjectPartHandler has to send it through the plaintext stream
// path or the part lands as zeros.
func TestCopyObjectPartHandlerInlineContent(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, apply: true, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
util_http.InitGlobalHttpClient()
content := []byte("inline-content")
uploadID := s3a.generateUploadID("dst")
filerImpl.entries["/buckets/b"] = &filer_pb.Entry{Name: "b", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}}
filerImpl.entries["/buckets/b/src"] = &filer_pb.Entry{
Name: "src",
Attributes: &filer_pb.FuseAttributes{FileSize: uint64(len(content))},
Content: content,
}
filerImpl.entries["/buckets/b/.uploads/"+uploadID] = &filer_pb.Entry{
Name: uploadID,
IsDirectory: true,
Extended: map[string][]byte{s3_constants.ExtMultipartObjectKey: []byte("dst")},
}
r := httptest.NewRequest(http.MethodPut, "/b/dst?partNumber=1&uploadId="+uploadID, nil)
r = mux.SetURLVars(r, map[string]string{"bucket": "b", "object": "dst"})
r.Header.Set("X-Amz-Copy-Source", "/b/src")
r.Header.Set("x-amz-copy-source-range", "bytes=2-5")
w := httptest.NewRecorder()
s3a.CopyObjectPartHandler(w, r)
if w.Code != http.StatusOK {
t.Fatalf("CopyObjectPartHandler = %d %s, want 200", w.Code, w.Body.String())
}
wantMd5 := fmt.Sprintf("%x", md5.Sum(content[2:6]))
if !strings.Contains(w.Body.String(), wantMd5) {
t.Errorf("response %q does not contain copied-range ETag %q", w.Body.String(), wantMd5)
}
var partEntry *filer_pb.Entry
for key, e := range filerImpl.entries {
if strings.Contains(key, uploadID) && !e.IsDirectory {
partEntry = e
}
}
if partEntry == nil || len(partEntry.Chunks) == 0 {
t.Fatalf("no part entry with chunks stored: %+v", filerImpl.entries)
}
found := false
for _, stored := range volume.stored {
if bytes.Contains(stored, content[2:6]) {
found = true
}
}
if !found {
t.Errorf("destination volume does not hold the copied content bytes")
}
}
// An empty part must carry the empty-body MD5 on the stored entry too, so
// ListParts and complete's composite ETag see the same digest CopyPartResult
// reported.
func TestWriteEmptyCopyPartStoresMd5(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, apply: true, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
etag, code := s3a.writeEmptyCopyPart("b", "u1", 3)
if code != s3err.ErrNone {
t.Fatalf("writeEmptyCopyPart: %v", code)
}
const emptyMD5Hex = "d41d8cd98f00b204e9800998ecf8427e"
if etag != emptyMD5Hex {
t.Fatalf("writeEmptyCopyPart etag = %q, want %q", etag, emptyMD5Hex)
}
var partEntry *filer_pb.Entry
for key, e := range filerImpl.entries {
if strings.Contains(key, "u1") {
partEntry = e
}
}
if partEntry == nil {
t.Fatalf("no part entry stored: %+v", filerImpl.entries)
}
if got := string(partEntry.Extended[s3_constants.ExtETagKey]); got != emptyMD5Hex {
t.Errorf("stored ExtETagKey = %q, want %q", got, emptyMD5Hex)
}
if got := fmt.Sprintf("%x", partEntry.Attributes.Md5); got != emptyMD5Hex {
t.Errorf("stored Attributes.Md5 = %q, want %q", got, emptyMD5Hex)
}
}
// A whole-range chunk copy normally reuses the source chunk's ETag as the
// part MD5; TUS and mount writes leave chunks without one, so the part has
// to hash the copied bytes instead of saving an empty digest.
func TestCopyChunksForRangeMissingChunkETag(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
fid := fmt.Sprintf("3,%016x%08x", 111, 212)
volume.stored[fid] = []byte("etagless")
entry := &filer_pb.Entry{
Name: "src",
Attributes: &filer_pb.FuseAttributes{FileSize: 8},
Chunks: []*filer_pb.FileChunk{
{FileId: fid, Offset: 0, Size: 8},
},
}
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, 0, 7, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange: %v", err)
}
if len(dstChunks) != 1 {
t.Fatalf("copyChunksForRange returned %d chunks, want 1", len(dstChunks))
}
wantMd5 := md5.Sum([]byte("etagless"))
if !bytes.Equal(partMd5, wantMd5[:]) {
t.Errorf("part Md5 = %x, want %x (hashed copied range)", partMd5, wantMd5)
}
}
@@ -2,7 +2,6 @@ package s3api
import (
"context"
"crypto/md5"
"encoding/base64"
"encoding/json"
"errors"
@@ -420,19 +419,14 @@ func (s3a *S3ApiServer) writeEmptyCopyPart(dstBucket, uploadID string, partID in
return "", s3err.ErrInternalError
}
}
const emptyMD5Hex = "d41d8cd98f00b204e9800998ecf8427e"
if err := s3a.mkFile(uploadDir, partName, nil, func(e *filer_pb.Entry) {
if e.Attributes == nil {
e.Attributes = &filer_pb.FuseAttributes{}
}
e.Attributes.FileSize = 0
e.Attributes.Md5 = md5.New().Sum(nil)
if e.Extended == nil {
e.Extended = map[string][]byte{}
}
e.Extended[s3_constants.ExtETagKey] = []byte(emptyMD5Hex)
}); err != nil {
return "", s3err.ErrInternalError
}
const emptyMD5Hex = "d41d8cd98f00b204e9800998ecf8427e"
return emptyMD5Hex, s3err.ErrNone
}
@@ -73,19 +73,12 @@ func canStreamCopyChunk(chunk *filer_pb.FileChunk) bool {
// Trusting a hint that disagrees with the wire bytes (e.g. caller says
// "compressed" but the volume returned raw bytes for a partial range)
// would label raw bytes as gzip and corrupt reads on the destination.
//
// digestWriter, when non-nil, receives the plaintext bytes as they are
// forwarded so the caller can derive the destination chunk's ETag. It is
// not fed when the wire bytes are gzip — only possible on the full-chunk
// pass-through, whose logical content still matches the source chunk's
// own ETag and needs no recomputation.
func (s3a *S3ApiServer) streamCopyChunkRange(
ctx context.Context,
srcUrl, srcFileId string,
offset, size int64,
isFullChunk bool,
assignResult *filer_pb.AssignVolumeResponse,
digestWriter io.Writer,
) error {
// Range header takes int64, but mirror the int32 bound from
// downloadChunkData so the two paths reject the same inputs.
@@ -173,11 +166,7 @@ func (s3a *S3ApiServer) streamCopyChunkRange(
return
}
src := io.Reader(srcResp.Body)
if digestWriter != nil && !bodyIsGzipped {
src = io.TeeReader(srcResp.Body, digestWriter)
}
if _, err := io.Copy(fw, src); err != nil {
if _, err := io.Copy(fw, srcResp.Body); err != nil {
if shouldLogStreamError(err) {
glog.V(2).Infof("stream copy %s offset=%d size=%d: %v",
srcUrl, offset, size, err)
+143 -206
View File
@@ -19,7 +19,6 @@ import (
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
type OptionalString struct {
@@ -540,6 +539,18 @@ func (s3a *S3ApiServer) listFilerEntries(ctx context.Context, req listObjectsReq
}
dirName, entryName, _ := entryUrlEncode(dir, entry.Name, encodingTypeUrl)
if entry.IsDirectory {
if cursor.emitDirAsCommonPrefix {
cursor.emitDirAsCommonPrefix = false
dirPrefix := fmt.Sprintf("%s/%s/", dir, entry.Name)[len(bucketPrefix):]
commonPrefixes = append(commonPrefixes, PrefixEntry{
Prefix: dirPrefix,
})
cursor.maxKeys--
lastEntryWasCommonPrefix = true
lastCommonPrefix = dirPrefix
lastPrefixNullBackers, lastPrefixConfirmed = nil, true
return
}
if originalPrefix != "" {
normalizedPrefix := strings.TrimPrefix(strings.TrimSuffix(originalPrefix, "/"), "/")
if normalizedPrefix != "" {
@@ -554,8 +565,6 @@ func (s3a *S3ApiServer) listFilerEntries(ctx context.Context, req listObjectsReq
// plain key. The traversal never descends into it under a delimiter,
// so the CommonPrefix its nested keys fold into is added here too.
if entry.IsPrefixObject() {
keyOnly := cursor.prefixObjectKeyOnly
cursor.prefixObjectKeyOnly = false
key := fmt.Sprintf("%s/%s", dir, entry.Name)[len(bucketPrefix):]
if strings.HasPrefix(key, originalPrefix) {
if folded := prefixForKey(dir, entry.Name); folded != "" {
@@ -570,10 +579,8 @@ func (s3a *S3ApiServer) listFilerEntries(ctx context.Context, req listObjectsReq
// whole, so a page ending between them cannot resume at the prefix:
// a marker of "<key>/" means the subtree is done, not that it is
// next. The page runs one item over maxKeys instead of dropping it.
if !keyOnly {
if childPrefix := prefixForKey(dir, entry.Name+"/"); childPrefix != "" && s3a.hasChildren(ctx, bucket, key) {
addCommonPrefix(childPrefix)
}
if childPrefix := prefixForKey(dir, entry.Name+"/"); childPrefix != "" && s3a.hasChildren(ctx, bucket, key) {
addCommonPrefix(childPrefix)
}
return
}
@@ -794,10 +801,9 @@ type ListingCursor struct {
// resolvePendingNulls settles trailing null objects whose .versions sibling
// has not streamed yet before a page is declared full.
resolvePendingNulls func() error
// prefixObjectKeyOnly limits the next prefix-object entry passed to the entry
// callback to its plain key; its "<name>/" CommonPrefix is deferred to the
// directory's own block position.
prefixObjectKeyOnly bool
// emitDirAsCommonPrefix marks the next directory entry passed to the entry
// callback for reporting as a CommonPrefix rather than a Contents key.
emitDirAsCommonPrefix bool
}
// excludedMarkerKey returns the key an exclusive marker names that the walk's cutoff no
@@ -944,20 +950,35 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
return
}
// A directory's keys sort at "<name>/" among this level's children, not at
// the name itself: siblings like "a-c" and "a.txt" sort before "a/b" while
// "a0" sorts after. Start the window at the first child whose block can
// still hold keys past the marker — the shortest prefix of the marker
// followed by a byte no greater than "/" — and ask for it inclusively, so
// a marker-named directory's subtree is reached at all.
startName := marker
for i := 0; i < len(marker); i++ {
if marker[i] <= '/' {
startName = marker[:i]
break
// markerParentWalked tracks whether marker now names a directory whose
// subtree this call already walked: after the descent below it is the
// parent directory, and seeing its entry again must not re-list it.
markerParentWalked := false
if strings.Contains(marker, "/") {
subDir, subMarker := toParentAndDescendants(marker)
// println("doListFilerEntries dir", dir+"/"+subDir, "subMarker", subMarker)
subNextMarker, subErr := s3a.doListFilerEntries(ctx, client, listDirectoryRequest{dir: dir + "/" + subDir, marker: subMarker, delimiter: delimiter, bucket: bucket}, cursor, eachEntryFn)
if subErr != nil {
err = subErr
return
}
nextMarker = subDir + "/" + subNextMarker
// finished processing this subdirectory
marker = subDir
markerParentWalked = true
}
if cursor.isTruncated {
return
}
// Ask for the marker's own entry: an exclusive window never delivers the
// directory a marker names, and its children sort after the marker key.
// Delimited listings keep exclusive windows — a marker equal to a
// directory there is the resume position of an already-emitted common
// prefix, not a subtree to walk.
if marker != "" && delimiter == "" {
inclusiveStartFrom = true
}
inclusiveStartFrom = startName != ""
// now marker is also a direct child of dir
ctx, cancel := context.WithCancel(ctx)
@@ -967,128 +988,6 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
// each request window inside the page.
pageMarker := marker
// Defer each directory until a child past its "<name>/" block arrives, so
// emission follows S3 byte order rather than the filer's name order.
type pendingDir struct {
e *filer_pb.Entry
matchedPrefixDir bool
}
var pendingDirs []pendingDir
stopped := false
// hasEntries reports whether the filer directory holds at least one entry,
// probed on the request's own client; errors answer conservatively as true.
hasEntries := func(path string) bool {
probeCtx, cancel := context.WithCancel(ctx)
defer cancel()
stream, lerr := client.ListEntries(probeCtx, &filer_pb.ListEntriesRequest{Directory: path, Limit: 1})
if lerr != nil {
return true
}
_, recvErr := stream.Recv()
return recvErr == nil
}
emitDir := func(e *filer_pb.Entry, matchedPrefixDir bool) error {
blockKey := e.Name + "/"
var subMarker string
if strings.HasPrefix(marker, blockKey) {
if delimiter == "/" {
// this block collapses to one common prefix, already emitted
return nil
}
subMarker = marker[len(blockKey):]
} else if blockKey <= marker {
return nil
}
// A prefix object's plain key is emitted at the name position or not at
// all; the block emits only its "<name>/" fold, so the callback sees a
// plain directory.
emitEntry := e
if e.IsPrefixObject() {
emitEntry = proto.Clone(e).(*filer_pb.Entry)
delete(emitEntry.Extended, s3_constants.SeaweedFSPrefixObject)
}
isKeyObject := e.IsDirectoryKeyObject() && !e.IsPrefixObject()
if delimiter != "/" || cursor.prefixEndsOnDelimiter {
// A trailing-slash prefix (e.g. "logs/") names one directory, so the
// recursion below answers whether it exists and what it holds.
if cursor.prefixEndsOnDelimiter {
cursor.prefixEndsOnDelimiter = false
}
if isKeyObject && blockKey > marker {
// Directory key objects (created via PutObject with trailing "/")
// appear as regular keys in recursive listing mode.
eachEntryFn(dir, emitEntry)
}
subNextMarker, subErr := s3a.doListFilerEntries(ctx, client, listDirectoryRequest{dir: dir + "/" + e.Name, marker: subMarker, delimiter: delimiter, bucket: bucket}, cursor, eachEntryFn)
if subErr != nil {
return fmt.Errorf("doListFilerEntries2: %w", subErr)
}
nextMarker = e.Name + "/" + subNextMarker
if cursor.maxKeys <= 0 && subNextMarker == "" && hasEntries(dir+"/"+e.Name) {
// The recursion starved on quota before listing the subtree;
// keep a resume position that re-enters it.
cursor.isTruncated = true
}
// An empty directory gets no marker under its own "<dir>/" prefix:
// that listing enumerates children, where S3 returns nothing for an
// empty prefix, and a fake entry is read back as a real object by
// clients that enumerate locks or similar.
// println("doListFilerEntries2 dir", dir+"/"+e.Name, "subNextMarker", subNextMarker)
if cursor.isTruncated || matchedPrefixDir {
stopped = true
}
// println("doListFilerEntries2 nextMarker", nextMarker)
} else {
switch {
case isKeyObject && blockKey > marker:
eachEntryFn(dir, emitEntry)
case e.IsPrefixObject():
// The plain key already listed at the name position; the
// deferred block emits the "<name>/" common prefix only when
// children fold under it.
if hasEntries(dir + "/" + e.Name) {
eachEntryFn(dir, emitEntry)
}
case !s3a.dirHoldsOnlyHiddenEntries(ctx, client, bucket, dir+"/"+e.Name, cursor):
eachEntryFn(dir, emitEntry)
}
// Whatever the block emitted sits at its "<name>/" position, so the
// resume marker is the block boundary: the bare name sorts inside the
// (name, name/) band and would reopen the block for a second emission.
nextMarker = e.Name + "/"
}
return nil
}
// pendingDirs is kept ordered by "<name>/" rather than by name: "a-c/" and
// "a./" sort before "a/" even though "a" is the smaller name.
flushDirs := func(upTo string) error {
for len(pendingDirs) > 0 && (upTo == "" || pendingDirs[0].e.Name+"/" < upTo) {
if cursor.maxKeys <= 0 {
cursor.isTruncated = true
return nil
}
d := pendingDirs[0]
pendingDirs = pendingDirs[1:]
if err := emitDir(d.e, d.matchedPrefixDir); err != nil {
return err
}
if stopped || cursor.isTruncated {
return nil
}
}
return nil
}
queueDir := func(pd pendingDir) {
blockKey := pd.e.Name + "/"
i := 0
for i < len(pendingDirs) && pendingDirs[i].e.Name+"/" < blockKey {
i++
}
pendingDirs = append(pendingDirs, pendingDir{})
copy(pendingDirs[i+1:], pendingDirs[i:])
pendingDirs[i] = pd
}
// Entries that emit nothing (empty directories, the .uploads folder, the marker
// echo) consume the request window without consuming maxKeys, so one window may
// end before maxKeys is satisfied. Keep requesting from the last received entry
@@ -1098,7 +997,7 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
Directory: dir,
Prefix: prefix,
Limit: uint32(cursor.maxKeys) + 2, // bucket root directory needs to skip additional s3_constants.MultipartUploadsFolder folder
StartFromFileName: startName,
StartFromFileName: marker,
InclusiveStartFrom: inclusiveStartFrom,
}
@@ -1129,21 +1028,47 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
}
entriesReceived++
lastEntryName = entry.Name
// A directory whose block sorts before this entry emits now — every
// key it yields still sorts before this entry's name.
if err = flushDirs(entry.Name); err != nil || stopped || cursor.isTruncated {
return
// listFilerEntries always calls doListFilerEntries with inclusiveStartFrom=false
// (S3 marker semantics are exclusive), but keep the guard explicit to preserve
// behavior if inclusive callers are introduced in the future.
// A versioned object lives in a "<key>.versions" directory, so the marker also
// has to be matched against the object name that directory stands for.
markerName := entry.Name
if entry.IsDirectory {
markerName = strings.TrimSuffix(markerName, s3_constants.VersionsFolder)
}
// A directory block still contributes past a marker that lands inside
// its "<name>/" band; only a block entirely behind it is skipped.
if marker != "" {
if entry.IsDirectory {
if entry.Name+"/" <= marker && !strings.HasPrefix(marker, entry.Name+"/") {
continue
}
} else if entry.Name <= marker {
if marker != "" && (entry.Name == marker || markerName == marker) {
// The marker excludes only the key it names. A directory under
// that name sorts its children after the marker, so a recursive
// listing still walks the subtree — from the start. A directory
// key object lists as "<marker>/" — a different key that also
// sorts after — so it is emitted; a prefix object would repeat
// the marker itself and is not. Internal folders keep their
// exclusion, .versions dirs fold into the marker key, and a
// delimited listing keeps skipping — the same marker resumes an
// already-emitted common prefix there.
walkableMarkerDir := delimiter == "" && entry.IsDirectory && entry.Name == marker &&
!markerParentWalked && !strings.HasSuffix(entry.Name, s3_constants.VersionsFolder) &&
entry.Name != s3_constants.MultipartUploadsFolder
if !walkableMarkerDir {
continue
}
if entry.IsDirectoryKeyObject() && !entry.IsPrefixObject() {
eachEntryFn(dir, entry)
if cursor.isTruncated {
return
}
}
subNextMarker, subErr := s3a.doListFilerEntries(ctx, client, listDirectoryRequest{dir: dir + "/" + entry.Name, delimiter: delimiter, bucket: bucket}, cursor, eachEntryFn)
if subErr != nil {
err = fmt.Errorf("doListFilerEntries: %w", subErr)
return
}
nextMarker = entry.Name + "/" + subNextMarker
if cursor.isTruncated {
return
}
continue
}
// The .versions sibling of the key just emitted still decides that key's
@@ -1180,25 +1105,6 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
}
if entry.IsDirectory {
// glog.V(4).Infof("List Dir Entries %s, file: %s, maxKeys %d", dir, entry.Name, cursor.maxKeys)
// A marker inside a skipped subtree still descends — the
// internal folder is not listed, its contents may be.
if strings.HasPrefix(marker, entry.Name+"/") {
if delimiter == "/" {
// The whole block already collapsed into its common
// prefix; nothing under it is listed again.
continue
}
subNextMarker, subErr := s3a.doListFilerEntries(ctx, client, listDirectoryRequest{dir: dir + "/" + entry.Name, marker: marker[len(entry.Name)+1:], delimiter: delimiter, bucket: bucket}, cursor, eachEntryFn)
if subErr != nil {
err = fmt.Errorf("doListFilerEntries: %w", subErr)
return
}
nextMarker = entry.Name + "/" + subNextMarker
if cursor.isTruncated {
return
}
continue
}
if entry.Name == s3_constants.MultipartUploadsFolder { // FIXME no need to apply to all directories. this extra also affects maxKeys
continue
}
@@ -1213,10 +1119,6 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
}
// Extract object name from .versions directory name
baseObjectName := strings.TrimSuffix(entry.Name, s3_constants.VersionsFolder)
// A marker at or past the folded key already covered it.
if marker != "" && baseObjectName <= marker {
continue
}
// A page resuming from a marker inside the base key's extension
// region ("k.bak" sorts between "k" and "k.versions") means an
// earlier page already listed and settled the base null object;
@@ -1253,32 +1155,76 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
continue
}
pd := pendingDir{e: entry, matchedPrefixDir: matchedPrefixDir}
// A prefix object lists as the plain key "<name>", which sorts
// at the name, not at the "<name>/" block its children occupy; the
// deferred block emits the common prefix at its own position.
if entry.IsPrefixObject() && entry.Name > marker {
cursor.prefixObjectKeyOnly = true
if delimiter != "/" || cursor.prefixEndsOnDelimiter {
// A trailing-slash prefix (e.g. "logs/") names one directory and asks
// whether it exists, so a real but empty directory must be reported for
// that probe.
explicitDirProbe := cursor.prefixEndsOnDelimiter
if cursor.prefixEndsOnDelimiter {
cursor.prefixEndsOnDelimiter = false
}
isKeyObject := entry.IsDirectoryKeyObject()
if isKeyObject {
// Directory key objects (created via PutObject with trailing "/")
// must appear as regular keys in recursive listing mode.
eachEntryFn(dir, entry)
}
// Recurse into subdirectory to list any children, noting whether the
// subtree produced any entries.
childEmitted := false
subNextMarker, subErr := s3a.doListFilerEntries(ctx, client, listDirectoryRequest{dir: dir + "/" + entry.Name, delimiter: delimiter, bucket: bucket}, cursor, func(d string, e *filer_pb.Entry) {
childEmitted = true
eachEntryFn(d, e)
})
if subErr != nil {
err = fmt.Errorf("doListFilerEntries2: %w", subErr)
return
}
// A real but empty directory (created out of band via mount, mkdir or
// the filer API, so it carries no MIME) is otherwise invisible to S3
// clients that detect directories by listing it under its own "<dir>/"
// prefix. Answer that explicit probe so tools like hadoop-aws can find
// the directory: a delimited listing reports it as a CommonPrefix --
// a real key in Contents would look like an object a client can read --
// while a recursive listing keeps the directory marker key it used.
// Plain listings are left untouched, so empty directories left behind
// by deleted objects are not shown as keys.
// A directory that still holds version history no longer names anything,
// so it gets no marker either.
if explicitDirProbe && !isKeyObject && !childEmitted && !cursor.isTruncated && entry.Attributes != nil &&
!s3a.dirHoldsOnlyHiddenEntries(ctx, client, bucket, dir+"/"+entry.Name, cursor) {
if delimiter == "/" {
cursor.emitDirAsCommonPrefix = true
} else {
entry.Attributes.Mime = s3_constants.FolderMimeType
}
eachEntryFn(dir, entry)
}
// println("doListFilerEntries2 dir", dir+"/"+entry.Name, "subNextMarker", subNextMarker)
nextMarker = entry.Name + "/" + subNextMarker
if cursor.isTruncated {
return
}
if matchedPrefixDir {
return
}
// println("doListFilerEntries2 nextMarker", nextMarker)
} else if entry.IsDirectoryKeyObject() || !s3a.dirHoldsOnlyHiddenEntries(ctx, client, bucket, dir+"/"+entry.Name, cursor) {
eachEntryFn(dir, entry)
}
queueDir(pd)
continue
} else {
eachEntryFn(dir, entry)
// glog.V(4).Infof("List File Entries %s, file: %s, maxKeys %d", dir, entry.Name, cursor.maxKeys)
}
eachEntryFn(dir, entry)
// glog.V(4).Infof("List File Entries %s, file: %s, maxKeys %d", dir, entry.Name, cursor.maxKeys)
if cursor.prefixEndsOnDelimiter {
cursor.prefixEndsOnDelimiter = false
}
}
if cursor.isTruncated || stopped {
if cursor.isTruncated || entriesReceived < request.Limit {
return
}
if entriesReceived < request.Limit {
err = flushDirs("")
return
}
startName = lastEntryName
marker = lastEntryName
inclusiveStartFrom = false
}
}
@@ -1536,15 +1482,6 @@ func adjustMarkerForDelimiter(marker, prefix, delimiter string) string {
return marker
}
// A marker ending on "/" already names the bound past that directory's
// block — the walk reads "boo/" as "the boo/ block is done". Trimming it
// to the bare name would sort inside the (name, name/) band instead and
// reopen the block. Other delimiters have no matching directory boundary,
// so their markers still need the trim below.
if delimiter == "/" {
return marker
}
// Remove the trailing delimiter
// This ensures we skip all entries under the prefix but don't skip
// potential directory entries that start with a similar prefix
@@ -65,11 +65,12 @@ func TestDirectoryListedAsCommonPrefix(t *testing.T) {
assert.Empty(t, seenFiles, "No files should be seen, only the directory")
}
// TestEmptyDirectoryNotListedUnderOwnPrefix covers the explicit "<dir>/" probe
// on a real but empty directory (created via mount, mkdir or the filer API, so
// it has no MIME): it is listed under its own prefix in neither delimited nor
// recursive mode, matching S3 where an empty prefix has no key to return.
func TestEmptyDirectoryNotListedUnderOwnPrefix(t *testing.T) {
// TestEmptyDirectorySurfacedAsMarker reproduces the hadoop-aws / Spark case where a
// real but empty directory (created via mount, mkdir or the filer API, so it has no
// MIME) must be visible to S3 clients that detect directories by listing under the
// "<dir>/" prefix. Under a delimiter it is reported as a CommonPrefix, which the
// probe accepts, instead of a key a client might try to read.
func TestEmptyDirectorySurfacedAsMarker(t *testing.T) {
s3a := &S3ApiServer{
option: &S3ApiServerOption{BucketsPath: "/buckets"},
}
@@ -86,15 +87,20 @@ func TestEmptyDirectoryNotListedUnderOwnPrefix(t *testing.T) {
},
}
for _, delimiter := range []string{"/", ""} {
cursor := &ListingCursor{maxKeys: 1000, prefixEndsOnDelimiter: true}
var seen []*filer_pb.Entry
_, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/test", prefix: "logs", delimiter: delimiter, bucket: "test"}, cursor,
func(dir string, entry *filer_pb.Entry) {
seen = append(seen, entry)
})
assert.NoError(t, err)
assert.Empty(t, seen, "an empty directory reports nothing under its own prefix (delimiter %q)", delimiter)
// Mirrors the getFileStatus probe: prefix "logs/" with delimiter "/".
cursor := &ListingCursor{maxKeys: 1000, prefixEndsOnDelimiter: true}
var seen []*filer_pb.Entry
_, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/test", prefix: "logs", delimiter: "/", bucket: "test"}, cursor,
func(dir string, entry *filer_pb.Entry) {
seen = append(seen, entry)
})
assert.NoError(t, err)
if assert.Len(t, seen, 1, "empty directory must be surfaced under the logs/ prefix") {
assert.True(t, cursor.emitDirAsCommonPrefix,
"a delimited probe reports the empty directory as a common prefix")
assert.Empty(t, seen[0].Attributes.Mime,
"the entry must not be dressed as a directory key object")
}
}
@@ -179,10 +185,10 @@ func TestEmptyDirectoryHiddenInFlatListing(t *testing.T) {
assert.Equal(t, []string{"foo"}, seen, "flat listing must hide the empty directory and return only real objects")
}
// A trailing-slash delimited listing of an empty directory enumerates its
// children, so it returns nothing: the directory is neither a readable Contents
// key nor a CommonPrefix of its own.
func TestEmptyDirectoryHiddenInDelimitedSelfListing(t *testing.T) {
// A trailing-slash delimited listing of an empty directory reports the
// directory as a CommonPrefix, not as a Contents key: clients that treat
// every returned key as an object (e.g. lock listings) skip prefixes.
func TestEmptyDirectoryListedAsCommonPrefix(t *testing.T) {
filer := &fakeBucketDeleteFiler{entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/test": {
{Name: "locks", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: ""}},
@@ -202,5 +208,6 @@ func TestEmptyDirectoryHiddenInDelimitedSelfListing(t *testing.T) {
})
require.NoError(t, err)
assert.Empty(t, response.Contents, "an empty directory is not a readable key")
assert.Empty(t, response.CommonPrefixes, "an empty directory has no prefixes to fold into")
require.Len(t, response.CommonPrefixes, 1)
assert.Equal(t, "locks/", response.CommonPrefixes[0].Prefix)
}
+8 -343
View File
@@ -4,13 +4,11 @@ import (
"context"
"io"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
grpc "google.golang.org/grpc"
@@ -100,31 +98,18 @@ func (c *markerEchoFilerClient) ListEntries(ctx context.Context, in *filer_pb.Li
return &testListEntriesStream{entries: entries}, nil
}
filtered := make([]*filer_pb.Entry, 0, len(entries))
for _, e := range entries {
if e.Name > in.StartFromFileName || (in.InclusiveStartFrom && e.Name == in.StartFromFileName) {
filtered = append(filtered, e)
}
}
// Emulate buggy backend behavior: echo the start entry again even when the
// request did not ask for it.
if !in.InclusiveStartFrom {
var echo *filer_pb.Entry
for _, e := range entries {
if e.Name == in.StartFromFileName {
echo = e
break
}
}
if echo != nil {
for i, e := range entries {
if e.Name == in.StartFromFileName {
// Emulate buggy backend behavior: return marker again even when exclusive.
if c.returnFollowing {
filtered = append([]*filer_pb.Entry{echo}, filtered...)
} else {
filtered = []*filer_pb.Entry{echo}
echoAndFollowing := entries[i:]
return &testListEntriesStream{entries: echoAndFollowing}, nil
}
return &testListEntriesStream{entries: []*filer_pb.Entry{e}}, nil
}
}
return &testListEntriesStream{entries: filtered}, nil
return &testListEntriesStream{entries: nil}, nil
}
func ensureEntryAttributes(entries []*filer_pb.Entry) {
@@ -1158,323 +1143,3 @@ func TestListObjectsV2_PrefixEndingWithSlash_WithDelimiter(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, []string{"fileA"}, results, "Should only return files under directory '1', not '1000'")
}
// A marker landing between a directory's name and its trailing slash must
// still produce keys inside the directory in byte order: the marker's own
// block band, not just its name, decides what follows it.
func TestDoListFilerEntries_MarkerBetweenDirAndSlash(t *testing.T) {
s3a := &S3ApiServer{}
dir := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}}
}
file := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}
}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b/o": {dir("a"), file("a-c"), file("a.txt"), file("a0"), file("ab")},
"/buckets/b/o/a": {file("b"), dir("c")},
"/buckets/b/o/a/c": {file("d")},
},
}
collect := func(marker string) []string {
cursor := &ListingCursor{maxKeys: 1000}
var seen []string
_, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b/o", marker: marker, bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, strings.TrimPrefix(dir, "/buckets/b/")+"/"+entry.Name)
cursor.maxKeys--
})
assert.NoError(t, err)
return seen
}
// byte order: o/a-c, o/a.txt, o/a/b, o/a/c/d, o/a0, o/ab
assert.Equal(t, []string{
"o/a-c", "o/a.txt", "o/a/b", "o/a/c/d", "o/a0", "o/ab",
}, collect(""))
assert.Equal(t, []string{
"o/a/b", "o/a/c/d", "o/a0", "o/ab",
}, collect("a.txt"))
assert.Equal(t, []string{
"o/a/c/d", "o/a0", "o/ab",
}, collect("a/b"))
assert.Equal(t, []string{
"o/ab",
}, collect("a0"))
// a marker that sorts inside the "a" band before its slash still walks a:
assert.Equal(t, []string{
"o/a.txt", "o/a/b", "o/a/c/d", "o/a0", "o/ab",
}, collect("a-c"))
}
// Directory blocks order by "<name>/", not by name: "a-/" sorts before "a/".
func TestDoListFilerEntries_DirBlocksSortBySlashName(t *testing.T) {
s3a := &S3ApiServer{}
dir := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}}
}
file := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}
}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {dir("a"), dir("a-"), file("a.txt")},
"/buckets/b/a": {file("b")},
"/buckets/b/a-": {file("x")},
},
}
cursor := &ListingCursor{maxKeys: 1000}
var seen []string
_, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, strings.TrimPrefix(dir, "/buckets/b")+"/"+entry.Name)
cursor.maxKeys--
})
assert.NoError(t, err)
assert.Equal(t, []string{"/a-/x", "/a.txt", "/a/b"}, seen)
}
// A full page must report truncation while deferred directories still hold keys.
func TestDoListFilerEntries_FullPageTruncatesWithPendingDir(t *testing.T) {
s3a := &S3ApiServer{}
dir := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}}
}
file := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}
}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {dir("a"), file("a-c")},
"/buckets/b/a": {file("x")},
},
}
cursor := &ListingCursor{maxKeys: 1}
var seen []string
nextMarker, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, entry.Name)
cursor.maxKeys--
})
assert.NoError(t, err)
assert.Equal(t, []string{"a-c"}, seen)
assert.True(t, cursor.isTruncated, "deferred directory still holds a key")
assert.Equal(t, "a-c", nextMarker)
// the next page re-enters the deferred block through the marker band
cursor = &ListingCursor{maxKeys: 1000}
seen = nil
_, err = s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", marker: nextMarker, bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, dir+"/"+entry.Name)
cursor.maxKeys--
})
assert.NoError(t, err)
assert.Equal(t, []string{"/buckets/b/a/x"}, seen)
}
// A directory that is also a plain object lists "a" at the name position and
// does not repeat it when the marker is the object itself.
func TestDoListFilerEntries_PrefixObjectKeepsNamePosition(t *testing.T) {
s3a := &S3ApiServer{}
dirObj := func(name string) *filer_pb.Entry {
e := &filer_pb.Entry{Name: name, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{FileMode: uint32(os.ModeDir | 0644)}}
e.MarkPrefixObject()
return e
}
file := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}
}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {dirObj("a"), file("a.txt")},
"/buckets/b/a": {file("b")},
},
}
collect := func(marker string) []string {
cursor := &ListingCursor{maxKeys: 1000}
var seen []string
_, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", marker: marker, bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, dir+"/"+entry.Name)
cursor.maxKeys--
})
assert.NoError(t, err)
return seen
}
assert.Equal(t, []string{"/buckets/b/a", "/buckets/b/a.txt", "/buckets/b/a/b"}, collect(""))
// the marker is the object "a" itself: excluded, but "a.txt" and the
// "a/" block still follow
assert.Equal(t, []string{"/buckets/b/a.txt", "/buckets/b/a/b"}, collect("a"))
}
// A marker that lands inside a directory's "<name>/" block — the continuation
// token a delimited page returns for it — must not re-list the block's folded
// contents under delimiter "/".
func TestDoListFilerEntries_DelimiterSkipsResumedDirBlock(t *testing.T) {
s3a := &S3ApiServer{}
dir := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}}
}
file := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}
}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {dir("a"), file("b")},
"/buckets/b/a": {file("x")},
},
}
collect := func(marker string) []string {
cursor := &ListingCursor{maxKeys: 1000}
var seen []string
_, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", marker: marker, delimiter: "/", bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, strings.TrimPrefix(dir, "/buckets/b")+"/"+entry.Name)
cursor.maxKeys--
})
assert.NoError(t, err)
return seen
}
// The "a/" block already collapsed into a common prefix on the earlier
// page, so neither "a/" nor anything under it is emitted again.
assert.Equal(t, []string{"/b"}, collect("a/"))
assert.Equal(t, []string{"/b"}, collect("a/x"))
}
// A delimited listing resumes a directory at its "<name>/" block boundary, not
// the bare name: the name alone sorts inside the (name, name/) band and makes
// the next pass re-emit the same common prefix until the quota runs out. The
// marker also survives a round trip through the request path unchanged —
// adjustMarkerForDelimiter must not trim the trailing slash off it.
func TestDoListFilerEntries_DelimitedDirResumesAtBlockBoundary(t *testing.T) {
s3a := &S3ApiServer{}
dir := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}}
}
file := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}
}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {dir("a"), file("b")},
"/buckets/b/a": {file("x")},
},
}
// The outer listFilerEntries loop re-enters with the returned marker until
// a pass comes back empty; every entry must be emitted exactly once.
var seen []string
marker := ""
for pass := 0; pass < 4; pass++ {
cursor := &ListingCursor{maxKeys: 1000}
emitted := 0
nextMarker, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", marker: marker, delimiter: "/", bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, strings.TrimPrefix(dir, "/buckets/b")+"/"+entry.Name)
emitted++
})
assert.NoError(t, err)
if emitted == 0 {
break
}
marker = adjustMarkerForDelimiter(nextMarker, "", "/")
}
assert.Equal(t, []string{"/a", "/b"}, seen)
}
// A prefix object's "<name>/" common prefix belongs to the block position, not
// the name position: it must not outrank "a.txt" on a full page nor repeat on
// the next one.
func TestDoListFilerEntries_PrefixObjectDefersCommonPrefix(t *testing.T) {
s3a := &S3ApiServer{}
dirObj := func(name string) *filer_pb.Entry {
e := &filer_pb.Entry{Name: name, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{FileMode: uint32(os.ModeDir | 0644)}}
e.MarkPrefixObject()
return e
}
file := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}
}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {dirObj("a"), file("a.txt")},
"/buckets/b/a": {file("b")},
},
}
// Page one holds the plain key "a" and "a.txt"; the "a/" block sort after
// both, so the page truncates at "a.txt" without touching it.
cursor := &ListingCursor{maxKeys: 2}
var seen []string
var sawPrefixObject bool
nextMarker, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", delimiter: "/", bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, entry.Name)
sawPrefixObject = sawPrefixObject || entry.IsPrefixObject()
cursor.maxKeys--
})
assert.NoError(t, err)
assert.Equal(t, []string{"a", "a.txt"}, seen)
assert.True(t, sawPrefixObject, "the name-position emission is the plain object")
assert.True(t, cursor.isTruncated)
assert.Equal(t, "a.txt", nextMarker)
// Page two emits the deferred block once, as a plain directory fold.
cursor = &ListingCursor{maxKeys: 2}
seen = nil
sawPrefixObject = false
_, err = s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", marker: nextMarker, delimiter: "/", bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, entry.Name)
sawPrefixObject = sawPrefixObject || entry.IsPrefixObject()
cursor.maxKeys--
})
assert.NoError(t, err)
assert.Equal(t, []string{"a"}, seen)
assert.False(t, sawPrefixObject, "the block emission is the \"<name>/\" fold, not the plain key again")
assert.False(t, cursor.isTruncated)
}
// A directory key object that spends the page's last slot still reports
// truncation so the next page re-enters its subtree.
func TestDoListFilerEntries_DirKeyObjectBoundaryTruncates(t *testing.T) {
s3a := &S3ApiServer{}
dirKeyObj := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: s3_constants.FolderMimeType}}
}
file := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}
}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {dirKeyObj("a")},
"/buckets/b/a": {file("x")},
},
}
cursor := &ListingCursor{maxKeys: 1}
var seen []string
nextMarker, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, entry.Name)
cursor.maxKeys--
})
assert.NoError(t, err)
assert.Equal(t, []string{"a"}, seen)
assert.True(t, cursor.isTruncated, "the subtree may still hold unlisted keys")
assert.Equal(t, "a/", nextMarker)
// The next page resumes inside the "a/" block and lists the child.
cursor = &ListingCursor{maxKeys: 1000}
seen = nil
_, err = s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", marker: nextMarker, bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, strings.TrimPrefix(dir, "/buckets/b")+"/"+entry.Name)
cursor.maxKeys--
})
assert.NoError(t, err)
assert.Equal(t, []string{"/a/x"}, seen)
assert.False(t, cursor.isTruncated)
}
+4 -18
View File
@@ -82,7 +82,7 @@ func (s3a *S3ApiServer) PostPolicyBucketHandler(w http.ResponseWriter, r *http.R
// Verify policy signature.
identity, errCode := s3a.iam.doesPolicySignatureMatch(formValues)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, refineDenialForBucketName(r, identity, errCode))
s3err.WriteErrorResponse(w, r, errCode)
return
}
if identity != nil {
@@ -140,7 +140,7 @@ func (s3a *S3ApiServer) PostPolicyBucketHandler(w http.ResponseWriter, r *http.R
// Authorize the object like the PUT path; the coarse Write check above does not consult the bucket policy.
if errCode := s3a.iam.AuthorizeObjectWrite(r, identity, bucket, object); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, refineDenialForBucketName(r, identity, errCode))
s3err.WriteErrorResponse(w, r, errCode)
return
}
@@ -362,24 +362,10 @@ func getRedirectPostRawQuery(bucket, key, etag string) string {
}
// Check to see if Policy is signed correctly, returning the signing identity.
// The identity's Write permission on the bucket is checked only once the
// signature verifies, as on the header-signed path, so a form that fails
// verification learns nothing about the key's permissions. A verified identity
// that may not write is returned with ErrAccessDenied.
func (iam *IdentityAccessManagement) doesPolicySignatureMatch(formValues http.Header) (*Identity, s3err.ErrorCode) {
var identity *Identity
var errCode s3err.ErrorCode
// For SignV2 - Signature field will be valid
if _, ok := formValues["Signature"]; ok {
identity, errCode = iam.doesPolicySignatureV2Match(formValues)
} else {
identity, errCode = iam.doesPolicySignatureV4Match(formValues)
return iam.doesPolicySignatureV2Match(formValues)
}
if errCode != s3err.ErrNone {
return nil, errCode
}
if !identity.CanDo(s3_constants.ACTION_WRITE, formValues.Get("bucket"), "") {
return identity, s3err.ErrAccessDenied
}
return identity, s3err.ErrNone
return iam.doesPolicySignatureV4Match(formValues)
}
+1 -2
View File
@@ -2796,9 +2796,8 @@ func (s3a *S3ApiServer) validateConditionalHeaders(r *http.Request, headers cond
// 1. Check If-Match
if headers.ifMatch != "" {
// AWS answers 404 when there is no current version (or it is a delete marker)
if !objectExists {
return s3err.ErrNoSuchKey
return s3err.ErrPreconditionFailed
}
// If `ifMatch` is "*", the condition is met if the object exists.
// Otherwise, we need to check the ETag.
@@ -87,7 +87,7 @@ func TestCopyChunksReadOnlyIsAccessDenied(t *testing.T) {
t.Fatalf("copyChunks err %v maps to %v, want %v", err, code, s3err.ErrAccessDenied)
}
_, _, err = s3a.copyChunksForRange(entry, 0, 7, "/buckets/b/dst")
_, err = s3a.copyChunksForRange(entry, 0, 7, "/buckets/b/dst")
if code := s3a.mapCopyErrorToS3Error(err); code != s3err.ErrAccessDenied {
t.Fatalf("copyChunksForRange err %v maps to %v, want %v", err, code, s3err.ErrAccessDenied)
}
@@ -11,7 +11,6 @@ import (
"github.com/gorilla/mux"
iamlib "github.com/seaweedfs/seaweedfs/weed/iam"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/require"
)
@@ -229,22 +228,6 @@ func TestObjectLockActionsRoundTrip(t *testing.T) {
}
}
// Anonymous misses must not disclose bucket or key existence: 404 is only
// served when anonymous listing is allowed.
func TestAnonymousBucketListAuthorization(t *testing.T) {
s3a := &S3ApiServer{iam: &IdentityAccessManagement{}, bucketConfigCache: NewBucketConfigCache(time.Minute)}
s3a.bucketConfigCache.Set("open", &BucketConfig{Name: "open", IsPublicRead: true})
s3a.bucketConfigCache.Set("private", &BucketConfig{Name: "private"})
req := httptest.NewRequest(http.MethodGet, "http://localhost:8333/open/key", nil)
if got := s3a.authorizeAnonymousBucketList(req, "open"); got != s3err.ErrNone {
t.Errorf("listable bucket: %v, want ErrNone", got)
}
if got := s3a.authorizeAnonymousBucketList(req, "private"); got != s3err.ErrAccessDenied {
t.Errorf("private bucket: %v, want AccessDenied", got)
}
}
// Internal Seaweed-X-Amz-* headers carry server-validated object lock values
// into entry metadata; client-supplied ones must be dropped on entry or the
// dedicated-permission check on x-amz-object-lock-* is bypassed.
-30
View File
@@ -40,33 +40,3 @@ func TestParseAndValidateRangeRejectsMalformedNumericOffsets(t *testing.T) {
})
}
}
func TestHeadObjectRange(t *testing.T) {
s3a, f := newAnonymousReadTestServer(t)
f.entries["/buckets/b/o"] = anonymousReadEntry(anonymousReadGrant("READ"))
for _, tc := range []struct {
rangeHeader, contentRange, contentLength string
code int
}{
{"", "", "11", http.StatusOK},
{"bytes=1-4", "bytes 1-4/11", "4", http.StatusPartialContent},
{"bytes=-3", "bytes 8-10/11", "3", http.StatusPartialContent},
{"bytes=5-", "bytes 5-10/11", "6", http.StatusPartialContent},
{"bytes=20-", "bytes */11", "", http.StatusRequestedRangeNotSatisfiable},
} {
headers := map[string]string{}
if tc.rangeHeader != "" {
headers["Range"] = tc.rangeHeader
}
rr := serveAnonymousRead(s3a, http.MethodHead, "o", "", headers)
if rr.Code != tc.code {
t.Errorf("Range %q: status %d, want %d", tc.rangeHeader, rr.Code, tc.code)
}
if got := rr.Header().Get("Content-Range"); got != tc.contentRange {
t.Errorf("Range %q: Content-Range %q, want %q", tc.rangeHeader, got, tc.contentRange)
}
if got := rr.Header().Get("Content-Length"); tc.contentLength != "" && got != tc.contentLength {
t.Errorf("Range %q: Content-Length %q, want %q", tc.rangeHeader, got, tc.contentLength)
}
}
}
+1 -4
View File
@@ -661,10 +661,7 @@ func (s3a *S3ApiServer) checkPolicyWithEntry(r *http.Request, bucket, object, ac
// Returns s3err.ErrNone if allowed, or an error code if denied or on error.
func (s3a *S3ApiServer) recheckPolicyWithObjectEntry(r *http.Request, bucket, object, action string, objectEntry map[string][]byte, handlerName string) s3err.ErrorCode {
if isAnonymousObjectRead(r) {
// Refined like writeAnonymousAwareNotFound's masked miss, so a denied
// existing key and a missing key answer alike on a malformed name.
identity, _ := GetIdentityFromContext(r).(*Identity)
return refineDenialForBucketName(r, identity, s3a.authorizeAnonymousObjectRead(r, bucket, object, objectEntry))
return s3a.authorizeAnonymousObjectRead(r, bucket, object, objectEntry)
}
identityRaw := GetIdentityFromContext(r)
var identity *Identity
-14
View File
@@ -27,23 +27,10 @@ const extendedAttributeUpdateAttempts = 5
// createDirectory creates a new directory at the specified path
func (h *S3TablesHandler) createDirectory(ctx context.Context, client filer_pb.SeaweedFilerClient, path string) error {
return h.createDirectoryEntry(ctx, client, path, false, nil)
}
// createDirectoryExclusive creates a directory entry together with its
// extended attributes in one CreateEntry that fails with
// filer_pb.ErrEntryAlreadyExists when an entry is already there: nothing is
// replaced and no reader sees the directory without its attributes.
func (h *S3TablesHandler) createDirectoryExclusive(ctx context.Context, client filer_pb.SeaweedFilerClient, path string, extended map[string][]byte) error {
return h.createDirectoryEntry(ctx, client, path, true, extended)
}
func (h *S3TablesHandler) createDirectoryEntry(ctx context.Context, client filer_pb.SeaweedFilerClient, path string, exclusive bool, extended map[string][]byte) error {
dir, name := splitPath(path)
now := time.Now().Unix()
return filer_pb.CreateEntry(ctx, client, &filer_pb.CreateEntryRequest{
Directory: dir,
OExcl: exclusive,
Entry: &filer_pb.Entry{
Name: name,
IsDirectory: true,
@@ -52,7 +39,6 @@ func (h *S3TablesHandler) createDirectoryEntry(ctx context.Context, client filer
Crtime: now,
FileMode: uint32(0755 | os.ModeDir),
},
Extended: extended,
},
})
}
@@ -1,89 +0,0 @@
package s3tables
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
)
// competingCreateClient lands a foreign entry where the handler's existence
// check just answered "not there": the create that follows must lose to it
// rather than replace it, the way a second caller's create wins the window in
// a check-then-create race.
type competingCreateClient struct {
filer_pb.SeaweedFilerClient
f *s3tablestest.MemFiler
dir string
name string
plant bool
}
func (c *competingCreateClient) LookupDirectoryEntry(ctx context.Context, req *filer_pb.LookupDirectoryEntryRequest, opts ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
resp, err := c.SeaweedFilerClient.LookupDirectoryEntry(ctx, req, opts...)
if c.plant && err != nil && req.Directory == c.dir && req.Name == c.name {
c.plant = false
c.f.Put(c.dir, c.name, map[string][]byte{
ExtendedKeyTableBucket: []byte("true"),
ExtendedKeyMetadata: []byte(`{"OwnerAccountID":"competitor"}`),
})
}
return resp, err
}
func runHandler(t *testing.T, m *Manager, client filer_pb.SeaweedFilerClient, operation string, input interface{}) (int, S3TablesError) {
t.Helper()
body, err := json.Marshal(input)
require.NoError(t, err)
req, err := newManagerRequest(context.Background(), operation, body, "admin")
require.NoError(t, err)
recorder := httptest.NewRecorder()
m.handler.HandleRequest(recorder, req, NewManagerClient(client))
result := recorder.Result()
defer result.Body.Close()
var response S3TablesError
json.NewDecoder(result.Body).Decode(&response)
return result.StatusCode, response
}
func TestCreateTableBucketLostRaceKeepsTheFirstCreate(t *testing.T) {
fs := s3tablestest.Start(t)
m := NewManager()
client := &competingCreateClient{SeaweedFilerClient: fs.Client, f: fs, dir: TablesPath, name: "bucket", plant: true}
status, resp := runHandler(t, m, client, "CreateTableBucket", &CreateTableBucketRequest{Name: "bucket"})
assert.Equal(t, http.StatusConflict, status)
assert.Equal(t, ErrCodeBucketAlreadyExists, resp.Type)
survivor := fs.Get(TablesPath, "bucket")
require.NotNil(t, survivor)
assert.Equal(t, `{"OwnerAccountID":"competitor"}`, string(survivor.Extended[ExtendedKeyMetadata]))
}
func TestCreateNamespaceLostRaceKeepsTheFirstCreate(t *testing.T) {
fs := s3tablestest.Start(t)
m := NewManager()
bucketMetadata, _ := json.Marshal(&tableBucketMetadata{Name: "bucket", OwnerAccountID: DefaultAccountID})
fs.Put(TablesPath, "bucket", map[string][]byte{ExtendedKeyTableBucket: []byte("true"), ExtendedKeyMetadata: bucketMetadata})
arn, err := BuildBucketARN(DefaultRegion, DefaultAccountID, "bucket")
require.NoError(t, err)
client := &competingCreateClient{SeaweedFilerClient: fs.Client, f: fs, dir: GetTableBucketPath("bucket"), name: "ns", plant: true}
status, resp := runHandler(t, m, client, "CreateNamespace", &CreateNamespaceRequest{
TableBucketARN: arn,
Namespace: []string{"ns"},
})
assert.Equal(t, http.StatusConflict, status)
assert.Equal(t, ErrCodeNamespaceAlreadyExists, resp.Type)
survivor := fs.Get(GetTableBucketPath("bucket"), "ns")
require.NotNil(t, survivor)
assert.Equal(t, `{"OwnerAccountID":"competitor"}`, string(survivor.Extended[ExtendedKeyMetadata]))
}

Some files were not shown because too many files have changed in this diff Show More