Compare commits

..
Author SHA1 Message Date
Chris Lu 14acb0ffb9 test: rename stale reserved-folders case in bucket delete test
.versions directories are now descended into, so the case only holds
because the folder is empty.
2026-10-16 23:45:17 -07:00
Chris Lu e778f13ff4 s3: count stored versions as bucket contents
A <key>.versions directory holding version entries is user data; skipping
it let bucketHasUserObjects report an empty bucket while the recursive
delete dropped every stored version.
2026-10-16 00:03:12 -07:00
Chris Lu f5e9c9cf2c test: drop redundant allowDeleteBucketNotEmpty=false in s3 integration test
False is the default now, so TestS3DeleteBucketNotEmpty exercises the
out-of-box behavior.
2026-10-15 22:59:07 -07:00
Chris Lu 1eb0c57e61 test: pin allowDeleteBucketNotEmpty=true on versioning config1
The config1 run relied on the flag's old true default to contrast with
config2's false. Pin it so the two modes stay distinct.
2026-10-15 22:51:53 -07:00
Chris Lu 87867b667d s3: default allowDeleteBucketNotEmpty to false
DeleteBucket on a non-empty bucket silently destroyed every object,
while AWS returns 409 BucketNotEmpty. Clients like 'aws s3 rb' rely on
that refusal as the 'delete only if empty' safety net.

Deployments that need the recursive delete can opt back in with
-allowDeleteBucketNotEmpty=true.
2026-10-15 22:51:20 -07:00
134 changed files with 1546 additions and 7427 deletions
-1
View File
@@ -169,7 +169,6 @@ seaweed-volume/
- `--memprofile`
- `--compactionMBps` (default 0)
- `--maintenanceMBps` (default 0)
- `--compactionMaxParallelCommits` (default 0)
- `--fileSizeLimitMB` (default 256)
- `--concurrentUploadLimitMB` (default 0)
- `--concurrentDownloadLimitMB` (default 0)
+1080 -1071
View File
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

-1
View File
@@ -64,7 +64,6 @@ seaweed-volume --port 8080 --master localhost:9333 --dir /data/vol1 --max 7 \
| `--whiteList` | | Comma-separated IPs with write permission |
| `--preStopSeconds` | `10` | Graceful drain period before shutdown |
| `--compactionMBps` | `0` (unlimited) | Compaction I/O rate limit |
| `--compactionMaxParallelCommits` | `0` (unlimited) | Max concurrent vacuum commits |
| `--pprof` | `false` | Enable pprof HTTP handlers |
Set `RUST_LOG=debug` (or `trace`, `info`, `warn`) for log level control.
-8
View File
@@ -125,11 +125,6 @@ pub struct Cli {
#[arg(long = "maintenanceMBps", default_value_t = 0)]
pub maintenance_mb_per_second: u32,
/// Limit how many vacuum commits run at once on this server; queued
/// commits hold no volume lock. 0 means unlimited.
#[arg(long = "compactionMaxParallelCommits", default_value_t = 0)]
pub max_parallel_vacuum_commits: usize,
/// Limit file size to avoid out of memory.
#[arg(long = "fileSizeLimitMB", default_value_t = 256)]
pub file_size_limit_mb: u32,
@@ -268,8 +263,6 @@ pub struct VolumeServerConfig {
pub mem_profile: String,
pub compaction_byte_per_second: i64,
pub maintenance_byte_per_second: i64,
/// Max concurrent vacuum commits; 0 means unlimited.
pub max_parallel_vacuum_commits: usize,
pub file_size_limit_bytes: i64,
pub concurrent_upload_limit: i64,
pub concurrent_download_limit: i64,
@@ -934,7 +927,6 @@ fn resolve_config_with_env(cli: Cli, env: EnvLookup<'_>) -> VolumeServerConfig {
mem_profile: cli.mem_profile,
compaction_byte_per_second: cli.compaction_mb_per_second as i64 * 1024 * 1024,
maintenance_byte_per_second: cli.maintenance_mb_per_second as i64 * 1024 * 1024,
max_parallel_vacuum_commits: cli.max_parallel_vacuum_commits,
file_size_limit_bytes: cli.file_size_limit_mb as i64 * 1024 * 1024,
concurrent_upload_limit: cli.concurrent_upload_limit_mb as i64 * 1024 * 1024,
concurrent_download_limit: cli.concurrent_download_limit_mb as i64 * 1024 * 1024,
-8
View File
@@ -340,14 +340,6 @@ async fn run(
rack: config.rack.clone(),
file_size_limit_bytes: config.file_size_limit_bytes,
maintenance_byte_per_second: config.maintenance_byte_per_second,
vacuum_commit_slots: if config.max_parallel_vacuum_commits > 0 {
Some(tokio::sync::Semaphore::new(
config.max_parallel_vacuum_commits,
))
} else {
None
},
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
// Go sets isHeartbeating: true unconditionally at startup
is_heartbeating: std::sync::atomic::AtomicBool::new(true),
has_master: !config.masters.is_empty(),
-1
View File
@@ -397,7 +397,6 @@ mod tests {
mem_profile: String::new(),
compaction_byte_per_second: 0,
maintenance_byte_per_second: 0,
max_parallel_vacuum_commits: 0,
file_size_limit_bytes: 0,
concurrent_upload_limit: 0,
concurrent_download_limit: 0,
-300
View File
@@ -53,29 +53,6 @@ fn needle_not_found(needle_id: NeedleId) -> Status {
Status::not_found(format!("needle not found {}", needle_id.0))
}
/// 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
/// vacuum commit refuse politely instead of being dropped mid-wait.
fn grpc_timeout_deadline<T>(request: &Request<T>) -> Option<tokio::time::Instant> {
let value = request.metadata().get("grpc-timeout")?.to_str().ok()?;
let (digits, unit) = value.split_at(value.len().checked_sub(1)?);
if digits.len() > 8 {
return None;
}
let n: u64 = digits.parse().ok()?;
let dur = match unit {
"H" => std::time::Duration::from_secs(n * 60 * 60),
"M" => std::time::Duration::from_secs(n * 60),
"S" => std::time::Duration::from_secs(n),
"m" => std::time::Duration::from_millis(n),
"u" => std::time::Duration::from_micros(n),
"n" => std::time::Duration::from_nanos(n),
_ => return None,
};
Some(tokio::time::Instant::now() + dur)
}
fn unix_now_seconds() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -1285,12 +1262,8 @@ impl VolumeServer for VolumeGrpcService {
) -> Result<Response<volume_server_pb::VacuumVolumeCommitResponse>, Status> {
self.check_grpc_admin_auth(&request)?;
self.state.check_maintenance()?;
let deadline = grpc_timeout_deadline(&request);
let vid = VolumeId(request.into_inner().volume_id);
let _permit = self.state.acquire_vacuum_commit_slot(deadline).await?;
self.state.check_maintenance()?;
// Match Go's store_vacuum.go CommitCompactVolume: skip commit if stopping
if *self.state.is_stopping.read().unwrap() {
return Err(Status::internal(format!(
@@ -7374,8 +7347,6 @@ mod tests {
rack: String::new(),
file_size_limit_bytes: 0,
maintenance_byte_per_second: 0,
vacuum_commit_slots: None,
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
is_heartbeating: std::sync::atomic::AtomicBool::new(true),
has_master: false,
pre_stop_seconds: 0,
@@ -7490,8 +7461,6 @@ mod tests {
rack: String::new(),
file_size_limit_bytes: 0,
maintenance_byte_per_second: 0,
vacuum_commit_slots: None,
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
is_heartbeating: std::sync::atomic::AtomicBool::new(true),
has_master: false,
pre_stop_seconds: 0,
@@ -7603,271 +7572,6 @@ mod tests {
);
}
fn make_commit_limited_service(
collection: &str,
max_parallel_commits: usize,
) -> (VolumeGrpcService, TempDir) {
let (mut service, tmp) = make_local_service_with_volume(collection, None);
if max_parallel_commits > 0 {
Arc::get_mut(&mut service.state)
.unwrap()
.vacuum_commit_slots = Some(tokio::sync::Semaphore::new(max_parallel_commits));
}
(service, tmp)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn test_vacuum_commit_slots_bound_concurrent_commits() {
async fn peak_holders(limit: usize) -> i32 {
let (service, _tmp) = make_commit_limited_service("bound_commits", limit);
let current = Arc::new(std::sync::atomic::AtomicI32::new(0));
let peak = Arc::new(std::sync::atomic::AtomicI32::new(0));
let mut workers = Vec::new();
for _ in 0..8 {
let state = service.state.clone();
let current = current.clone();
let peak = peak.clone();
workers.push(tokio::spawn(async move {
let Ok(Some(_permit)) = state.acquire_vacuum_commit_slot(None).await else {
return;
};
let n = current.fetch_add(1, Ordering::Relaxed) + 1;
peak.fetch_max(n, Ordering::Relaxed);
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
current.fetch_sub(1, Ordering::Relaxed);
}));
}
for w in workers {
w.await.unwrap();
}
peak.load(Ordering::Relaxed)
}
assert_eq!(peak_holders(1).await, 1);
assert!(peak_holders(3).await <= 3);
}
#[tokio::test]
async fn test_vacuum_commit_slots_unlimited_by_default() {
let (service, _tmp) = make_commit_limited_service("unlimited_commits", 0);
for i in 0..8 {
service
.state
.acquire_vacuum_commit_slot(Some(
tokio::time::Instant::now() + std::time::Duration::from_secs(1),
))
.await
.unwrap_or_else(|e| {
panic!("acquisition {i} must not wait when no limit is configured: {e}")
});
}
}
#[tokio::test]
async fn test_vacuum_commit_slot_not_granted_to_expired_deadline() {
let (service, _tmp) = make_commit_limited_service("expired_commit", 1);
let slots = service.state.vacuum_commit_slots.as_ref().unwrap();
let past = tokio::time::Instant::now() - std::time::Duration::from_secs(1);
for _ in 0..100 {
let err = service
.state
.acquire_vacuum_commit_slot(Some(past))
.await
.expect_err("an expired deadline must not get a slot");
assert_eq!(err.code(), tonic::Code::DeadlineExceeded);
assert_eq!(
slots.available_permits(),
1,
"a refused acquisition must not keep the slot"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_vacuum_commit_deadline_while_queued() {
let (service, _tmp) = make_commit_limited_service("queued_deadline", 1);
let state = service.state.clone();
let held = state
.vacuum_commit_slots
.as_ref()
.unwrap()
.acquire()
.await
.unwrap();
let mut request =
Request::new(volume_server_pb::VacuumVolumeCommitRequest { volume_id: 1 });
request
.metadata_mut()
.insert("grpc-timeout", "20m".parse().unwrap());
let err = service
.vacuum_volume_commit(request)
.await
.expect_err("the queued commit must expire at its deadline");
assert_eq!(err.code(), tonic::Code::DeadlineExceeded);
assert_eq!(
state
.vacuum_commit_slots
.as_ref()
.unwrap()
.available_permits(),
0,
"a refused commit must not consume the held slot"
);
drop(held);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_queued_vacuum_commit_holds_no_store_lock() {
let (service, _tmp) = make_commit_limited_service("no_lock_commit", 1);
service
.state
.store
.write()
.unwrap()
.compact_volume(VolumeId(1), 0, 0, |_| true)
.unwrap();
let state = service.state.clone();
let held = state
.vacuum_commit_slots
.as_ref()
.unwrap()
.acquire()
.await
.unwrap();
let committed = tokio::spawn(async move {
service
.vacuum_volume_commit(Request::new(volume_server_pb::VacuumVolumeCommitRequest {
volume_id: 1,
}))
.await
});
tokio::time::timeout(std::time::Duration::from_secs(5), async {
while state.vacuum_commits_waiting.load(Ordering::Relaxed) == 0 {
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
})
.await
.expect("the commit must pass the entry checks and wait for a slot");
tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
if let Ok(_guard) = state.store.try_read() {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
})
.await
.expect("store read blocked behind a queued commit");
drop(held);
tokio::time::timeout(std::time::Duration::from_secs(5), committed)
.await
.expect("queued commit did not run after the slot freed")
.unwrap()
.unwrap();
assert_eq!(
state
.vacuum_commit_slots
.as_ref()
.unwrap()
.available_permits(),
1,
"the commit must return its slot"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_vacuum_commit_releases_slot_on_error() {
let (service, _tmp) = make_commit_limited_service("commit_err", 1);
service
.vacuum_volume_commit(Request::new(volume_server_pb::VacuumVolumeCommitRequest {
volume_id: 42,
}))
.await
.expect_err("committing an unknown volume must fail");
assert_eq!(
service
.state
.vacuum_commit_slots
.as_ref()
.unwrap()
.available_permits(),
1,
"a failed commit must return its slot"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_queued_vacuum_commit_does_not_start_in_maintenance() {
let (service, _tmp) = make_commit_limited_service("maint_commit", 1);
service
.state
.store
.write()
.unwrap()
.compact_volume(VolumeId(1), 0, 0, |_| true)
.unwrap();
let revision_before = {
let store = service.state.store.read().unwrap();
let (_, v) = store.find_volume(VolumeId(1)).unwrap();
v.super_block.compaction_revision
};
let state = service.state.clone();
let held = state
.vacuum_commit_slots
.as_ref()
.unwrap()
.acquire()
.await
.unwrap();
let committed = tokio::spawn(async move {
service
.vacuum_volume_commit(Request::new(volume_server_pb::VacuumVolumeCommitRequest {
volume_id: 1,
}))
.await
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while state.vacuum_commits_waiting.load(Ordering::Relaxed) == 0 {
assert!(
std::time::Instant::now() < deadline,
"the commit must pass the entry maintenance check and wait for a slot"
);
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
state.maintenance.store(true, Ordering::Relaxed);
drop(held);
let err = tokio::time::timeout(std::time::Duration::from_secs(5), committed)
.await
.expect("queued commit did not return after the slot freed")
.unwrap()
.expect_err("a queued commit must not run in maintenance mode");
assert!(err.message().contains("maintenance mode"));
let revision_after = {
let store = state.store.read().unwrap();
let (_, v) = store.find_volume(VolumeId(1)).unwrap();
v.super_block.compaction_revision
};
assert_eq!(
revision_before, revision_after,
"the volume must not be committed"
);
assert_eq!(
state
.vacuum_commit_slots
.as_ref()
.unwrap()
.available_permits(),
1,
"the refused commit must return its slot"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_volume_incremental_copy_streams_remote_only_volume_data() {
let (service, _tmp, shutdown_tx, dat_bytes, super_block_size, _delete_count) =
@@ -9924,8 +9628,6 @@ mod tests {
rack: String::new(),
file_size_limit_bytes: 0,
maintenance_byte_per_second: 0,
vacuum_commit_slots: None,
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
is_heartbeating: std::sync::atomic::AtomicBool::new(true),
has_master: false,
pre_stop_seconds: 0,
@@ -11760,8 +11462,6 @@ mod tests {
rack: String::new(),
file_size_limit_bytes: 0,
maintenance_byte_per_second: 0,
vacuum_commit_slots: None,
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
is_heartbeating: std::sync::atomic::AtomicBool::new(true),
has_master: false,
pre_stop_seconds: 0,
-2
View File
@@ -5285,8 +5285,6 @@ mod tests {
rack: String::new(),
file_size_limit_bytes: 0,
maintenance_byte_per_second: 0,
vacuum_commit_slots: None,
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
is_heartbeating: AtomicBool::new(false),
has_master: false,
pre_stop_seconds: 0,
-2
View File
@@ -1485,8 +1485,6 @@ mod tests {
rack: String::new(),
file_size_limit_bytes: 0,
maintenance_byte_per_second: 0,
vacuum_commit_slots: None,
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
is_heartbeating: std::sync::atomic::AtomicBool::new(false),
has_master: true,
pre_stop_seconds: 0,
-1
View File
@@ -136,7 +136,6 @@ mod tests {
mem_profile: String::new(),
compaction_byte_per_second: 0,
maintenance_byte_per_second: 0,
max_parallel_vacuum_commits: 0,
file_size_limit_bytes: 0,
concurrent_upload_limit: 0,
concurrent_download_limit: 0,
@@ -63,12 +63,6 @@ pub struct VolumeServerState {
pub file_size_limit_bytes: i64,
/// Default IO rate limit for maintenance copy/replication work.
pub maintenance_byte_per_second: i64,
/// Bounds concurrent vacuum commits; None means unlimited. Queued commits
/// wait on the semaphore, not on the store lock, so reads keep flowing.
/// Mirrors Go's `vacuumCommitSlots`.
pub vacuum_commit_slots: Option<tokio::sync::Semaphore>,
/// Vacuum commits currently waiting for a slot.
pub vacuum_commits_waiting: AtomicU32,
/// Whether the server is connected to master (heartbeat active).
pub is_heartbeating: AtomicBool,
/// Whether master addresses are configured.
@@ -150,40 +144,6 @@ impl VolumeServerState {
Ok(())
}
/// Wait for a vacuum commit slot without touching the store lock; the wait
/// counts against the caller's `grpc-timeout`. An already-expired deadline
/// is refused even when a slot is free, matching Go's post-acquire
/// `ctx.Err()` check in `acquireVacuumCommitSlot`.
pub async fn acquire_vacuum_commit_slot(
&self,
deadline: Option<tokio::time::Instant>,
) -> Result<Option<tokio::sync::SemaphorePermit<'_>>, tonic::Status> {
let Some(slots) = &self.vacuum_commit_slots else {
return Ok(None);
};
self.vacuum_commits_waiting.fetch_add(1, Ordering::Relaxed);
let acquired = match deadline {
Some(d) => match tokio::time::timeout_at(d, slots.acquire()).await {
Ok(permit) => permit.map_err(|e| tonic::Status::internal(e.to_string())),
Err(_) => Err(tonic::Status::deadline_exceeded(
"timed out waiting for a vacuum commit slot",
)),
},
None => slots
.acquire()
.await
.map_err(|e| tonic::Status::internal(e.to_string())),
};
self.vacuum_commits_waiting.fetch_sub(1, Ordering::Relaxed);
let permit = acquired?;
if deadline.is_some_and(|d| tokio::time::Instant::now() >= d) {
return Err(tonic::Status::deadline_exceeded(
"timed out waiting for a vacuum commit slot",
));
}
Ok(Some(permit))
}
/// Build the seed master set from a list of raw `host:port[.grpcPort]`
/// addresses, normalised the same way Go's `pb.ServerAddress.ToHttpAddress`
/// does (drop the `.grpcPort` suffix, preserve everything else).
-2
View File
@@ -206,8 +206,6 @@ mod tests {
rack: String::new(),
file_size_limit_bytes: 0,
maintenance_byte_per_second: 0,
vacuum_commit_slots: None,
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
is_heartbeating: AtomicBool::new(false),
has_master: false,
pre_stop_seconds: 0,
+8 -19
View File
@@ -620,20 +620,11 @@ impl DiskLocation {
return Err(v.compacting_error());
}
if let Some(mut v) = self.volumes.remove(&vid) {
match v.destroy(only_empty, only_garbage, keep_remote_data) {
Ok(()) => {
crate::metrics::VOLUME_GAUGE
.with_label_values(&[&v.collection, "volume"])
.dec();
Ok(())
}
Err(e) => {
// A failed destroy left the local files in place, so the
// replica stays registered rather than becoming a phantom.
self.volumes.insert(vid, v);
Err(e)
}
}
crate::metrics::VOLUME_GAUGE
.with_label_values(&[&v.collection, "volume"])
.dec();
v.destroy(only_empty, only_garbage, keep_remote_data)?;
Ok(())
} else {
Err(VolumeError::NotFound)
}
@@ -650,13 +641,11 @@ impl DiskLocation {
for vid in vids {
if let Some(mut v) = self.volumes.remove(&vid) {
crate::metrics::VOLUME_GAUGE
.with_label_values(&[&v.collection, "volume"])
.dec();
if let Err(e) = v.destroy(false, false, false) {
warn!(volume_id = vid.0, error = %e, "delete collection: failed to destroy volume");
self.volumes.insert(vid, v);
} else {
crate::metrics::VOLUME_GAUGE
.with_label_values(&[&v.collection, "volume"])
.dec();
}
}
}
+7 -6
View File
@@ -5006,12 +5006,13 @@ impl Volume {
.get(&storage_name);
if let Some(backend) = backend {
if let Err(e) = backend.delete_file_blocking(&storage_key) {
// Stop short of local cleanup: a failed remote delete must
// not strand a registered replica with no local files.
return Err(VolumeError::Io(io::Error::other(format!(
"volume {} remote object {}/{} delete failed: {}",
self.id, storage_name, storage_key, e
))));
warn!(
volume_id = self.id.0,
storage_name,
storage_key,
error = %e,
"failed to delete remote tier file during destroy"
);
}
} else {
warn!(
-2
View File
@@ -107,8 +107,6 @@ fn build_test_state(
rack: String::new(),
file_size_limit_bytes: 0,
maintenance_byte_per_second: 0,
vacuum_commit_slots: None,
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
is_heartbeating: std::sync::atomic::AtomicBool::new(true),
ec_decodes_in_flight: std::sync::Mutex::new(std::collections::HashSet::new()),
ec_decode_tail: std::sync::Mutex::new(std::collections::HashSet::new()),
-2
View File
@@ -14,7 +14,6 @@ import (
"sync"
"time"
"github.com/google/uuid"
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
adminplugin "github.com/seaweedfs/seaweedfs/weed/admin/plugin"
"github.com/seaweedfs/seaweedfs/weed/cluster"
@@ -1156,7 +1155,6 @@ func (s *AdminServer) SetBucketLifecycle(bucketName string, rules []BucketLifecy
bucketPath := filerConfig.BucketsPath + "/" + bucketName
resp, err := client.ObjectTransaction(context.Background(), &filer_pb.ObjectTransactionRequest{
LockKey: bucketPath,
RequestId: uuid.NewString(),
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + bucketPath,
Mutations: []*filer_pb.ObjectMutation{bucketLifecycleMutation(filerConfig.BucketsPath, bucketName, lifecycleXML)},
})
-2
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"github.com/google/uuid"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
@@ -127,7 +126,6 @@ func (s *AdminServer) writeBucketPolicy(bucketName string, policyJSON []byte) er
bucketPath := filerConfig.BucketsPath + "/" + bucketName
resp, err := client.ObjectTransaction(context.Background(), &filer_pb.ObjectTransactionRequest{
LockKey: bucketPath,
RequestId: uuid.NewString(),
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + bucketPath,
Mutations: []*filer_pb.ObjectMutation{bucketPolicyMutation(filerConfig.BucketsPath, bucketName, policyJSON)},
})
+5 -1
View File
@@ -396,7 +396,11 @@ window.Dashboard = {
// Initialize event handlers
function initializeEventHandlers() {
// S3 Bucket Management
const createBucketForm = document.getElementById('createBucketForm');
if (createBucketForm) {
createBucketForm.addEventListener('submit', handleCreateBucket);
}
// Delete bucket buttons
document.addEventListener('click', function (e) {
if (e.target.closest('.delete-bucket-btn')) {
-174
View File
@@ -1,174 +0,0 @@
package topology
import (
"fmt"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
)
// A master that answered a capacity question for a disk type a node lacks
// published an empty DiskInfo of that type for the node: no capacity, no
// volumes, DiskId 0 -- the same id as the node's real first disk. Disks are
// keyed by node and disk id here, so whichever DiskInfo the map iteration
// visited last won disk 0: an SSD-only node sometimes looked like an empty
// HDD disk (and a balance destination for HDD volumes), and an HDD node
// sometimes lost its volumes behind an empty "ssd" disk.
func TestUpdateTopologyIgnoresEmptyDiskTypeEntries(t *testing.T) {
topo := &master_pb.TopologyInfo{
Id: "phantom_disk_types",
DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{{
Id: "rack1",
DataNodeInfos: []*master_pb.DataNodeInfo{
{
Id: "ssd-node",
Address: "ssd-node:8080",
DiskInfos: map[string]*master_pb.DiskInfo{
"ssd": {Type: "ssd", MaxVolumeCount: 200, VolumeCount: 1,
VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 7, DiskType: "ssd"}}},
"": {Type: ""}, // the empty entry the master made up
},
},
{
Id: "hdd-node",
Address: "hdd-node:8080",
DiskInfos: map[string]*master_pb.DiskInfo{
"": {Type: "", MaxVolumeCount: 100, VolumeCount: 1,
VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 3}}},
"ssd": {Type: "ssd"},
},
},
{
// An empty disk of unknown capacity with nothing else on the
// node is a disk, not a leftover: it must stay.
Id: "new-node",
Address: "new-node:8080",
DiskInfos: map[string]*master_pb.DiskInfo{"": {Type: ""}},
},
},
}},
}},
}
at := NewActiveTopology(0)
// Map iteration order is random; repeat so a shadowed disk would show.
for i := 0; i < 64; i++ {
if err := at.UpdateTopology(topo); err != nil {
t.Fatalf("UpdateTopology: %v", err)
}
for node, want := range map[string]string{"ssd-node": "ssd", "hdd-node": "", "new-node": ""} {
disks := at.GetNodeDisks(node)
if len(disks) != 1 || disks[0].DiskType != want || (node != "new-node" && disks[0].DiskInfo.MaxVolumeCount == 0) {
var got []string
for _, d := range disks {
got = append(got, "\""+d.DiskType+"\"")
}
t.Fatalf("pass %d: %s disks %v, want exactly its real %q disk", i, node, got, want)
}
}
}
if _, _, disks := CountTopologyResources(topo); disks != 3 {
t.Errorf("CountTopologyResources disks = %d, want 3 (a made-up empty entry is not a disk)", disks)
}
}
// The volume index is built from the per-disk map, so a shadowed disk 0 also
// hid its volumes: planners failed to find a volume on the server that holds
// it ("Could not find volume N on source server").
func TestUpdateTopologyKeepsVolumesBehindEmptyDiskTypeEntries(t *testing.T) {
topo := &master_pb.TopologyInfo{
Id: "phantom_disk_types_index",
DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{{
Id: "rack1",
DataNodeInfos: []*master_pb.DataNodeInfo{{
Id: "hdd-node",
Address: "hdd-node:8080",
DiskInfos: map[string]*master_pb.DiskInfo{
"": {Type: "", MaxVolumeCount: 100, VolumeCount: 1,
VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 3, Collection: "c1"}}},
"ssd": {Type: "ssd"},
},
}},
}},
}},
}
at := NewActiveTopology(0)
for i := 0; i < 64; i++ {
if err := at.UpdateTopology(topo); err != nil {
t.Fatalf("UpdateTopology: %v", err)
}
locations := at.GetVolumeLocations(3, "c1")
if len(locations) != 1 || locations[0].ServerID != "hdd-node" {
t.Fatalf("pass %d: volume 3 locations %+v, want it on hdd-node", i, locations)
}
}
}
// The made-up entry always carries DiskId 0, so it need not collide with a
// real disk: here each node's only usable disk is location 1 (location 0 is
// unavailable, so the volume server does not report it). The entry must still
// not count as a disk, nor reach planners as an empty disk of unknown
// capacity on a node that has no disk of its type.
func TestUpdateTopologyIgnoresEmptyDiskTypeEntriesAtAnyDiskId(t *testing.T) {
topo := &master_pb.TopologyInfo{
Id: "phantom_disk_types_no_collision",
DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{{
Id: "rack1",
DataNodeInfos: []*master_pb.DataNodeInfo{
{
Id: "ssd-node",
Address: "ssd-node:8080",
DiskInfos: map[string]*master_pb.DiskInfo{
"ssd": {Type: "ssd", DiskId: 1, MaxVolumeCount: 200, VolumeCount: 1,
MaxVolumeCountByDisk: map[uint32]int64{1: 200},
VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 7, DiskType: "ssd", DiskId: 1}}},
"": {Type: ""},
},
},
{
Id: "hdd-node",
Address: "hdd-node:8080",
DiskInfos: map[string]*master_pb.DiskInfo{
"": {Type: "", DiskId: 1, MaxVolumeCount: 100, VolumeCount: 1,
MaxVolumeCountByDisk: map[uint32]int64{1: 100},
VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 3, DiskId: 1}}},
"ssd": {Type: "ssd"},
},
},
},
}},
}},
}
at := NewActiveTopology(0)
for i := 0; i < 16; i++ {
if err := at.UpdateTopology(topo); err != nil {
t.Fatalf("UpdateTopology: %v", err)
}
for node, want := range map[string]string{"ssd-node": "ssd", "hdd-node": ""} {
disks := at.GetNodeDisks(node)
if len(disks) != 1 || disks[0].DiskType != want || disks[0].DiskID != 1 {
var got []string
for _, d := range disks {
got = append(got, fmt.Sprintf("%q@%d", d.DiskType, d.DiskID))
}
t.Fatalf("pass %d: %s disks %v, want only its real %q disk 1", i, node, got, want)
}
}
}
for _, disk := range at.GetDisksWithEffectiveCapacity(TaskTypeBalance, "", 1) {
if disk.DiskID == 0 {
t.Errorf("planners offered %s disk 0 (%q), which does not exist", disk.NodeID, disk.DiskType)
}
}
if _, _, disks := CountTopologyResources(topo); disks != 2 {
t.Errorf("CountTopologyResources disks = %d, want 2", disks)
}
}
+1 -37
View File
@@ -9,36 +9,6 @@ import (
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
)
// isEmptyDiskTypeEntry reports a DiskInfo with no capacity and nothing on it.
func isEmptyDiskTypeEntry(diskInfo *master_pb.DiskInfo) bool {
return diskInfo != nil &&
diskInfo.MaxVolumeCount <= 0 &&
diskInfo.VolumeCount == 0 &&
diskInfo.RemoteVolumeCount == 0 &&
len(diskInfo.VolumeInfos) == 0 &&
len(diskInfo.EcShardInfos) == 0 &&
len(diskInfo.MaxVolumeCountByDisk) == 0
}
// IsPhantomDiskTypeEntry reports whether node's DiskInfo of diskType is an
// empty entry on a node that has a disk of another type with capacity or
// data. Masters used to publish such an entry for every disk type a capacity
// check had asked about; keyed by node and disk id it can replace or pose as
// a real disk and be offered to planners as an empty destination of a type
// the node lacks. A node whose entries are all empty keeps them, as disks of
// unknown capacity.
func IsPhantomDiskTypeEntry(node *master_pb.DataNodeInfo, diskType string) bool {
if node == nil || !isEmptyDiskTypeEntry(node.DiskInfos[diskType]) {
return false
}
for otherType, other := range node.DiskInfos {
if otherType != diskType && other != nil && !isEmptyDiskTypeEntry(other) {
return true
}
}
return false
}
// CountTopologyResources counts datacenters, nodes, and disks in topology info
func CountTopologyResources(topologyInfo *master_pb.TopologyInfo) (dcCount, nodeCount, diskCount int) {
if topologyInfo == nil {
@@ -52,10 +22,7 @@ func CountTopologyResources(topologyInfo *master_pb.TopologyInfo) (dcCount, node
// DiskInfos is keyed by disk type, so same-type physical disks
// collapse into one entry. Count physical disks so the number
// matches the per-disk activeDisk map.
for diskType, diskInfo := range node.DiskInfos {
if IsPhantomDiskTypeEntry(node, diskType) {
continue
}
for _, diskInfo := range node.DiskInfos {
diskCount += len(diskInfo.SplitByPhysicalDisk())
}
}
@@ -115,9 +82,6 @@ func (at *ActiveTopology) UpdateTopology(topologyInfo *master_pb.TopologyInfo) e
// One activeDisk per physical disk_id: the master keys
// DiskInfos by disk type, so same-type disks must be split out.
for diskType, diskInfo := range nodeInfo.DiskInfos {
if IsPhantomDiskTypeEntry(nodeInfo, diskType) {
continue
}
perDiskInfos := diskInfo.SplitByPhysicalDisk()
for _, perDisk := range perDiskInfos {
disk := &activeDisk{
+8 -12
View File
@@ -256,13 +256,11 @@ templ Admin(data dash.AdminData) {
if tier.IsRemote {
<span class="text-muted">-</span>
} else {
<div class="d-flex align-items-center">
<div class="progress flex-grow-1" style="height: 20px; min-width: 48px;">
<div class="progress-bar" role="progressbar"
style={fmt.Sprintf("width: %d%%", tier.UsagePercent())}>
</div>
<div class="progress" style="height: 20px;">
<div class="progress-bar" role="progressbar"
style={fmt.Sprintf("width: %d%%", tier.UsagePercent())}>
{fmt.Sprintf("%d%%", tier.UsagePercent())}
</div>
<span class="ms-2 text-nowrap">{fmt.Sprintf("%d%%", tier.UsagePercent())}</span>
</div>
}
</td>
@@ -447,13 +445,11 @@ templ Admin(data dash.AdminData) {
<td>{vs.DataCenter}</td>
<td>{vs.Rack}</td>
<td>
<div class="d-flex align-items-center">
<div class="progress flex-grow-1" style="height: 20px; min-width: 48px;">
<div class="progress-bar" role="progressbar"
style={fmt.Sprintf("width: %d%%", calculatePercent(vs.Volumes, vs.MaxVolumes))}>
</div>
<div class="progress" style="height: 20px;">
<div class="progress-bar" role="progressbar"
style={fmt.Sprintf("width: %d%%", calculatePercent(vs.Volumes, vs.MaxVolumes))}>
{fmt.Sprintf("%d/%d", vs.Volumes, vs.MaxVolumes)}
</div>
<span class="ms-2 text-nowrap">{fmt.Sprintf("%d/%d", vs.Volumes, vs.MaxVolumes)}</span>
</div>
</td>
<td>
+43 -43
View File
@@ -342,33 +342,33 @@ func Admin(data dash.AdminData) templ.Component {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"d-flex align-items-center\"><div class=\"progress flex-grow-1\" style=\"height: 20px; min-width: 48px;\"><div class=\"progress-bar\" role=\"progressbar\" style=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"progress\" style=\"height: 20px;\"><div class=\"progress-bar\" role=\"progressbar\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %d%%", tier.UsagePercent()))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 262, Col: 123}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 261, Col: 119}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"></div></div><span class=\"ms-2 text-nowrap\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d%%", tier.UsagePercent()))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 265, Col: 132}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 262, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -390,7 +390,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var20 templ.SafeURL
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, "/cluster/mount-clients"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 288, Col: 74}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 286, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -403,7 +403,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalMountClients))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 296, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 294, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
@@ -421,7 +421,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var22 templ.SafeURL
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("//%s/ui/index.html", master.Address)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 327, Col: 121}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 325, Col: 121}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -434,7 +434,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(master.Address)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 328, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 326, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
@@ -467,7 +467,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.MasterNodes)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 361, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 359, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
@@ -480,7 +480,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.VolumeServers)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 369, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 367, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
@@ -493,7 +493,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.FilerNodes)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 377, Col: 84}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 375, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
@@ -506,7 +506,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.S3Nodes)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 385, Col: 81}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 383, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
@@ -519,7 +519,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.MessageBrokers)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 393, Col: 88}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 391, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
@@ -532,7 +532,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var29 templ.SafeURL
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, "/volumes"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 418, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 416, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
@@ -545,7 +545,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var30 templ.SafeURL
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, "/cluster"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 419, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 417, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
@@ -563,7 +563,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(vs.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 440, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 438, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
@@ -576,7 +576,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var32 templ.SafeURL
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("//%s/ui/index.html", vs.GetDisplayAddress())))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 442, Col: 129}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 440, Col: 129}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
@@ -589,7 +589,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(vs.GetDisplayAddress())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 443, Col: 75}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 441, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
@@ -602,7 +602,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(vs.DataCenter)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 447, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 445, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
@@ -615,39 +615,39 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(vs.Rack)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 448, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 446, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</td><td><div class=\"d-flex align-items-center\"><div class=\"progress flex-grow-1\" style=\"height: 20px; min-width: 48px;\"><div class=\"progress-bar\" role=\"progressbar\" style=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</td><td><div class=\"progress\" style=\"height: 20px;\"><div class=\"progress-bar\" role=\"progressbar\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %d%%", calculatePercent(vs.Volumes, vs.MaxVolumes)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 453, Col: 139}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 450, Col: 135}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\"></div></div><span class=\"ms-2 text-nowrap\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", vs.Volumes, vs.MaxVolumes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 456, Col: 131}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 451, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</span></div></td><td>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</div></div></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -659,7 +659,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", vs.EcShards))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 461, Col: 127}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 457, Col: 127}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
@@ -677,7 +677,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d vol", vs.EcVolumes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 463, Col: 119}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 459, Col: 119}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
@@ -701,7 +701,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(vs.DiskUsage))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 470, Col: 74}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 466, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
@@ -714,7 +714,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(vs.DiskCapacity))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 470, Col: 107}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 466, Col: 107}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
@@ -732,7 +732,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(vs.RemoteSize))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 473, Col: 137}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 469, Col: 137}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
@@ -761,7 +761,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var43 templ.SafeURL
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, "/filer"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 508, Col: 88}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 504, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
@@ -774,7 +774,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var44 templ.SafeURL
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, "/cluster"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 509, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 505, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
@@ -792,7 +792,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var45 templ.SafeURL
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("//%s", filer.Address)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 528, Col: 106}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 524, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
@@ -805,7 +805,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(filer.Address)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 529, Col: 66}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 525, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil {
@@ -818,7 +818,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var47 string
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(filer.DataCenter)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 533, Col: 65}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 529, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
if templ_7745c5c3_Err != nil {
@@ -831,7 +831,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var48 string
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(filer.Rack)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 534, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 530, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
if templ_7745c5c3_Err != nil {
@@ -844,7 +844,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var49 string
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(filer.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 535, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 531, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
if templ_7745c5c3_Err != nil {
@@ -868,7 +868,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var50 templ.SafeURL
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, "/cluster/s3"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 568, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 564, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
if templ_7745c5c3_Err != nil {
@@ -886,7 +886,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var51 string
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(s3Node.Address)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 585, Col: 63}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 581, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
if templ_7745c5c3_Err != nil {
@@ -899,7 +899,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var52 string
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(s3Node.DataCenter)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 586, Col: 66}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 582, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
if templ_7745c5c3_Err != nil {
@@ -912,7 +912,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var53 string
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(s3Node.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 587, Col: 97}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 583, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
if templ_7745c5c3_Err != nil {
@@ -936,7 +936,7 @@ func Admin(data dash.AdminData) templ.Component {
var templ_7745c5c3_Var54 string
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 611, Col: 81}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 607, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
if templ_7745c5c3_Err != nil {
+6
View File
@@ -280,6 +280,9 @@ func runFiler(cmd *Command, args []string) bool {
filerS3Options.bindIp = f.bindIp
}
filerS3Options.localFilerSocket = f.localSocket
if *f.dataCenter != "" && *filerS3Options.dataCenter == "" {
filerS3Options.dataCenter = f.dataCenter
}
// Set S3 metrics IP based on bind IP if not explicitly set
if *filerS3Options.metricsHttpIp == "" {
*filerS3Options.metricsHttpIp = *filerS3Options.bindIp
@@ -321,6 +324,9 @@ func runFiler(cmd *Command, args []string) bool {
if *filerSftpOptions.bindIp == "" {
filerSftpOptions.bindIp = f.bindIp
}
if *f.dataCenter != "" && *filerSftpOptions.dataCenter == "" {
filerSftpOptions.dataCenter = f.dataCenter
}
go func(delay time.Duration) {
time.Sleep(delay * time.Second)
filerSftpOptions.startSftpServer()
+5 -22
View File
@@ -501,7 +501,6 @@ func initMiniVolumeFlags() {
miniOptions.v.readMode = cmdMini.Flag.String("volume.readMode", "proxy", "[local|proxy|redirect] how to deal with non-local volume: 'not found|read in remote node|redirect volume location'.")
miniOptions.v.compactionMBPerSecond = cmdMini.Flag.Int("volume.compactionMBps", 0, "limit compaction speed in mega bytes per second")
miniOptions.v.maintenanceMBPerSecond = cmdMini.Flag.Int("volume.maintenanceMBps", 0, "limit maintenance IO rate in MB/s")
miniOptions.v.maxParallelVacuumCommits = cmdMini.Flag.Int("volume.compactionMaxParallelCommits", 0, "limit how many vacuum commits run at once on this server; queued commits hold no volume lock. 0 means unlimited")
miniOptions.v.fileSizeLimitMB = cmdMini.Flag.Int("volume.fileSizeLimitMB", 256, "limit file size to avoid out of memory")
miniOptions.v.ldbTimeout = cmdMini.Flag.Int64("volume.index.leveldbTimeout", 0, "alive time for leveldb")
miniOptions.v.concurrentUploadLimitMB = cmdMini.Flag.Int("volume.concurrentUploadLimitMB", 0, "limit total concurrent upload size")
@@ -1407,6 +1406,7 @@ func runMini(cmd *Command, args []string) bool {
miniFilerOptions.dataCenter = miniDataCenter
miniFilerOptions.rack = miniRack
miniS3Options.dataCenter = miniDataCenter
miniFilerOptions.disableHttp = miniDisableHttp
miniMasterOptions.disableHttp = miniDisableHttp
@@ -1627,30 +1627,13 @@ func startMiniServices(miniWhiteList []string, allServicesReady chan struct{}) {
if miniProgressBoard != nil {
miniProgressBoard.starting("Iceberg")
}
// Probe only when the catalog listener actually bound; otherwise the
// port may answer as an unrelated process and get advertised as the
// catalog.
if miniS3Options.icebergBound.Load() {
waitForServiceReady("Iceberg", *miniS3Options.portIceberg, bindIp)
} else {
if miniProgressBoard != nil {
miniProgressBoard.failed("Iceberg")
}
glog.Warningf("Iceberg catalog on port %d did not start; see S3 startup log", *miniS3Options.portIceberg)
}
waitForServiceReady("Iceberg", *miniS3Options.portIceberg, bindIp)
}
if miniS3Options.portLance != nil && *miniS3Options.portLance > 0 {
if miniProgressBoard != nil {
miniProgressBoard.starting("Lance")
}
if miniS3Options.lanceBound.Load() {
waitForServiceReady("Lance", *miniS3Options.portLance, bindIp)
} else {
if miniProgressBoard != nil {
miniProgressBoard.failed("Lance")
}
glog.Warningf("Lance namespace server on port %d did not start; see S3 startup log", *miniS3Options.portLance)
}
waitForServiceReady("Lance", *miniS3Options.portLance, bindIp)
}
}
if *miniEnableWebDAV {
@@ -1865,10 +1848,10 @@ func startMiniAdminWithWorker(allServicesReady chan struct{}) {
// the admin UI would otherwise print an endpoint nothing is listening on.
var icebergPort, lancePort int
if miniEnableS3 != nil && *miniEnableS3 {
if miniS3Options.portIceberg != nil && *miniS3Options.portIceberg > 0 && miniS3Options.icebergBound.Load() {
if miniS3Options.portIceberg != nil {
icebergPort = *miniS3Options.portIceberg
}
if miniS3Options.portLance != nil && *miniS3Options.portLance > 0 && miniS3Options.lanceBound.Load() {
if miniS3Options.portLance != nil {
lancePort = *miniS3Options.portLance
}
}
+6 -25
View File
@@ -12,7 +12,6 @@ import (
"runtime"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/gorilla/mux"
@@ -88,14 +87,6 @@ type S3Options struct {
// its HTTP/gRPC servers once the ctx is cancelled, in addition to on
// interrupt. Used by weed mini to orchestrate an ordered shutdown.
shutdownCtx context.Context
// icebergBound/lanceBound record whether the auxiliary catalog listeners
// bound their ports. The aux binds happen before the S3 listener serves,
// so once the S3 port answers these are final; mini reads them to probe
// and advertise only catalogs that are actually running instead of
// whatever process may hold the configured port.
icebergBound atomic.Bool
lanceBound atomic.Bool
}
func init() {
@@ -408,16 +399,12 @@ func (s3opt *S3Options) startS3Server() bool {
// Start Iceberg REST Catalog server if enabled
if *s3opt.portIceberg > 0 {
if httpS := s3opt.startIcebergServer(s3ApiServer); httpS != nil {
httpServers = append(httpServers, httpS)
}
httpServers = append(httpServers, s3opt.startIcebergServer(s3ApiServer))
}
// Start Lance Namespace server if enabled
if s3opt.portLance != nil && *s3opt.portLance > 0 {
if httpS := s3opt.startLanceServer(s3ApiServer); httpS != nil {
httpServers = append(httpServers, httpS)
}
httpServers = append(httpServers, s3opt.startLanceServer(s3ApiServer))
}
if runtime.GOOS != "windows" {
@@ -614,12 +601,8 @@ func (s3opt *S3Options) startIcebergServer(s3ApiServer *s3api.S3ApiServer) *http
icebergListener, icebergLocalListener, err := util.NewIpAndLocalListeners(
*s3opt.bindIp, *s3opt.portIceberg, time.Duration(*s3opt.idleTimeout)*time.Second)
if err != nil {
// The catalog is auxiliary to the S3 gateway; a port conflict must not
// take the whole server down.
glog.Errorf("Iceberg REST Catalog listener on %s error: %v", listenAddress, err)
return nil
glog.Fatalf("Iceberg REST Catalog listener on %s error: %v", listenAddress, err)
}
s3opt.icebergBound.Store(true)
glog.V(0).Infof("Start Iceberg REST Catalog Server at http://%s", listenAddress)
@@ -634,7 +617,7 @@ func (s3opt *S3Options) startIcebergServer(s3ApiServer *s3api.S3ApiServer) *http
}
go func() {
if err := httpS.Serve(icebergListener); err != nil && err != http.ErrServerClosed {
glog.Errorf("Iceberg REST Catalog Server Fail to serve: %v", err)
glog.Fatalf("Iceberg REST Catalog Server Fail to serve: %v", err)
}
}()
return httpS
@@ -660,10 +643,8 @@ func (s3opt *S3Options) startLanceServer(s3ApiServer *s3api.S3ApiServer) *http.S
lanceListener, lanceLocalListener, err := util.NewIpAndLocalListeners(
*s3opt.bindIp, *s3opt.portLance, time.Duration(*s3opt.idleTimeout)*time.Second)
if err != nil {
glog.Errorf("Lance Namespace listener on %s error: %v", listenAddress, err)
return nil
glog.Fatalf("Lance Namespace listener on %s error: %v", listenAddress, err)
}
s3opt.lanceBound.Store(true)
glog.V(0).Infof("Start Lance Namespace Server at http://%s", listenAddress)
@@ -677,7 +658,7 @@ func (s3opt *S3Options) startLanceServer(s3ApiServer *s3api.S3ApiServer) *http.S
}
go func() {
if err := httpS.Serve(lanceListener); err != nil && err != http.ErrServerClosed {
glog.Errorf("Lance Namespace Server Fail to serve: %v", err)
glog.Fatalf("Lance Namespace Server Fail to serve: %v", err)
}
}()
return httpS
-59
View File
@@ -1,59 +0,0 @@
package command
import (
"net"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api"
)
func holdPort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { l.Close() })
return l.Addr().(*net.TCPAddr).Port
}
// An occupied auxiliary catalog port must not take the S3 gateway down, must
// return no server for shutdown registration, and must not report the catalog
// as bound for mini's readiness checks.
func TestAuxListenerBindFailureIsNonFatal(t *testing.T) {
icebergPort := holdPort(t)
lancePort := holdPort(t)
bind := "127.0.0.1"
empty := ""
zero := 0
idle := 10
s3opt := &S3Options{
bindIp: &bind,
ip: &bind,
port: &zero,
portHttps: &zero,
portIceberg: &icebergPort,
portLance: &lancePort,
idleTimeout: &idle,
icebergCredentialRole: &empty,
externalUrl: &empty,
tlsPrivateKey: &empty,
}
s3a := &s3api.S3ApiServer{}
if s := s3opt.startIcebergServer(s3a); s != nil {
s.Close()
t.Error("startIcebergServer on an occupied port should return nil")
}
if s3opt.icebergBound.Load() {
t.Error("icebergBound must stay false when the listener failed to bind")
}
if s := s3opt.startLanceServer(s3a); s != nil {
s.Close()
t.Error("startLanceServer on an occupied port should return nil")
}
if s3opt.lanceBound.Load() {
t.Error("lanceBound must stay false when the listener failed to bind")
}
}
+1 -2
View File
@@ -92,8 +92,7 @@ func TestDeriveS3AdvertisedEndpoint(t *testing.T) {
},
}
for i := range tests {
tc := &tests[i]
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := tc.opt.deriveS3AdvertisedEndpoint()
if got != tc.want {
+2 -3
View File
@@ -149,7 +149,6 @@ func init() {
serverOptions.v.readMode = cmdServer.Flag.String("volume.readMode", "proxy", "[local|proxy|redirect] how to deal with non-local volume: 'not found|read in remote node|redirect volume location'.")
serverOptions.v.compactionMBPerSecond = cmdServer.Flag.Int("volume.compactionMBps", 0, "limit compaction speed in mega bytes per second")
serverOptions.v.maintenanceMBPerSecond = cmdServer.Flag.Int("volume.maintenanceMBps", 0, "limit maintenance (replication / balance) IO rate in MB/s. Unset is 0, no limitation.")
serverOptions.v.maxParallelVacuumCommits = cmdServer.Flag.Int("volume.compactionMaxParallelCommits", 0, "limit how many vacuum commits run at once on this server; queued commits hold no volume lock. 0 means unlimited")
serverOptions.v.fileSizeLimitMB = cmdServer.Flag.Int("volume.fileSizeLimitMB", 256, "limit file size to avoid out of memory")
serverOptions.v.ldbTimeout = cmdServer.Flag.Int64("volume.index.leveldbTimeout", 0, "alive time for leveldb (default to 0). If leveldb of volume is not accessed in ldbTimeout hours, it will be off loaded to reduce opened files and memory consumption.")
serverOptions.v.concurrentUploadLimitMB = cmdServer.Flag.Int("volume.concurrentUploadLimitMB", 0, "limit total concurrent upload size, 0 means unlimited")
@@ -175,7 +174,6 @@ func init() {
s3Options.icebergCredentialDuration = cmdServer.Flag.Int("s3.iceberg.credentialDurationSeconds", 3600, "lifetime of credentials vended by the Iceberg catalog")
s3Options.domainName = cmdServer.Flag.String("s3.domainName", "", "suffix of the host name in comma separated list, {bucket}.{domainName}")
s3Options.allowedOrigins = cmdServer.Flag.String("s3.allowedOrigins", "*", "comma separated list of allowed origins")
s3Options.dataCenter = cmdServer.Flag.String("s3.dataCenter", "", "prefer to read and write to volumes in this data center")
s3Options.tlsPrivateKey = cmdServer.Flag.String("s3.key.file", "", "path to the TLS private key file")
s3Options.tlsCertificate = cmdServer.Flag.String("s3.cert.file", "", "path to the TLS certificate file")
s3Options.tlsCACertificate = cmdServer.Flag.String("s3.cacert.file", "", "path to the TLS CA certificate file")
@@ -211,7 +209,6 @@ func init() {
sftpOptions.clientAliveCountMax = cmdServer.Flag.Int("sftp.clientAliveCountMax", 3, "maximum number of missed keep-alive messages before disconnecting")
sftpOptions.userStoreFile = cmdServer.Flag.String("sftp.userStoreFile", "", "path to JSON file containing user credentials and permissions")
sftpOptions.trustedUserCAKeysFile = cmdServer.Flag.String("sftp.trustedUserCAKeysFile", "", "path to a file with trusted user CA public keys (OpenSSH authorized_keys format); required when 'certificate' is in -sftp.authMethods")
sftpOptions.dataCenter = cmdServer.Flag.String("sftp.dataCenter", "", "prefer to read and write to volumes in this data center")
sftpOptions.localSocket = cmdServer.Flag.String("sftp.localSocket", "", "default to /tmp/seaweedfs-sftp-<port>.sock")
iamOptions.port = cmdServer.Flag.Int("iam.port", 8111, "iam server http listen port")
@@ -339,6 +336,8 @@ func runServer(cmd *Command, args []string) bool {
filerOptions.rack = serverRack
mqBrokerOptions.dataCenter = serverDataCenter
mqBrokerOptions.rack = serverRack
s3Options.dataCenter = serverDataCenter
sftpOptions.dataCenter = serverDataCenter
*filerOptions.disableHttp = *filerOptions.disableHttp || *serverDisableHttp
masterOptions.disableHttp = serverDisableHttp
-12
View File
@@ -11,15 +11,3 @@ func TestServerFilerDisableHttpFlag(t *testing.T) {
t.Fatalf("-filer.disableHttp default = %q, want false", flag.DefValue)
}
}
func TestServerGatewayDataCenterFlags(t *testing.T) {
for _, name := range []string{"s3.dataCenter", "sftp.dataCenter"} {
flag := cmdServer.Flag.Lookup(name)
if flag == nil {
t.Fatalf("weed server does not expose -%s", name)
}
if flag.DefValue != "" {
t.Fatalf("-%s default = %q, want empty", name, flag.DefValue)
}
}
}
-3
View File
@@ -61,7 +61,6 @@ type VolumeServerOptions struct {
memProfile *string
compactionMBPerSecond *int
maintenanceMBPerSecond *int
maxParallelVacuumCommits *int
fileSizeLimitMB *int
concurrentUploadLimitMB *int
concurrentDownloadLimitMB *int
@@ -216,7 +215,6 @@ func init() {
v.memProfile = cmdVolume.Flag.String("memprofile", "", "memory profile output file")
v.compactionMBPerSecond = cmdVolume.Flag.Int("compactionMBps", 0, "limit background compaction or copying speed in mega bytes per second")
v.maintenanceMBPerSecond = cmdVolume.Flag.Int("maintenanceMBps", 0, "limit maintenance (replication / balance) IO rate in MB/s. Unset is 0, no limitation.")
v.maxParallelVacuumCommits = cmdVolume.Flag.Int("compactionMaxParallelCommits", 0, "limit how many vacuum commits run at once on this server; queued commits hold no volume lock. 0 means unlimited")
v.fileSizeLimitMB = cmdVolume.Flag.Int("fileSizeLimitMB", 256, "limit file size to avoid out of memory")
v.ldbTimeout = cmdVolume.Flag.Int64("index.leveldbTimeout", 0, "alive time for leveldb (default to 0). If leveldb of volume is not accessed in ldbTimeout hours, it will be off loaded to reduce opened files and memory consumption.")
v.concurrentUploadLimitMB = cmdVolume.Flag.Int("concurrentUploadLimitMB", 0, "limit total concurrent upload size, 0 means unlimited")
@@ -449,7 +447,6 @@ func (v VolumeServerOptions) startVolumeServer(volumeFolders, maxVolumeCounts, v
*v.fixJpgOrientation, *v.readMode,
*v.compactionMBPerSecond,
*v.maintenanceMBPerSecond,
*v.maxParallelVacuumCommits,
*v.fileSizeLimitMB,
int64(*v.concurrentUploadLimitMB)*1024*1024,
int64(*v.concurrentDownloadLimitMB)*1024*1024,
+1 -4
View File
@@ -135,10 +135,7 @@ func (c *ecShardUnmounter) doShardsUnmount() error {
for _, ts := range liveShards {
if ts.ShardID == ps.ShardID {
if ps.NodeAddress == "" || ps.NodeAddress == ts.NodeAddress {
// One node is one unmount target no matter how many of
// its disks hold the shard; ambiguity means the shard
// lives on more than one node.
if result != nil && result.NodeAddress != ts.NodeAddress {
if result != nil {
return fmt.Errorf("shard %v is ambiguous", ps)
}
result = ts
-81
View File
@@ -1,10 +1,8 @@
package ec
import (
"bytes"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/stretchr/testify/assert"
)
@@ -102,82 +100,3 @@ func TestEcShardStringRoundTrips(t *testing.T) {
assert.Equal(t, []*ShardRef{s}, got)
}
}
func unmountTestTopology(nodes map[string]map[string][]uint32) *master_pb.TopologyInfo {
var dataNodes []*master_pb.DataNodeInfo
for addr, disks := range nodes {
diskInfos := map[string]*master_pb.DiskInfo{}
for disk, shardIds := range disks {
si := erasure_coding.NewShardsInfo()
for _, sid := range shardIds {
si.Set(erasure_coding.NewShardInfo(erasure_coding.ShardId(sid), 1))
}
diskInfos[disk] = &master_pb.DiskInfo{
Type: "hdd",
MaxVolumeCount: 100,
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{{
Id: 100,
Collection: "c1",
EcIndexBits: si.Bitmap(),
ShardSizes: si.SizesInt64(),
DiskType: "hdd",
}},
}
}
dataNodes = append(dataNodes, &master_pb.DataNodeInfo{
Id: addr,
Address: addr,
DiskInfos: diskInfos,
})
}
return &master_pb.TopologyInfo{DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{{Id: "r1", DataNodeInfos: dataNodes}},
}}}
}
// A shard held on two disks of one node is a single unmount target: the
// unmount RPC addresses the node, not the disk.
func TestUnmountResolvesShardDuplicatedOnOneNode(t *testing.T) {
topo := unmountTestTopology(map[string]map[string][]uint32{
"vs3:8080": {"hdd:0": {12}, "hdd:1": {12}},
"vs4:8080": {"hdd:0": {13}},
})
for _, shards := range [][]*ShardRef{
{{ShardID: 12}},
{{ShardID: 12, NodeAddress: "vs3:8080"}},
} {
var out bytes.Buffer
err := UnmountShards(&Env{}, &out, ShardUnmountRequest{
Topology: topo,
VolumeID: 100,
Shards: shards,
})
assert.NoError(t, err, "shard %+v", shards)
assert.Contains(t, out.String(), "Will unmount 1 shard(s)")
}
}
func TestUnmountStillRejectsShardOnTwoNodes(t *testing.T) {
topo := unmountTestTopology(map[string]map[string][]uint32{
"vs3:8080": {"hdd:0": {12}},
"vs4:8080": {"hdd:0": {12}},
})
var out bytes.Buffer
err := UnmountShards(&Env{}, &out, ShardUnmountRequest{
Topology: topo,
VolumeID: 100,
Shards: []*ShardRef{{ShardID: 12}},
})
assert.ErrorContains(t, err, "ambiguous")
out.Reset()
err = UnmountShards(&Env{}, &out, ShardUnmountRequest{
Topology: topo,
VolumeID: 100,
Shards: []*ShardRef{{ShardID: 12, NodeAddress: "vs3:8080"}},
})
assert.NoError(t, err)
}
-170
View File
@@ -3,7 +3,6 @@ package filer
import (
"context"
"errors"
"os"
"sync"
"testing"
"time"
@@ -709,172 +708,3 @@ func TestPendingDeleteRecordKeepsItsTime(t *testing.T) {
t.Fatalf("MpDl = %v, %v, %v; want 100", ts, found, err)
}
}
// A replayed directory delete must remove the local subtree too: the origin
// only emits the directory's own key, and children the peer holds that the
// origin never listed would orphan under the missing parent.
func TestReplayDirectoryDeleteSweepsLocalChildren(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
ctx := context.Background()
put := func(path string, isDir bool) {
e := &Entry{FullPath: util.FullPath(path)}
if isDir {
e.Attr.Mode = os.ModeDir
}
if err := store.InsertEntry(ctx, e); err != nil {
t.Fatalf("insert %s: %v", path, err)
}
}
put("/d", true)
put("/d/a.txt", false)
put("/d/sub", true)
put("/d/sub/nested.txt", false)
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
DeleteChunks: false,
},
TsNs: time.Now().UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
for _, path := range []string{"/d", "/d/a.txt", "/d/sub", "/d/sub/nested.txt"} {
if _, err := store.FindEntry(ctx, util.FullPath(path)); !errors.Is(err, filer_pb.ErrNotFound) {
t.Fatalf("expected %s gone after replayed directory delete, got %v", path, err)
}
}
}
func TestReplayFileDeleteKeepsSiblingEntries(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
ctx := context.Background()
dir := &Entry{FullPath: "/d", Attr: Attr{Mode: os.ModeDir}}
if err := store.InsertEntry(ctx, dir); err != nil {
t.Fatal(err)
}
for _, name := range []string{"gone.txt", "kept.txt"} {
if err := store.InsertEntry(ctx, &Entry{FullPath: util.NewFullPath("/d", name)}); err != nil {
t.Fatal(err)
}
}
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/d",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "gone.txt"},
},
TsNs: time.Now().UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
if _, err := store.FindEntry(ctx, "/d/gone.txt"); !errors.Is(err, filer_pb.ErrNotFound) {
t.Fatalf("expected deleted file gone, got %v", err)
}
if _, err := store.FindEntry(ctx, "/d/kept.txt"); err != nil {
t.Fatalf("expected sibling kept, got %v", err)
}
}
func TestReplayDirectoryDeleteKeepsNewerChildren(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
ctx := context.Background()
eventTs := time.Now().UnixNano()
put := func(path string, isDir bool, mtime time.Time) {
e := &Entry{FullPath: util.FullPath(path), Attr: Attr{Mtime: mtime}}
if isDir {
e.Attr.Mode = os.ModeDir
}
if err := store.InsertEntry(ctx, e); err != nil {
t.Fatalf("insert %s: %v", path, err)
}
}
old := time.Unix(1600000000, 0)
newer := time.Unix(0, eventTs).Add(time.Second)
put("/d", true, old)
put("/d/old.txt", false, old)
put("/d/sub", true, old)
put("/d/sub/newer.txt", false, newer)
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
DeleteChunks: false,
},
TsNs: eventTs,
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
if _, err := store.FindEntry(ctx, "/d/old.txt"); !errors.Is(err, filer_pb.ErrNotFound) {
t.Fatalf("older child should be swept, got %v", err)
}
for _, path := range []string{"/d", "/d/sub", "/d/sub/newer.txt"} {
if _, err := store.FindEntry(ctx, util.FullPath(path)); err != nil {
t.Fatalf("newer child and its ancestors should survive: %s: %v", path, err)
}
}
}
// A directory kept for newer descendants still records the delete: a stale
// create replayed later for another child must be fenced by it.
func TestReplayKeptDirectoryDeleteFencesStaleCreates(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
ctx := context.Background()
eventTs := time.Now().UnixNano()
old := time.Unix(1600000000, 0)
newer := time.Unix(0, eventTs).Add(time.Second)
put := func(path string, isDir bool, mtime time.Time) {
e := &Entry{FullPath: util.FullPath(path), Attr: Attr{Mtime: mtime}}
if isDir {
e.Attr.Mode = os.ModeDir
}
if err := store.InsertEntry(ctx, e); err != nil {
t.Fatalf("insert %s: %v", path, err)
}
}
put("/d", true, old)
put("/d/newer.txt", false, newer)
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
},
TsNs: eventTs,
}
if err := f.Replay(event); err != nil {
t.Fatalf("replay: %v", err)
}
if _, err := store.FindEntry(ctx, "/d"); err != nil {
t.Fatalf("newer child should keep the directory: %v", err)
}
stale := &filer_pb.SubscribeMetadataResponse{
Directory: "/d",
EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "late.txt"},
},
TsNs: eventTs - int64(time.Minute),
}
if err := f.Replay(stale); err != nil {
t.Fatalf("replay stale create: %v", err)
}
if _, err := store.FindEntry(ctx, "/d/late.txt"); !errors.Is(err, filer_pb.ErrNotFound) {
t.Fatal("stale create resurrected a child under a directory the origin deleted")
}
}
+3 -113
View File
@@ -2,7 +2,6 @@ package filer
import (
"context"
"fmt"
"slices"
"strings"
@@ -79,26 +78,10 @@ func (f *Filer) Replay(resp *filer_pb.SubscribeMetadataResponse) error {
return err
}
if !newer {
// A directory delete on the origin removed the whole subtree, but
// only the directory's own key replicates. Sweep local children the
// origin never listed, or they orphan under a missing parent.
keepDir := false
if message.OldEntry.IsDirectory {
var err error
keepDir, err = f.deleteReplayedFolderChildren(ctx, oldPath, message.DeleteChunks, resp.TsNs)
if err != nil {
return err
}
glog.V(4).Infof("deleting %v", oldPath)
if err := f.Store.DeleteEntry(ctx, oldPath); err != nil {
return err
}
if !keepDir {
glog.V(4).Infof("deleting %v", oldPath)
if err := f.Store.DeleteEntry(ctx, oldPath); err != nil {
return err
}
}
// The origin deleted the directory even when a newer descendant
// keeps its entry here — recording the delete fences stale child
// creates that would otherwise resurrect under it.
f.notePathChange(oldPath, resp.TsNs, true)
}
}
@@ -121,99 +104,6 @@ func (f *Filer) Replay(resp *filer_pb.SubscribeMetadataResponse) error {
return nil
}
// deleteReplayedFolderChildren removes local entries under dir, and queues
// the chunks of removed files for deletion. Children this filer committed
// after the event stay — a newer local write wins over a delayed delete —
// along with the directories that hold them.
func (f *Filer) deleteReplayedFolderChildren(ctx context.Context, dir util.FullPath, deleteChunks bool, tsNs int64) (bool, error) {
sweep := &folderSweep{chunksByPath: make(map[util.FullPath][]*filer_pb.FileChunk)}
kept, err := f.sweepReplayedFolder(ctx, dir, deleteChunks, tsNs, sweep)
// Flush what was collected even on error: the entries already deleted
// cannot be listed again on retry, so their chunks must be queued now.
sweep.flush(ctx, f)
if err != nil {
return false, err
}
return kept, nil
}
// folderSweep accumulates the chunks of removed children for deletion. It
// flushes in batches so a large subtree cannot hold every chunk reference in
// memory at once, and so chunk queueing overlaps the listing work.
type folderSweep struct {
chunksByPath map[util.FullPath][]*filer_pb.FileChunk
count int
}
const folderSweepChunkBatch = 4096
func (s *folderSweep) add(path util.FullPath, chunks []*filer_pb.FileChunk) {
s.chunksByPath[path] = append(s.chunksByPath[path], chunks...)
s.count += len(chunks)
}
func (s *folderSweep) flush(ctx context.Context, f *Filer) {
for path, chunks := range s.chunksByPath {
f.DeleteChunks(ctx, path, chunks)
}
s.chunksByPath = make(map[util.FullPath][]*filer_pb.FileChunk)
s.count = 0
}
// sweepReplayedFolder deletes every child of dir whose local commit is not
// newer than the event, working subtree-first so nested entries go too.
// It reports whether a newer descendant keeps dir alive — the caller then
// keeps the dir entry itself.
func (f *Filer) sweepReplayedFolder(ctx context.Context, dir util.FullPath, deleteChunks bool, tsNs int64, sweep *folderSweep) (kept bool, err error) {
lastFileName := ""
for {
entries, _, lerr := f.ListDirectoryEntries(ctx, dir, lastFileName, false, PaginationSize, "", "", "")
if lerr != nil {
return false, fmt.Errorf("list folder %s: %w", dir, lerr)
}
for _, sub := range entries {
lastFileName = sub.Name()
childKept := false
if tsNs > 0 {
localTsNs, tsErr := f.localCommitTsNs(ctx, sub.FullPath)
if tsErr != nil {
return false, tsErr
}
childKept = localTsNs > tsNs
}
if !childKept && sub.IsDirectory() {
childKept, err = f.sweepReplayedFolder(ctx, sub.FullPath, deleteChunks, tsNs, sweep)
if err != nil {
return false, err
}
}
if childKept {
kept = true
continue
}
if derr := f.Store.DeleteEntry(ctx, sub.FullPath); derr != nil {
return false, fmt.Errorf("delete %s: %w", sub.FullPath, derr)
}
f.notePathChange(sub.FullPath, tsNs, true)
// hard-linked chunks are owned by the shared inode record; the
// origin's own events decide their deletion. A directory entry
// can still carry chunks of its own — an S3 child promotes an
// existing file to a directory without dropping them — so the
// entry's chunks are keyed to its own path whatever it is.
if deleteChunks && len(sub.HardLinkId) == 0 && len(sub.GetChunks()) > 0 {
sweep.add(sub.FullPath, sub.GetChunks())
if sweep.count >= folderSweepChunkBatch {
sweep.flush(ctx, f)
}
}
}
if len(entries) < PaginationSize {
break
}
}
return kept, nil
}
// replayBlockedByLocal reports whether the local store already holds a
// version of path committed after the event's timestamp. Lookup failures are
// propagated: a replayed change must not be applied on the guess that the
-117
View File
@@ -1,117 +0,0 @@
package filer
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
const (
// ZipExtractMaxMembers bounds how many members one archive write fans out to.
ZipExtractMaxMembers = 100000
// ZipExtractMaxBytes bounds the total uncompressed bytes one archive may declare.
ZipExtractMaxBytes = int64(1) << 40 // 1 TiB
// ZipExtractMaxArchiveBytes bounds the compressed bytes one archive may
// spool to local disk when the caller passes no tighter limit. It matches
// the TUS default max upload size.
ZipExtractMaxArchiveBytes = int64(5) << 30 // 5 GiB
zipSpoolMemoryLimit = 32 << 20 // 32 MiB; larger archives spool to a temp file
)
// ErrZipTooLarge marks a spool failure caused by the archive exceeding the
// caller's size limit, so handlers can answer 4xx instead of 5xx.
var ErrZipTooLarge = errors.New("zip archive too large")
// SpoolZipReader buffers r into a seekable source for zip.NewReader: memory for
// small archives, a temp file otherwise. At most maxArchiveBytes are buffered;
// a non-positive limit falls back to ZipExtractMaxArchiveBytes. The returned
// cleanup releases the spool.
func SpoolZipReader(r io.Reader, maxArchiveBytes int64) (ra io.ReaderAt, n int64, cleanup func(), err error) {
nop := func() {}
if maxArchiveBytes <= 0 {
maxArchiveBytes = ZipExtractMaxArchiveBytes
}
head, err := io.ReadAll(io.LimitReader(r, zipSpoolMemoryLimit+1))
if err != nil {
return nil, 0, nop, err
}
if int64(len(head)) > maxArchiveBytes {
return nil, 0, nop, fmt.Errorf("%w: exceeds %d bytes", ErrZipTooLarge, maxArchiveBytes)
}
if int64(len(head)) <= zipSpoolMemoryLimit {
return bytes.NewReader(head), int64(len(head)), nop, nil
}
f, err := os.CreateTemp("", "seaweedfs-extract-*.zip")
if err != nil {
return nil, 0, nop, err
}
cleanup = func() {
name := f.Name()
f.Close()
os.Remove(name)
}
if _, err = f.Write(head); err == nil {
var rest int64
// One byte past the limit so an oversized archive errors out instead
// of being silently truncated.
if rest, err = io.Copy(f, io.LimitReader(r, maxArchiveBytes+1-int64(len(head)))); err == nil {
n = int64(len(head)) + rest
}
}
if err == nil && n > maxArchiveBytes {
err = fmt.Errorf("%w: exceeds %d bytes", ErrZipTooLarge, maxArchiveBytes)
}
if err != nil {
cleanup()
return nil, 0, nop, err
}
return f, n, cleanup, nil
}
// CheckZipLimits enforces the member-count and total-size caps on an opened archive.
func CheckZipLimits(zr *zip.Reader) error {
if len(zr.File) > ZipExtractMaxMembers {
return fmt.Errorf("zip archive has %d members, limit %d", len(zr.File), ZipExtractMaxMembers)
}
var total uint64
for _, f := range zr.File {
if f.UncompressedSize64 > uint64(ZipExtractMaxBytes)-total {
return fmt.Errorf("zip archive uncompressed size exceeds %d bytes", ZipExtractMaxBytes)
}
total += f.UncompressedSize64
}
return nil
}
// SanitizeZipMemberName converts a zip member name into a relative filer path.
// ok=false means the member must be skipped: absolute or escaping paths,
// non-regular files such as symlinks and devices.
func SanitizeZipMemberName(f *zip.File) (name string, ok bool) {
mode := f.FileInfo().Mode()
if mode&(os.ModeSymlink|os.ModeDevice|os.ModeNamedPipe|os.ModeSocket) != 0 {
return "", false
}
name = strings.TrimSuffix(strings.ReplaceAll(f.Name, "\\", "/"), "/")
if name == "" || strings.HasPrefix(name, "/") {
return "", false
}
for _, seg := range strings.Split(name, "/") {
if seg == "" || seg == "." || seg == ".." || strings.ContainsRune(seg, '\x00') {
return "", false
}
}
// filepath.IsLocal is the check static analysis recognizes: no element may
// be "." or "..", the path may not be absolute or empty, and on Windows it
// may not be a reserved name.
if !filepath.IsLocal(name) {
return "", false
}
return name, true
}
-154
View File
@@ -1,154 +0,0 @@
package filer
import (
"archive/zip"
"bytes"
"io"
"math"
"os"
"strings"
"testing"
"time"
)
func zipFile(t *testing.T, name string, mode os.FileMode) *zip.File {
t.Helper()
h := &zip.FileHeader{Name: name, Method: zip.Store, Modified: time.Unix(1700000000, 0)}
h.SetMode(mode)
return &zip.File{FileHeader: *h}
}
func TestSanitizeZipMemberName(t *testing.T) {
tests := []struct {
name string
mode os.FileMode
want string
ok bool
}{
{"a.png", 0644, "a.png", true},
{"dir/b.txt", 0644, "dir/b.txt", true},
{"dir\\b.txt", 0644, "dir/b.txt", true},
{"dir/", os.ModeDir | 0755, "dir", true},
{"../escape.txt", 0644, "", false},
{"a/../b.txt", 0644, "", false},
{"./dot.txt", 0644, "", false},
{"/abs.txt", 0644, "", false},
{"a//b.txt", 0644, "", false},
{"", 0644, "", false},
{"link.txt", os.ModeSymlink | 0777, "", false},
{"dev", os.ModeDevice | 0666, "", false},
}
for _, tt := range tests {
got, ok := SanitizeZipMemberName(zipFile(t, tt.name, tt.mode))
if ok != tt.ok || got != tt.want {
t.Errorf("SanitizeZipMemberName(%q, %v) = (%q, %v), want (%q, %v)", tt.name, tt.mode, got, ok, tt.want, tt.ok)
}
}
}
func TestSpoolZipReaderMemory(t *testing.T) {
data := []byte(strings.Repeat("x", 1024))
ra, n, cleanup, err := SpoolZipReader(bytes.NewReader(data), 0)
if err != nil {
t.Fatalf("spool: %v", err)
}
defer cleanup()
if n != int64(len(data)) {
t.Fatalf("size = %d, want %d", n, len(data))
}
if _, isFile := ra.(*os.File); isFile {
t.Fatal("small archive should spool to memory")
}
buf := make([]byte, 4)
if _, err := ra.ReadAt(buf, 0); err != nil || string(buf) != "xxxx" {
t.Fatalf("ReadAt = %q, %v", buf, err)
}
}
func TestSpoolZipReaderTempFile(t *testing.T) {
data := bytes.Repeat([]byte("y"), zipSpoolMemoryLimit+1)
ra, n, cleanup, err := SpoolZipReader(bytes.NewReader(data), 0)
if err != nil {
t.Fatalf("spool: %v", err)
}
f, isFile := ra.(*os.File)
if !isFile {
cleanup()
t.Fatal("large archive should spool to a temp file")
}
name := f.Name()
cleanup()
if _, err := os.Stat(name); !os.IsNotExist(err) {
t.Fatalf("temp file %s still exists after cleanup: %v", name, err)
}
if n != int64(len(data)) {
t.Fatalf("size = %d, want %d", n, len(data))
}
}
func TestSpoolZipReaderLimit(t *testing.T) {
data := []byte(strings.Repeat("x", 2048))
if _, _, _, err := SpoolZipReader(bytes.NewReader(data), 1024); err == nil {
t.Fatal("expected limit error for in-memory archive")
}
if _, _, cleanup, err := SpoolZipReader(bytes.NewReader(bytes.Repeat([]byte("y"), zipSpoolMemoryLimit+1024)), zipSpoolMemoryLimit+512); err == nil {
t.Fatal("expected limit error for spooled archive")
} else {
cleanup()
}
}
func TestCheckZipLimits(t *testing.T) {
over := &zip.Reader{File: make([]*zip.File, ZipExtractMaxMembers+1)}
for i := range over.File {
over.File[i] = &zip.File{}
}
if err := CheckZipLimits(over); err == nil {
t.Fatal("expected member count limit error")
}
big := &zip.Reader{File: []*zip.File{{}}}
big.File[0].UncompressedSize64 = uint64(ZipExtractMaxBytes) + 1
if err := CheckZipLimits(big); err == nil {
t.Fatal("expected total size limit error")
}
overflow := &zip.Reader{File: []*zip.File{{}, {}}}
overflow.File[0].UncompressedSize64 = math.MaxUint64
overflow.File[1].UncompressedSize64 = math.MaxUint64
if err := CheckZipLimits(overflow); err == nil {
t.Fatal("expected total size overflow error")
}
ok := &zip.Reader{File: []*zip.File{{}, {}}}
if err := CheckZipLimits(ok); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSpoolZipReaderRoundTrip(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, _ := zw.Create("a/b.txt")
w.Write([]byte("hello"))
zw.Close()
ra, n, cleanup, err := SpoolZipReader(bytes.NewReader(buf.Bytes()), 0)
if err != nil {
t.Fatalf("spool: %v", err)
}
defer cleanup()
zr, err := zip.NewReader(ra, n)
if err != nil {
t.Fatalf("open zip: %v", err)
}
rc, err := zr.File[0].Open()
if err != nil {
t.Fatalf("open member: %v", err)
}
defer rc.Close()
data, _ := io.ReadAll(rc)
if string(data) != "hello" {
t.Fatalf("member content = %q", data)
}
}
+2 -12
View File
@@ -143,14 +143,8 @@ var baseS3ActionMap = map[string]string{
"UploadPart": s3_constants.ACTION_WRITE,
"CompleteMultipartUpload": s3_constants.ACTION_WRITE,
"AbortMultipartUpload": s3_constants.ACTION_WRITE,
"ListMultipartUploads": s3_constants.ACTION_LIST_MULTIPART_UPLOADS,
"ListParts": s3_constants.ACTION_LIST_PARTS,
// AWS IAM names for the multipart listing operations get their own
// action names so a policy grant cannot widen into a general Read
// or Write grant; VerifyActionPermission matches them against the
// resolved request action.
"ListBucketMultipartUploads": s3_constants.ACTION_LIST_MULTIPART_UPLOADS,
"ListMultipartUploadParts": s3_constants.ACTION_LIST_PARTS,
"ListMultipartUploads": s3_constants.ACTION_WRITE,
"ListParts": s3_constants.ACTION_WRITE,
// Retention and legal hold operations
"GetObjectRetention": s3_constants.ACTION_GET_OBJECT_RETENTION,
"PutObjectRetention": s3_constants.ACTION_PUT_OBJECT_RETENTION,
@@ -215,10 +209,6 @@ func MapToIdentitiesAction(action string) string {
return "PutBucketPolicy"
case s3_constants.ACTION_DELETE_BUCKET_POLICY:
return "DeleteBucketPolicy"
case s3_constants.ACTION_LIST_MULTIPART_UPLOADS:
return "ListBucketMultipartUploads"
case s3_constants.ACTION_LIST_PARTS:
return "ListMultipartUploadParts"
default:
return ""
}
+2 -4
View File
@@ -14,7 +14,6 @@ import (
"sync"
"time"
"github.com/google/uuid"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
@@ -564,9 +563,8 @@ func (f *FilerOIDCProviderStore) UpdateProvider(ctx context.Context, filerAddres
func (f *FilerOIDCProviderStore) deleteProviderEntryIfUnchanged(ctx context.Context, client filer_pb.SeaweedFilerClient, entry *filer_pb.Entry) (bool, error) {
fullPath := f.basePath + "/" + entry.Name
resp, err := client.ObjectTransaction(ctx, &filer_pb.ObjectTransactionRequest{
LockKey: fullPath,
RequestId: uuid.NewString(),
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + fullPath,
LockKey: fullPath,
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + fullPath,
Condition: &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{{
Kind: filer_pb.WriteCondition_IF_ENTRY_EQUAL, ExpectedEntry: entry,
}}},
+2 -4
View File
@@ -11,7 +11,6 @@ import (
"sync"
"time"
"github.com/google/uuid"
"github.com/karlseguin/ccache/v2"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
@@ -381,9 +380,8 @@ func (f *FilerRoleStore) UpdateRole(ctx context.Context, filerAddress string, ro
func (f *FilerRoleStore) deleteRoleEntryIfUnchanged(ctx context.Context, client filer_pb.SeaweedFilerClient, entry *filer_pb.Entry) (bool, error) {
fullPath := f.basePath + "/" + entry.Name
resp, err := client.ObjectTransaction(ctx, &filer_pb.ObjectTransactionRequest{
LockKey: fullPath,
RequestId: uuid.NewString(),
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + fullPath,
LockKey: fullPath,
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + fullPath,
Condition: &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{{
Kind: filer_pb.WriteCondition_IF_ENTRY_EQUAL, ExpectedEntry: entry,
}}},
+3 -6
View File
@@ -140,12 +140,6 @@ func (wfs *WFS) invalidateOpenMtimeCache(inode uint64) {
}
func (wfs *WFS) Release(cancel <-chan struct{}, in *fuse.ReleaseIn) {
// Drop the file description's flock locks first: close() frees them
// regardless of how writeback fares, and a flush stuck on an unreachable
// filer must not pin a dead holder's lock in the table.
if in.ReleaseFlags&fuse.FUSE_RELEASE_FLOCK_UNLOCK != 0 {
wfs.releaseFlockOwner(in.NodeId, in.LockOwner)
}
// Flush is usually sent before Release, but the FUSE protocol does not
// guarantee it. Route every Release through doFlush so a dirty handle
// (e.g. a deferred create with no intervening Flush) is not dropped.
@@ -159,5 +153,8 @@ func (wfs *WFS) Release(cancel <-chan struct{}, in *fuse.ReleaseIn) {
glog.Warningf("release fh %d inode %d: fallback flush failed: %v", in.Fh, in.NodeId, status)
}
}
if in.ReleaseFlags&fuse.FUSE_RELEASE_FLOCK_UNLOCK != 0 {
wfs.releaseFlockOwner(in.NodeId, in.LockOwner)
}
wfs.ReleaseHandle(FileHandleId(in.Fh))
}
-77
View File
@@ -1,77 +0,0 @@
package mount
import (
"math"
"testing"
"time"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// A close() frees the file description's flock locks even when the handle
// flush is stuck — e.g. a filer that stopped answering. If the locks outlive
// the blocked flush, a restarted process taking the same file's flock sees
// "file already locked" until the mount restarts.
func TestReleaseDropsFlockWhileFlushBlocked(t *testing.T) {
wfs := newInvalidateTestWFS(t)
wfs.posixLocks = NewPosixLockTable()
startFakeFiler(t, wfs, &fakeFilerServer{})
inode := wfs.inodeToPath.Lookup(util.FullPath("/dir/f"), time.Now().Unix(), false, false, 0, false)
fh, _ := wfs.fhMap.AcquireFileHandle(wfs, inode, &filer_pb.Entry{
Name: "f",
Attributes: &filer_pb.FuseAttributes{},
}, 0, 0)
fh.dirtyMetadata = true
const owner = uint64(42)
if st := wfs.posixLocks.SetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: f_WRLCK, Owner: owner, IsFlock: true}); st != fuse.OK {
t.Fatalf("take flock: %v", st)
}
// Stall the release flush at the handle lock flushMetadataToFiler waits on.
active := wfs.fhLockTable.AcquireLock("test", fh.fh, util.ExclusiveLock)
released := false
releaseLock := func() {
if !released {
wfs.fhLockTable.ReleaseLock(fh.fh, active)
released = true
}
}
defer releaseLock()
done := make(chan struct{})
go func() {
defer close(done)
wfs.Release(nil, &fuse.ReleaseIn{
InHeader: fuse.InHeader{NodeId: inode},
Fh: uint64(fh.fh),
LockOwner: owner,
ReleaseFlags: fuse.FUSE_RELEASE_FLOCK_UNLOCK,
})
}()
// The dead holder's flock must be gone while the flush is still parked.
deadline := time.Now().Add(10 * time.Second)
for {
var out fuse.LkOut
wfs.posixLocks.GetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: f_WRLCK, Owner: 7, IsFlock: true}, &out)
if out.Lk.Typ == f_UNLCK {
break
}
if time.Now().After(deadline) {
t.Fatal("flock still held while the release flush is blocked")
}
time.Sleep(5 * time.Millisecond)
}
// Let the parked flush through: the live filer answers the metadata update.
releaseLock()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("release never completed after the flush unblocked")
}
}
-2
View File
@@ -392,8 +392,6 @@ message ObjectTransactionRequest {
string condition_key = 6; // if set, evaluate the condition against this entry instead of lock_key (still locking lock_key)
string route_key = 7; // ring key identifying the owner filer; a non-owner forwards the whole transaction to it
bool is_moved = 8; // set on a forwarded transaction so the receiver applies it locally instead of forwarding again
string request_id = 9; // idempotency token: the owner records the outcome and replays it on a retried request instead of re-applying
string condition_alt_key = 10; // if the condition's entry is absent, evaluate it against this entry instead (e.g. null slot, else latest version)
}
message ObjectTransactionResponse {
+6 -26
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v7.35.0
// protoc v3.21.12
// source: filer.proto
package filer_pb
@@ -1811,11 +1811,9 @@ type ObjectTransactionRequest struct {
Mutations []*ObjectMutation `protobuf:"bytes,3,rep,name=mutations,proto3" json:"mutations,omitempty"`
IsFromOtherCluster bool `protobuf:"varint,4,opt,name=is_from_other_cluster,json=isFromOtherCluster,proto3" json:"is_from_other_cluster,omitempty"`
Signatures []int32 `protobuf:"varint,5,rep,packed,name=signatures,proto3" json:"signatures,omitempty"`
ConditionKey string `protobuf:"bytes,6,opt,name=condition_key,json=conditionKey,proto3" json:"condition_key,omitempty"` // if set, evaluate the condition against this entry instead of lock_key (still locking lock_key)
RouteKey string `protobuf:"bytes,7,opt,name=route_key,json=routeKey,proto3" json:"route_key,omitempty"` // ring key identifying the owner filer; a non-owner forwards the whole transaction to it
IsMoved bool `protobuf:"varint,8,opt,name=is_moved,json=isMoved,proto3" json:"is_moved,omitempty"` // set on a forwarded transaction so the receiver applies it locally instead of forwarding again
RequestId string `protobuf:"bytes,9,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` // idempotency token: the owner records the outcome and replays it on a retried request instead of re-applying
ConditionAltKey string `protobuf:"bytes,10,opt,name=condition_alt_key,json=conditionAltKey,proto3" json:"condition_alt_key,omitempty"` // if the condition's entry is absent, evaluate it against this entry instead (e.g. null slot, else latest version)
ConditionKey string `protobuf:"bytes,6,opt,name=condition_key,json=conditionKey,proto3" json:"condition_key,omitempty"` // if set, evaluate the condition against this entry instead of lock_key (still locking lock_key)
RouteKey string `protobuf:"bytes,7,opt,name=route_key,json=routeKey,proto3" json:"route_key,omitempty"` // ring key identifying the owner filer; a non-owner forwards the whole transaction to it
IsMoved bool `protobuf:"varint,8,opt,name=is_moved,json=isMoved,proto3" json:"is_moved,omitempty"` // set on a forwarded transaction so the receiver applies it locally instead of forwarding again
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1906,20 +1904,6 @@ func (x *ObjectTransactionRequest) GetIsMoved() bool {
return false
}
func (x *ObjectTransactionRequest) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *ObjectTransactionRequest) GetConditionAltKey() string {
if x != nil {
return x.ConditionAltKey
}
return ""
}
type ObjectTransactionResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
@@ -7449,7 +7433,7 @@ const file_filer_proto_rawDesc = "" +
"\fexclude_name\x18\t \x01(\tR\vexcludeName\x1a?\n" +
"\x11CopyExtendedEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xa0\x03\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xd5\x02\n" +
"\x18ObjectTransactionRequest\x12\x19\n" +
"\block_key\x18\x01 \x01(\tR\alockKey\x126\n" +
"\tcondition\x18\x02 \x01(\v2\x18.filer_pb.WriteConditionR\tcondition\x126\n" +
@@ -7460,11 +7444,7 @@ const file_filer_proto_rawDesc = "" +
"signatures\x12#\n" +
"\rcondition_key\x18\x06 \x01(\tR\fconditionKey\x12\x1b\n" +
"\troute_key\x18\a \x01(\tR\brouteKey\x12\x19\n" +
"\bis_moved\x18\b \x01(\bR\aisMoved\x12\x1d\n" +
"\n" +
"request_id\x18\t \x01(\tR\trequestId\x12*\n" +
"\x11condition_alt_key\x18\n" +
" \x01(\tR\x0fconditionAltKey\"f\n" +
"\bis_moved\x18\b \x01(\bR\aisMoved\"f\n" +
"\x19ObjectTransactionResponse\x12\x14\n" +
"\x05error\x18\x01 \x01(\tR\x05error\x123\n" +
"\n" +
-86
View File
@@ -1567,20 +1567,6 @@ func (m *ObjectTransactionRequest) MarshalToSizedBufferVT(dAtA []byte) (int, err
i -= len(m.unknownFields)
copy(dAtA[i:], m.unknownFields)
}
if len(m.ConditionAltKey) > 0 {
i -= len(m.ConditionAltKey)
copy(dAtA[i:], m.ConditionAltKey)
i = protohelpers.EncodeVarint(dAtA, i, uint64(len(m.ConditionAltKey)))
i--
dAtA[i] = 0x52
}
if len(m.RequestId) > 0 {
i -= len(m.RequestId)
copy(dAtA[i:], m.RequestId)
i = protohelpers.EncodeVarint(dAtA, i, uint64(len(m.RequestId)))
i--
dAtA[i] = 0x4a
}
if m.IsMoved {
i--
if m.IsMoved {
@@ -7202,14 +7188,6 @@ func (m *ObjectTransactionRequest) SizeVT() (n int) {
if m.IsMoved {
n += 2
}
l = len(m.RequestId)
if l > 0 {
n += 1 + l + protohelpers.SizeOfVarint(uint64(l))
}
l = len(m.ConditionAltKey)
if l > 0 {
n += 1 + l + protohelpers.SizeOfVarint(uint64(l))
}
n += len(m.unknownFields)
return n
}
@@ -13863,70 +13841,6 @@ func (m *ObjectTransactionRequest) UnmarshalVT(dAtA []byte) error {
}
}
m.IsMoved = bool(v != 0)
case 9:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field RequestId", wireType)
}
var stringLen uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return protohelpers.ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
stringLen |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
intStringLen := int(stringLen)
if intStringLen < 0 {
return protohelpers.ErrInvalidLength
}
postIndex := iNdEx + intStringLen
if postIndex < 0 {
return protohelpers.ErrInvalidLength
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
m.RequestId = string(dAtA[iNdEx:postIndex])
iNdEx = postIndex
case 10:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field ConditionAltKey", wireType)
}
var stringLen uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return protohelpers.ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
stringLen |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
intStringLen := int(stringLen)
if intStringLen < 0 {
return protohelpers.ErrInvalidLength
}
postIndex := iNdEx + intStringLen
if postIndex < 0 {
return protohelpers.ErrInvalidLength
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
m.ConditionAltKey = string(dAtA[iNdEx:postIndex])
iNdEx = postIndex
default:
iNdEx = preIndex
skippy, err := protohelpers.Skip(dAtA[iNdEx:])
+14 -45
View File
@@ -1813,27 +1813,6 @@ func isBucketListingRequest(r *http.Request, action Action) bool {
return object == "" || object == "/"
}
// multipartListingAction returns the scoped action for a request the router
// sends to ListObjectPartsHandler or ListMultipartUploadsHandler, or "" for
// any other request. It follows the route order in s3api_server.go and reads
// the object from the route vars, so a ?prefix promoted into the
// authorization object, or extra subresource parameters such as ?policy, do
// not change which operation the grant has to name.
func multipartListingAction(r *http.Request, action Action) Action {
if action != s3_constants.ACTION_READ || r == nil || r.Method != http.MethodGet || r.URL == nil {
return ""
}
query := r.URL.Query()
_, object := s3_constants.GetBucketAndObject(r)
if object != "" && object != "/" && query.Has("uploadId") {
return s3_constants.ACTION_LIST_PARTS
}
if query.Has("uploads") {
return s3_constants.ACTION_LIST_MULTIPART_UPLOADS
}
return ""
}
// authRequestWithAuthType authenticates and then authorizes a request for a given action.
func (iam *IdentityAccessManagement) authRequestWithAuthType(r *http.Request, action Action) (*Identity, s3err.ErrorCode, authType) {
identity, s3Err, reqAuthType := iam.authenticateRequestInternal(r)
@@ -2847,12 +2826,7 @@ func (iam *IdentityAccessManagement) VerifyActionPermission(r *http.Request, ide
}
case authorizeViaLegacyActions:
if !identity.CanDo(action, bucket, object) {
// The multipart listing routes are gated on ACTION_READ, but a
// grant may name the listing operation itself.
listing := multipartListingAction(r, action)
if listing == "" || !identity.CanDo(listing, bucket, object) {
return s3err.ErrAccessDenied
}
return s3err.ErrAccessDenied
}
return s3err.ErrNone
default:
@@ -2942,12 +2916,16 @@ func (iam *IdentityAccessManagement) bucketsNamedByAttachedPolicies(r *http.Requ
// its query describe. versionId, when set, and the STS session token ride along
// for policy conditions. Returns ErrNone when auth is disabled (checked by the
// callers) or the identity is an admin.
// objectKeyPolicyRequest synthesizes a <method> /<bucket>/<objectKey> request
// for policy evaluation against a key the real URL does not name. The query is
// built from scratch so a param on the real request (delete, uploadId,
// partNumber, ...) cannot steer ResolveS3Action off the intended action; the
// STS token and versionId ride along for policy conditions.
func objectKeyPolicyRequest(r *http.Request, method, bucket, objectKey, versionId string) *http.Request {
func (iam *IdentityAccessManagement) authorizeObjectKeyAction(r *http.Request, identity *Identity, method string, action Action, bucket, objectKey, versionId string) s3err.ErrorCode {
if identity == nil {
return s3err.ErrAccessDenied
}
if identity.isAdmin() {
return s3err.ErrNone
}
// Shallow copy: authorization only reads headers, so sharing the header map
// with the original request is safe, and this can run once per key.
keyReq := new(http.Request)
*keyReq = *r
keyURL := &url.URL{
@@ -2955,6 +2933,9 @@ func objectKeyPolicyRequest(r *http.Request, method, bucket, objectKey, versionI
Host: r.URL.Host,
Path: "/" + bucket + "/" + objectKey,
}
// Build the query from scratch so a param on the real request (delete,
// uploadId, partNumber, ...) cannot steer ResolveS3Action off the intended
// action; keep the STS token and per-key versionId for policy conditions.
keyQuery := make(url.Values)
if versionId != "" {
keyQuery.Set("versionId", versionId)
@@ -2973,18 +2954,6 @@ func objectKeyPolicyRequest(r *http.Request, method, bucket, objectKey, versionI
keyReq.Body = nil
keyReq.GetBody = nil
keyReq.ContentLength = 0
return keyReq
}
func (iam *IdentityAccessManagement) authorizeObjectKeyAction(r *http.Request, identity *Identity, method string, action Action, bucket, objectKey, versionId string) s3err.ErrorCode {
if identity == nil {
return s3err.ErrAccessDenied
}
if identity.isAdmin() {
return s3err.ErrNone
}
keyReq := objectKeyPolicyRequest(r, method, bucket, objectKey, versionId)
if iam.policyEngine != nil {
principal := buildPrincipalARN(identity, keyReq)
+2 -8
View File
@@ -354,15 +354,9 @@ func (iam *IdentityAccessManagement) verifyV4Signature(r *http.Request, shouldCh
// 8. Verify the signature for each plausible host value: when X-Forwarded-Host carries
// no port, the client may have signed the Host header the proxy kept or the forwarded
// host and port, and the headers alone cannot tell which.
// A router that rewrote the request path (e.g. the Iceberg catalog's
// doubled-/v1 collapse) records the client-sent path in the context;
// the signature covers that original path.
pathForSignature := s3_constants.GetOriginalRequestPathFromContext(r)
pathForSignature := r.URL.EscapedPath()
if pathForSignature == "" {
pathForSignature = r.URL.EscapedPath()
if pathForSignature == "" {
pathForSignature = r.URL.Path
}
pathForSignature = r.URL.Path
}
forwardedPrefix := r.Header.Get("X-Forwarded-Prefix")
var matchedHost string
-42
View File
@@ -7,12 +7,9 @@ import (
"fmt"
"net/http"
"slices"
"sync"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
@@ -680,42 +677,3 @@ func TestExtractSignedHeadersCase(t *testing.T) {
})
}
}
// 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.
func TestVerifyV4SignatureUsesOriginalRequestPath(t *testing.T) {
iam := &IdentityAccessManagement{
hashes: make(map[string]*sync.Pool),
hashCounters: make(map[string]*int32),
}
_ = iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: "someone",
Credentials: []*iam_pb.Credential{
{
AccessKey: "access_key_1",
SecretKey: "secret_key_1",
},
},
Actions: []string{"Admin", "Read", "Write"},
},
},
})
req := mustNewSignedRequest(http.MethodGet, "http://127.0.0.1:9000/v1/v1/bucket/namespaces", 0, nil, t)
// Simulate the rewrite: the routed path is collapsed while the signature
// still covers the doubled path the client sent.
rewritten := req.Clone(req.Context())
rewritten.URL.Path = "/v1/bucket/namespaces"
if _, errCode := iam.reqSignatureV4Verify(rewritten); errCode == s3err.ErrNone {
t.Fatal("rewritten request verified without the original path; expected a signature mismatch")
}
rewritten = rewritten.WithContext(s3_constants.SetOriginalRequestPathInContext(rewritten.Context(), "/v1/v1/bucket/namespaces"))
if _, errCode := iam.reqSignatureV4Verify(rewritten); errCode != s3err.ErrNone {
t.Fatalf("signed request rejected after path rewrite: %d", errCode)
}
}
+4 -8
View File
@@ -94,20 +94,16 @@ func (iam *IdentityAccessManagement) newChunkedReader(req *http.Request) (io.Rea
// Payload for STREAMING signature should be 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD' or 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER'
case streamingContentSHA256, streamingContentSHA256Trailer:
glog.V(3).Infof("streaming content sha256 (with trailer: %v)", contentSha256Header == streamingContentSHA256Trailer)
// With IAM disabled there is no credential to verify against; leave
// cred nil so chunk signatures are decoded but not verified.
if iam.isEnabled() {
credential, seedSignature, region, service, seedDate, errCode = iam.calculateSeedSignature(req)
if errCode != s3err.ErrNone {
return nil, errCode
}
credential, seedSignature, region, service, seedDate, errCode = iam.calculateSeedSignature(req)
if errCode != s3err.ErrNone {
return nil, errCode
}
case streamingUnsignedPayload:
glog.V(3).Infof("streaming unsigned payload")
// The seed signature is a SigV4 concept; recompute it only for SigV4-signed
// requests. JWT-bearer, presigned, and anonymous unsigned-streaming uploads
// carry no header seed, and verifyV4Signature would fail parsing them.
if iam.isEnabled() && isRequestSignatureV4(req) {
if isRequestSignatureV4(req) {
// We do not need to pass the seed signature to the Reader as each chunk is not signed,
// but we do compute it to verify the request's signature.
_, _, _, _, _, errCode = iam.calculateSeedSignature(req)
+1 -56
View File
@@ -137,7 +137,7 @@ func setupIam() IdentityAccessManagement {
hashCounters: map[string]*int32{},
identityAnonymous: nil,
domain: "",
isAuthEnabled: true,
isAuthEnabled: false,
}
iam.identities = append(iam.identities, &Identity{
@@ -477,58 +477,3 @@ func TestSignedStreamingUploadInvalidSignature(t *testing.T) {
assert.Error(t, err, "Expected error when reading chunk with invalid signature")
assert.Contains(t, err.Error(), "chunk signature does not match", "Error should indicate chunk signature mismatch")
}
// TestSignedStreamingUploadWithoutIAM verifies that signed streaming uploads
// (STREAMING-AWS4-HMAC-SHA256-PAYLOAD) are decoded rather than rejected when no
// credentials are configured. Trino and other AWS SDK v2 clients always send
// this payload type, so open gateways must accept it.
func TestSignedStreamingUploadWithoutIAM(t *testing.T) {
iam := IdentityAccessManagement{
identities: []*Identity{},
accessKeyIdent: map[string]*Identity{},
isAuthEnabled: false,
}
s3a := &S3ApiServer{iam: &iam}
chunkData := strings.Repeat("a", 1024)
payload := fmt.Sprintf("400;chunk-signature=%064x\r\n%s\r\n", 1, chunkData) +
fmt.Sprintf("0;chunk-signature=%064x\r\n\r\n", 2)
req, err := http.NewRequest(http.MethodPut, "http://localhost/test-bucket/test-object",
bytes.NewReader([]byte(payload)))
assert.NoError(t, err)
req.Header.Set("x-amz-content-sha256", streamingContentSHA256)
req.Header.Set("Content-Encoding", "aws-chunked")
req.Header.Set("x-amz-decoded-content-length", "1024")
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=x/20261016/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=abc")
reader, errCode := getRequestDataReader(s3a, req)
assert.Equal(t, s3err.ErrNone, errCode)
assert.NotNil(t, reader)
data, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, chunkData, string(data))
}
// TestTrailerStreamingUploadWithoutIAM verifies that the trailer variant
// (STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER) is also decoded when no
// credentials are configured, and that the checksum trailer is still honored.
func TestTrailerStreamingUploadWithoutIAM(t *testing.T) {
iam := IdentityAccessManagement{
identities: []*Identity{},
accessKeyIdent: map[string]*Identity{},
isAuthEnabled: false,
}
s3a := &S3ApiServer{iam: &iam}
req, expectedData := createTrailerStreamingRequest(t, true)
reader, errCode := getRequestDataReader(s3a, req)
assert.Equal(t, s3err.ErrNone, errCode)
assert.NotNil(t, reader)
data, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, expectedData, string(data))
}
-41
View File
@@ -110,14 +110,6 @@ func (s *Server) RegisterRoutes(router *mux.Router) {
// audit-logged.
router.Use(validateRequestPath)
// Clients pointed at a catalog URI ending in /v1 still append the API
// version themselves, producing /v1/v1/... requests (iceberg-java does
// this when configured with a /v1-suffixed URI). Collapse the repeated
// segments before routing so those requests reach the intended handler;
// a table bucket can never be named "v1" (names require 3-63 chars), so
// the rewrite is unambiguous.
router.Use(collapseDoubledV1Middleware(router))
// Configuration endpoint - no auth needed for config
router.HandleFunc("/v1/config", s.handleConfig).Methods(http.MethodGet)
@@ -189,39 +181,6 @@ func (s *Server) RegisterRoutes(router *mux.Router) {
glog.V(2).Infof("Registered Iceberg REST Catalog routes")
}
// collapseDoubledV1Middleware rewrites /v1/v1/... (and deeper repetitions) to
// the single-/v1 path and re-dispatches through the router once. The collapsed
// path never starts with /v1/v1 again, so re-entry cannot recurse. The path
// the client sent is kept in the request context for SigV4 verification,
// which must check the signature against what was actually signed.
func collapseDoubledV1Middleware(router *mux.Router) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path != "/v1/v1" && !strings.HasPrefix(path, "/v1/v1/") {
next.ServeHTTP(w, r)
return
}
for path == "/v1/v1" || strings.HasPrefix(path, "/v1/v1/") {
path = path[len("/v1"):]
}
signedPath := r.URL.EscapedPath()
if signedPath == "" {
signedPath = r.URL.Path
}
r2 := r.Clone(r.Context())
r2.URL.Path = path
r2.URL.RawPath = ""
r2.RequestURI = r2.URL.EscapedPath()
if r.URL.RawQuery != "" {
r2.RequestURI += "?" + r.URL.RawQuery
}
r2 = r2.WithContext(s3_constants.SetOriginalRequestPathInContext(r2.Context(), signedPath))
router.ServeHTTP(w, r2)
})
}
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
glog.V(2).Infof("Iceberg REST request: %s %s from %s", r.Method, r.RequestURI, r.RemoteAddr)
-66
View File
@@ -6,7 +6,6 @@ import (
"testing"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
// The catch-all answers anything unrouted, so a path that reaches it is a
@@ -40,68 +39,3 @@ func TestRegisteredRoutesReachAHandler(t *testing.T) {
}
}
}
// A catalog URI configured with a trailing /v1 (a common iceberg-java client
// mistake) produces /v1/v1/... requests; the router collapses the repeated
// prefix instead of answering "Path not found" or resolving "v1" as a table
// bucket name.
func TestDoubledV1PrefixIsCollapsed(t *testing.T) {
router := mux.NewRouter().SkipClean(true)
(&Server{}).RegisterRoutes(router)
for _, target := range []string{
"/v1/v1/config?warehouse=bucket",
// Degenerate repetition collapses in a single pass.
"/v1/v1/v1/config",
} {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s: got %d, want 200: %s", target, rec.Code, rec.Body.String())
}
}
// /v1/v1/namespaces also matches the {prefix} routes ("v1" can never be a
// table bucket name); it must still collapse to the unprefixed endpoint,
// which requires auth here rather than answering 404.
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/v1/namespaces", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("/v1/v1/namespaces: got %d, want 401: %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/nonexistent/path", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("/v1/nonexistent/path: got %d, want 404", rec.Code)
}
}
// The rewrite must preserve the client-sent path for SigV4 verification: a
// signed request is checked against the URI it was signed with, not the
// collapsed path it was routed under.
func TestDoubledV1CollapsePreservesOriginalPath(t *testing.T) {
router := mux.NewRouter().SkipClean(true)
var gotPath, gotSignedPath, gotQuery string
router.HandleFunc("/v1/probe", func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotQuery = r.URL.RawQuery
gotSignedPath = s3_constants.GetOriginalRequestPathFromContext(r)
}).Methods(http.MethodGet)
router.Use(collapseDoubledV1Middleware(router))
router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/v1/probe?warehouse=bucket", nil))
if gotPath != "/v1/probe" {
t.Fatalf("routed path: got %q, want /v1/probe", gotPath)
}
if gotQuery != "warehouse=bucket" {
t.Fatalf("query: got %q, want warehouse=bucket", gotQuery)
}
if gotSignedPath != "/v1/v1/probe" {
t.Fatalf("original signed path: got %q, want /v1/v1/probe", gotSignedPath)
}
}
-24
View File
@@ -58,9 +58,6 @@ const (
AmzDeleteMarker = "X-Amz-Delete-Marker"
SeaweedFSUploadId = "X-Seaweedfs-Upload-Id"
SeaweedFSExtract = "X-Seaweedfs-Extract"
SeaweedFSExtractedCount = "X-Seaweedfs-Extracted-Count"
AmzMetaSnowballAutoExtract = "X-Amz-Meta-Snowball-Auto-Extract"
SeaweedFSMultipartPartsCount = "X-Seaweedfs-Multipart-Parts-Count"
SeaweedFSMultipartPartBoundaries = "X-Seaweedfs-Multipart-Part-Boundaries" // JSON: [{part:1,start:0,end:2,etag:"abc"},{part:2,start:2,end:3,etag:"def"}]
SeaweedFSExpiresS3 = "X-Seaweedfs-Expires-S3"
@@ -331,7 +328,6 @@ const (
contextKeyPrincipalArn contextKey = "s3-principal-arn"
contextKeyIdentityClaim contextKey = "s3-identity-claim"
contextKeyIgnoredSessionToken contextKey = "s3-ignored-session-token"
contextKeyOriginalRequestPath contextKey = "s3-original-request-path"
)
// identityHolder is a mutable container for the authenticated identity name,
@@ -454,26 +450,6 @@ func GetIdentityClaimFromContext(r *http.Request) string {
return ""
}
// SetOriginalRequestPathInContext records the path a client actually sent
// before an internal rewrite (e.g. the Iceberg catalog collapsing a doubled
// /v1 prefix). SigV4 verification must run against this path: the client
// signed the URI it requested, not the normalized path it was routed with.
func SetOriginalRequestPathInContext(ctx context.Context, path string) context.Context {
if path == "" {
return ctx
}
return context.WithValue(ctx, contextKeyOriginalRequestPath, path)
}
// GetOriginalRequestPathFromContext returns the pre-rewrite request path
// recorded by SetOriginalRequestPathInContext, or "" when unset.
func GetOriginalRequestPathFromContext(r *http.Request) string {
if path, ok := r.Context().Value(contextKeyOriginalRequestPath).(string); ok {
return path
}
return ""
}
// IgnoreSessionTokenInContext marks the request's session token as tolerated
// rather than authenticated: a statically configured access key signed the
// request, so the token (which credential vendors like Unity Catalog emit on
-5
View File
@@ -20,11 +20,6 @@ const (
ACTION_DELETE_BUCKET_POLICY = "DeleteBucketPolicy"
ACTION_PUT_BUCKET_QUOTA = "PutBucketQuota"
ACTION_GET_BUCKET_QUOTA = "GetBucketQuota"
// Scoped to their single operation: the multipart listing routes are
// gated on ACTION_READ for compatibility, but a policy granting one of
// these names must not widen into a general Read grant.
ACTION_LIST_MULTIPART_UPLOADS = "ListBucketMultipartUploads"
ACTION_LIST_PARTS = "ListMultipartUploadParts"
SeaweedStorageDestinationHeader = "x-seaweedfs-destination"
MultipartUploadsFolder = ".uploads"
-112
View File
@@ -4,9 +4,6 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
@@ -260,112 +257,3 @@ func TestHasPolicyForBucket(t *testing.T) {
// No longer has policy
assert.False(t, engine.HasPolicyForBucket("test-bucket"))
}
// TestRecheckCopySourcePolicy verifies that a tag-conditioned Deny on
// s3:GetObject also blocks CopyObject-style reads of the source object. The
// destination request keeps its PUT method while the policy is evaluated
// against a synthesized GET on the source key.
func TestRecheckCopySourcePolicy(t *testing.T) {
s3a := &S3ApiServer{
policyEngine: NewBucketPolicyEngine(),
}
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::src-bucket", "arn:aws:s3:::src-bucket/*", "arn:aws:s3:::dst-bucket", "arn:aws:s3:::dst-bucket/*"]
},
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::src-bucket/*",
"Condition": {
"StringEquals": {
"s3:ExistingObjectTag/status": ["private"]
}
}
}
]
}`
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy("src-bucket", policyJSON))
identity := &Identity{Name: "denied-user", PrincipalArn: "arn:aws:iam::123456789012:user/denied"}
req := httptest.NewRequest(http.MethodPut, "/dst-bucket/dst-key", nil)
req = req.WithContext(s3_constants.SetIdentityInContext(req.Context(), identity))
privateEntry := map[string][]byte{
s3_constants.AmzObjectTaggingPrefix + "status": []byte("private"),
}
assert.Equal(t, s3err.ErrAccessDenied,
s3a.recheckCopySourcePolicy(req, "src-bucket", "src-key", "", privateEntry, "test"))
publicEntry := map[string][]byte{
s3_constants.AmzObjectTaggingPrefix + "status": []byte("public"),
}
assert.Equal(t, s3err.ErrNone,
s3a.recheckCopySourcePolicy(req, "src-bucket", "src-key", "", publicEntry, "test"))
}
// A source read whose only grant is conditioned on s3:ExistingObjectTag denies
// in the tagless first phase; the deferred lookup recheck must still grant it
// when the resolved entry's tags satisfy the Allow, and keep denying when they
// do not. The same recheck with a synthesized DELETE is what a rename applies
// to the source removal.
func TestRecheckPolicyForKeyWithLookup(t *testing.T) {
filerImpl := &ambiguousPutFiler{
entries: map[string]*filer_pb.Entry{
"/buckets/src-bucket/src-key": {
Name: "src-key",
Extended: map[string][]byte{
s3_constants.AmzObjectTaggingPrefix + "status": []byte("public"),
},
},
},
}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
s3a.policyEngine = NewBucketPolicyEngine()
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
s3a.bucketConfigCache.Set("src-bucket", &BucketConfig{Name: "src-bucket"})
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::src-bucket/*",
"Condition": {
"StringEquals": {
"s3:ExistingObjectTag/status": ["public"]
}
}
}
]
}`
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy("src-bucket", policyJSON))
identity := &Identity{Name: "tagged-user", PrincipalArn: "arn:aws:iam::123456789012:user/tagged"}
req := httptest.NewRequest(http.MethodPut, "/dst-bucket/dst-key", nil)
req = req.WithContext(s3_constants.SetIdentityInContext(req.Context(), identity))
assert.Equal(t, s3err.ErrNone,
s3a.recheckPolicyForKeyWithLookup(req, http.MethodGet, "src-bucket", "src-key", "", string(s3_constants.ACTION_READ), "test"),
"a tag-conditioned Allow on the resolved entry must still grant the read")
filerImpl.entries["/buckets/src-bucket/src-key"].Extended[s3_constants.AmzObjectTaggingPrefix+"status"] = []byte("private")
assert.Equal(t, s3err.ErrAccessDenied,
s3a.recheckPolicyForKeyWithLookup(req, http.MethodGet, "src-bucket", "src-key", "", string(s3_constants.ACTION_READ), "test"),
"non-matching tags must keep the first-phase deny")
// Without a policy on the bucket the recheck must not widen anything.
require.NoError(t, s3a.policyEngine.engine.DeleteBucketPolicy("src-bucket"))
assert.Equal(t, s3err.ErrAccessDenied,
s3a.recheckPolicyForKeyWithLookup(req, http.MethodGet, "src-bucket", "src-key", "", string(s3_constants.ACTION_READ), "test"))
}
@@ -6,10 +6,8 @@ import (
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
@@ -150,159 +148,3 @@ func TestOIDCBearerEnforcesRoleTrustPolicy(t *testing.T) {
require.Equal(t, s3err.ErrNone, errCode)
assert.Equal(t, "arn:aws:iam::role/BearerTrustedRole", identity.Principal)
}
// An OIDC bearer token's aws:userid claim is server-asserted from the
// verified (sub, iss) pair: a token-supplied value is overwritten, and a
// token without a subject is rejected outright.
func TestOIDCBearerUserIDIsIssuerScoped(t *testing.T) {
ctx := context.Background()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
jwks := map[string]interface{}{
"keys": []map[string]interface{}{{
"kty": "RSA",
"kid": "bearer-test-key",
"use": "sig",
"alg": "RS256",
"n": base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()),
"e": "AQAB",
}},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/jwks" {
json.NewEncoder(w).Encode(jwks)
}
}))
defer server.Close()
provider := oidc.NewOIDCProvider("test-oidc")
require.NoError(t, provider.Initialize(&oidc.OIDCConfig{
Issuer: server.URL,
ClientID: "test-client",
JWKSUri: server.URL + "/jwks",
RoleMapping: &providers.RoleMapping{
Rules: []providers.MappingRule{
{Claim: "groups", Value: "trusted-group", Role: "arn:aws:iam::role/BearerTrustedRole"},
},
},
}))
iamManager := integration.NewIAMManager()
require.NoError(t, iamManager.Initialize(&integration.IAMConfig{
STS: &sts.STSConfig{
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
Issuer: "test-sts",
SigningKey: []byte("test-signing-key-32-characters-long"),
},
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
Roles: &integration.RoleStoreConfig{StoreType: "memory"},
}, func() string { return "localhost:8888" }))
require.NoError(t, iamManager.RegisterIdentityProvider(provider))
require.NoError(t, iamManager.CreatePolicy(ctx, "", "BearerTestPolicy", &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{
Effect: "Allow",
Action: []string{"s3:*"},
Resource: []string{"arn:aws:s3:::*", "arn:aws:s3:::*/*"},
}},
}))
require.NoError(t, iamManager.CreateRole(ctx, "", "BearerTrustedRole", &integration.RoleDefinition{
RoleName: "BearerTrustedRole",
TrustPolicy: &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{
Effect: "Allow",
Principal: map[string]interface{}{"Federated": "test-oidc"},
Action: []string{"sts:AssumeRoleWithWebIdentity"},
}},
},
AttachedPolicies: []string{"BearerTestPolicy"},
}))
sign := func(extra map[string]interface{}) string {
claims := jwt.MapClaims{
"iss": server.URL,
"aud": "test-client",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"groups": []string{"trusted-group"},
}
for k, v := range extra {
claims[k] = v
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = "bearer-test-key"
signed, err := token.SignedString(key)
require.NoError(t, err)
return signed
}
s3iam := NewS3IAMIntegration(iamManager, "localhost:8888")
authenticate := func(token string) (*IAMIdentity, s3err.ErrorCode) {
req := httptest.NewRequest("GET", "/test-bucket/test-object", http.NoBody)
req.Header.Set("Authorization", "Bearer "+token)
return s3iam.AuthenticateJWT(ctx, req)
}
identity, errCode := authenticate(sign(map[string]interface{}{
"sub": "bearer-user",
"aws:userid": "admin",
}))
require.Equal(t, s3err.ErrNone, errCode)
assert.Equal(t,
sts.ComputeParentUser("bearer-user", server.URL),
identity.Claims["aws:userid"],
"the token-supplied aws:userid must be overwritten with the verified (sub, iss) hash")
assert.Equal(t, "bearer-user", identity.Claims["sub"])
assert.Equal(t, server.URL, identity.Claims["iss"])
assert.Equal(t, server.URL, identity.Claims["seaweed:oidc-issuers"],
"the registered provider's issuer must be asserted as trusted for legacy-owner anchoring")
_, errCode = authenticate(sign(map[string]interface{}{"sub": ""}))
assert.Equal(t, s3err.ErrAccessDenied, errCode, "a token with no subject must not authenticate")
}
// flakyListOIDCStore fails ListProviders on demand, standing in for a
// filer-backed provider store that cannot be read.
type flakyListOIDCStore struct {
integration.OIDCProviderStore
fail atomic.Bool
}
func (s *flakyListOIDCStore) ListProviders(ctx context.Context, filerAddress string) ([]*integration.OIDCProviderRecord, error) {
if s.fail.Load() {
return nil, fmt.Errorf("list OIDC providers: simulated outage")
}
return s.OIDCProviderStore.ListProviders(ctx, filerAddress)
}
// A failed store listing must not pass the registered providers off as the
// complete trusted set: hidden issuers could shrink the reported set to one
// entry, the only shape under which a legacy bare subject re-anchors.
func TestTrustedOIDCIssuersFailsClosedOnStoreError(t *testing.T) {
ctx := context.Background()
iamManager := integration.NewIAMManager()
s3iam := NewS3IAMIntegration(iamManager, "")
// No provider store at all: registered providers are the complete set —
// there is nowhere an issuer could hide behind a listing failure.
assert.Empty(t, s3iam.trustedOIDCIssuers(ctx))
mem := integration.NewMemoryOIDCProviderStore()
require.NoError(t, mem.StoreProvider(ctx, "", &integration.OIDCProviderRecord{
ARN: "arn:aws:iam::111122223333:oidc-provider/idp.example.com",
URL: "https://idp.example.com",
}))
store := &flakyListOIDCStore{OIDCProviderStore: mem}
iamManager.SetOIDCProviderStore(store)
assert.Equal(t, []string{"https://idp.example.com"}, s3iam.trustedOIDCIssuers(ctx))
store.fail.Store(true)
assert.Empty(t, s3iam.trustedOIDCIssuers(ctx),
"a failed listing must report no trusted issuers, not the partial registered set")
}
-72
View File
@@ -126,12 +126,6 @@ func (s3iam *S3IAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Requ
return nil, s3err.ErrAccessDenied
}
// A token without a subject has no issuer-scoped principal; letting it
// through would make the client-supplied claims below stand in for one.
if identity.UserID == "" {
return nil, s3err.ErrAccessDenied
}
// Extract role from OIDC identity
if identity.RoleArn == "" {
return nil, s3err.ErrAccessDenied
@@ -168,22 +162,6 @@ func (s3iam *S3IAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Requ
// Set critical claims explicitly, overwriting any from attributes to ensure correctness.
claims["sub"] = identity.UserID
claims["role"] = identity.RoleArn
claims["iss"] = identity.Issuer
// The bare sub collides across issuers; aws:userid carries the same
// (sub, iss) hash STS sessions already expose, so downstream ownership
// keys stay namespaced per identity provider. A claim the token itself
// supplies is always scrubbed, never adopted.
if parentUser := sts.ComputeParentUser(identity.UserID, identity.Issuer); parentUser != "" {
claims["aws:userid"] = parentUser
} else {
delete(claims, "aws:userid")
}
if issuers := s3iam.trustedOIDCIssuers(ctx); len(issuers) > 0 {
claims["seaweed:oidc-issuers"] = strings.Join(issuers, " ")
} else {
delete(claims, "seaweed:oidc-issuers")
}
// Use real email address if available
emailAddress := identity.UserID + "@oidc.local"
@@ -447,7 +425,6 @@ type OIDCIdentity struct {
UserID string
RoleArn string
Provider string
Issuer string
Email string
DisplayName string
Groups []string
@@ -511,7 +488,6 @@ func (s3iam *S3IAMIntegration) validateExternalOIDCToken(ctx context.Context, to
UserID: externalIdentity.UserID,
RoleArn: roleArn,
Provider: fmt.Sprintf("%T", provider), // Use provider type as identifier
Issuer: externalIdentity.Issuer,
Email: externalIdentity.Email,
DisplayName: externalIdentity.DisplayName,
Groups: externalIdentity.Groups,
@@ -519,54 +495,6 @@ func (s3iam *S3IAMIntegration) validateExternalOIDCToken(ctx context.Context, to
}, nil
}
// trustedOIDCIssuers returns the issuer URLs of the OIDC providers the
// deployment is configured to trust. It is what a legacy (pre-scoping) bare
// subject can be re-anchored to: exactly one issuer makes the mapping
// unambiguous; anything else leaves it unknown, which callers must treat as
// fail-closed rather than guessing from the caller's issuer.
//
// The list is only safe while complete. Without a provider store the
// registered providers are the whole set — there is nowhere an issuer could
// hide. With a store, a failed listing leaves the real set unknown, and
// passing the registered subset off as complete could collapse a
// multi-issuer deployment to a single issuer: the one shape a bare subject
// re-anchors under, so a caller from that issuer could claim a stored
// subject that belongs to a provider hidden behind the error. Fail closed.
func (s3iam *S3IAMIntegration) trustedOIDCIssuers(ctx context.Context) []string {
if s3iam.iamManager == nil {
return nil
}
seen := make(map[string]bool)
var issuers []string
// Configured identity providers registered with STS cover deployments
// whose OIDC setup lives in config rather than the provider store.
if stsService := s3iam.iamManager.GetSTSService(); stsService != nil {
for _, p := range stsService.GetProviders() {
if issuer, ok := p.(interface{ GetIssuer() string }); ok {
if iss := issuer.GetIssuer(); iss != "" && !seen[iss] {
seen[iss] = true
issuers = append(issuers, iss)
}
}
}
}
if s3iam.iamManager.GetOIDCProviderStore() == nil {
return issuers
}
records, err := s3iam.iamManager.ListOIDCProviders(ctx)
if err != nil {
glog.V(2).Infof("trustedOIDCIssuers: %v", err)
return nil
}
for _, rec := range records {
if rec.URL != "" && !seen[rec.URL] {
seen[rec.URL] = true
issuers = append(issuers, rec.URL)
}
}
return issuers
}
// selectPrimaryRole simply picks the first role from the list
// The OIDC provider should return roles in priority order (most important first)
func (s3iam *S3IAMIntegration) selectPrimaryRole(roles []string, externalIdentity *providers.ExternalIdentity) string {
+15 -2
View File
@@ -152,7 +152,7 @@ func TestBucketHasUserObjects_EmptyDirectories(t *testing.T) {
wantHasUser: true,
},
{
name: "bucket with only reserved folders (.uploads, *.versions)",
name: "empty *.versions folder and .uploads do not count",
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {
{Name: s3_constants.MultipartUploadsFolder, IsDirectory: true},
@@ -162,7 +162,7 @@ func TestBucketHasUserObjects_EmptyDirectories(t *testing.T) {
wantHasUser: false,
},
{
name: "nested versions-named directories are internal",
name: "nested versions directory with stored versions counts",
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {
{Name: "logs", IsDirectory: true},
@@ -174,6 +174,19 @@ func TestBucketHasUserObjects_EmptyDirectories(t *testing.T) {
{Name: "v1", IsDirectory: false},
},
},
wantHasUser: true,
},
{
name: "nested versions directory left empty does not count",
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {
{Name: "logs", IsDirectory: true},
},
"/buckets/b/logs": {
{Name: "foo" + s3_constants.VersionsFolder, IsDirectory: true},
},
"/buckets/b/logs/foo" + s3_constants.VersionsFolder: {},
},
wantHasUser: false,
},
{
+7 -5
View File
@@ -521,8 +521,9 @@ func (s3a *S3ApiServer) DeleteBucketHandler(w http.ResponseWriter, r *http.Reque
}
// bucketHasUserObjects checks whether a bucket contains any user objects.
// Empty directories left behind by deleted objects and internal folders
// (.uploads, *.versions) do not count as user objects.
// Empty directories left behind by deleted objects and the .uploads staging
// folder do not count as user objects; stored versions inside a <key>.versions
// directory do.
func (s3a *S3ApiServer) bucketHasUserObjects(bucket string) (bool, error) {
return s3a.dirHasUserObjects(s3a.option.BucketsPath + "/" + bucket)
}
@@ -556,9 +557,10 @@ func (s3a *S3ApiServer) dirHasUserObjects(root string) (bool, error) {
if entry.IsDirectoryKeyObject() {
return true, nil
}
// Internal folders are skipped by object listing at every level,
// so a directory with a reserved name never counts.
if isReservedDirectoryName(entry.Name) {
// Multipart staging folders are invisible to object listing at
// every level, so they never count. A <key>.versions directory
// is descended into since it may still hold stored versions.
if entry.Name == s3_constants.MultipartUploadsFolder {
continue
}
dirs = append(dirs, dir+"/"+entry.Name)
@@ -3,13 +3,10 @@ package s3api
import (
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
@@ -183,31 +180,3 @@ func TestAuthorizeObjectKeyActionBucketAllowDoesNotOverrideIdentityDeny(t *testi
iam.AuthorizeCopySource(req, reader, bpdBucket, "other/data.txt", ""),
"AuthorizeCopySource must preserve the bucket Allow over an implicit identity denial")
}
// A deferred tag-aware recheck (copy/rename source) re-runs only the bucket
// policy; it must still honor an explicit Deny in the caller's attached or
// session policies, or the phase-1 veto on secret/* could be bypassed.
func TestRecheckPolicyForKeyWithLookupHonorsIdentityDeny(t *testing.T) {
filerImpl := &ambiguousPutFiler{
entries: map[string]*filer_pb.Entry{
"/buckets/" + bpdBucket + "/secret/payroll.txt": {Name: "payroll.txt"},
"/buckets/" + bpdBucket + "/other/data.txt": {Name: "data.txt"},
},
}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
iam := newBucketPolicyDenyIAM(t, bpdBucketPolicyDoc(t, "secret/*", "other/*"))
s3a.iam = iam
s3a.policyEngine = iam.policyEngine
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
s3a.bucketConfigCache.Set(bpdBucket, &BucketConfig{Name: bpdBucket})
req := httptest.NewRequest(http.MethodPut, "/victim-bucket/dst-key", nil)
req = req.WithContext(s3_constants.SetIdentityInContext(req.Context(), bpdReader(t, iam)))
assert.Equal(t, s3err.ErrAccessDenied,
s3a.recheckPolicyForKeyWithLookup(req, http.MethodGet, bpdBucket, "secret/payroll.txt", "", s3_constants.ACTION_READ, "test"),
"an explicit identity-policy Deny must survive the bucket-policy recheck")
assert.Equal(t, s3err.ErrNone,
s3a.recheckPolicyForKeyWithLookup(req, http.MethodGet, bpdBucket, "other/data.txt", "", s3_constants.ACTION_READ, "test"),
"a bucket-policy Allow on a prefix the identity does not deny must still grant")
}
-3
View File
@@ -1194,9 +1194,6 @@ func (e *EmbeddedIamApi) GetUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values
resource = fmt.Sprintf("arn:aws:s3:::%s", act[1])
}
s3Action := fmt.Sprintf("s3:%s", iamMapToIdentitiesAction(act[0]))
if iamMapToIdentitiesAction(act[0]) == "" {
s3Action = fmt.Sprintf("s3:%s", act[0])
}
// Dedupe actions per resource
if seenAction[resource] == nil {
seenAction[resource] = make(map[string]bool)
-108
View File
@@ -2160,114 +2160,6 @@ func TestEmbeddedIamGetActionsPrefixScopedResource(t *testing.T) {
"PutObject outside the allowed prefix should be denied")
}
// TestEmbeddedIamGetActionsMultipartListing covers the AWS names for
// listing multipart uploads and parts: they grant only the named listing
// operation, never a general Read on the bucket.
func TestEmbeddedIamGetActionsMultipartListing(t *testing.T) {
api := NewEmbeddedIamApiForTest()
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory")
// Check each action on its own so a correct mapping for one cannot hide
// a wrong mapping for the other.
for _, tc := range []struct {
policyAction string
grant string
url string
object string
}{
{"s3:ListBucketMultipartUploads", "ListBucketMultipartUploads:demo", "/demo?uploads", ""},
{"s3:ListMultipartUploadParts", "ListMultipartUploadParts:demo", "/demo/key?uploadId=u1", "key"},
} {
t.Run(tc.policyAction, func(t *testing.T) {
policyDoc := `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["` + tc.policyAction + `"],
"Resource": ["arn:aws:s3:::demo", "arn:aws:s3:::demo/*"]
}]
}`
policy, err := api.GetPolicyDocument(&policyDoc)
require.NoError(t, err)
actions, err := api.getActions(&policy)
require.NoError(t, err)
require.NotEmpty(t, actions)
for _, a := range actions {
assert.Equal(t, tc.grant, a,
"%s should only grant its own listing operation", tc.policyAction)
}
identity := &Identity{Name: "bob"}
for _, a := range actions {
identity.Actions = append(identity.Actions, Action(a))
}
// The listing routes are gated on ACTION_READ, but the grant must
// not authorize object reads or anything else.
assert.False(t, identity.CanDo(ACTION_READ, "demo", "key"),
"a listing grant must not allow object reads")
assert.False(t, identity.CanDo(ACTION_WRITE, "demo", "key"),
"a listing grant must not allow writes")
assert.False(t, identity.CanDo(ACTION_LIST, "demo", ""),
"a listing grant must not allow ListObjects")
routed := func(url, object string) *http.Request {
return mux.SetURLVars(httptest.NewRequest(http.MethodGet, url, nil),
map[string]string{"bucket": "demo", "object": object})
}
req := routed(tc.url, tc.object)
assert.Equal(t, s3err.ErrNone, iam.VerifyActionPermission(req, identity, ACTION_READ, "demo", tc.object),
"the listing action should be authorized")
assert.Equal(t, s3err.ErrAccessDenied, iam.VerifyActionPermission(req, identity, ACTION_READ, "other", tc.object),
"the grant must stay scoped to the policy's bucket")
getReq := routed("/demo/key", "key")
assert.Equal(t, s3err.ErrAccessDenied, iam.VerifyActionPermission(getReq, identity, ACTION_READ, "demo", "key"),
"the grant must not authorize GetObject")
// Other ACTION_READ routes must not be authorized by a listing
// grant, whatever query parameters ride along.
policyReq := routed("/demo?policy", "")
assert.Equal(t, s3err.ErrAccessDenied, iam.VerifyActionPermission(policyReq, identity, ACTION_READ, "demo", ""),
"the grant must not authorize GetBucketPolicy")
})
}
grant := func(a string) *Identity { return &Identity{Name: "bob", Actions: []Action{Action(a)}} }
routed := func(url, object string) *http.Request {
return mux.SetURLVars(httptest.NewRequest(http.MethodGet, url, nil),
map[string]string{"bucket": "demo", "object": object})
}
// authRequestWithAuthType promotes ?prefix into the object for upload
// listings; the operation must still come from the route, not the prefix.
t.Run("upload listing with prefix", func(t *testing.T) {
req := routed("/demo?uploads&prefix=foo/", "")
assert.Equal(t, s3err.ErrNone,
iam.VerifyActionPermission(req, grant("ListBucketMultipartUploads:demo"), ACTION_READ, "demo", "foo/"))
assert.Equal(t, s3err.ErrAccessDenied,
iam.VerifyActionPermission(req, grant("ListMultipartUploadParts:demo"), ACTION_READ, "demo", "foo/"))
})
// The ?uploads route is registered before ?policy, so this reaches the
// upload listing handler and needs the upload listing grant.
t.Run("upload listing with extra subresource", func(t *testing.T) {
req := routed("/demo?policy&uploads", "")
assert.Equal(t, s3err.ErrNone,
iam.VerifyActionPermission(req, grant("ListBucketMultipartUploads:demo"), ACTION_READ, "demo", ""))
assert.Equal(t, s3err.ErrAccessDenied,
iam.VerifyActionPermission(req, grant("GetBucketPolicy:demo"), ACTION_READ, "demo", ""))
})
// Policies rebuilt from these grants must use valid action names.
for _, a := range []string{ACTION_LIST_MULTIPART_UPLOADS, ACTION_LIST_PARTS} {
assert.Equal(t, a, iamMapToIdentitiesAction(a))
}
}
// TestEmbeddedIamPutUserPolicyAllResourceWildcard reproduces issue #9209:
// an AWS-style policy using "Action":"s3:*" with a bare "Resource":"*" is
// valid AWS IAM syntax (meaning "any resource") but was rejected with
@@ -219,11 +219,6 @@ func (s3a *S3ApiServer) GetObjectAttributesHandler(w http.ResponseWriter, r *htt
}
}
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, s3_constants.S3_ACTION_GET_OBJECT_ATTRIBUTES, entry.Extended, "GetObjectAttributesHandler"); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// Evaluate conditional headers against the resolved entry (after version resolution)
// This ensures conditions are checked against the correct version, not always the latest
if s3a.hasConditionalHeaders(r) {
+5 -71
View File
@@ -202,14 +202,10 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request
// CopyObject requires s3:GetObject on the source in addition to s3:PutObject
// on the destination. The Auth middleware only checked the destination, so
// verify the source explicitly here. A first-phase deny decided without
// object tags can still flip on a tag-conditioned Allow, so when a bucket
// policy exists the tag-aware recheck gets the last word.
// verify the source explicitly here.
if errCode := s3a.authorizeCopySource(r, srcBucket, srcObject, srcVersionId); errCode != s3err.ErrNone {
if s3a.recheckPolicyForKeyWithLookup(r, http.MethodGet, srcBucket, srcObject, srcVersionId, string(s3_constants.ACTION_READ), "CopyObjectHandler") != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
s3err.WriteErrorResponse(w, r, errCode)
return
}
// A copy creates destination ownership and grants independently of the
@@ -242,11 +238,6 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request
return
}
if errCode := s3a.recheckCopySourcePolicy(r, srcBucket, srcObject, srcVersionId, entry.Extended, "CopyObjectHandler"); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// Cache remote-only sources before copying; otherwise the copy path below
// writes a destination with FileSize > 0 but no chunks/content.
if entry.IsInRemoteOnly() {
@@ -352,12 +343,6 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request
updatedEntry = cloneProtoEntry(rawEntry)
}
updatedEntry.Extended = mergeCopyMetadata(updatedEntry.Extended, updatedMetadata)
// The update keeps the object's existing lock state, but request
// lock headers still need validating so a disallowed combination
// is rejected rather than silently ignored.
if errCode := s3a.applyCopyObjectLockHeaders(r, dstBucket, dstObject, updatedEntry); errCode != s3err.ErrNone {
return errCode
}
if updatedEntry.Attributes == nil {
updatedEntry.Attributes = &filer_pb.FuseAttributes{}
}
@@ -461,12 +446,6 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request
if k == s3_constants.ExtAmzOwnerKey || k == s3_constants.ExtAmzAclKey {
continue
}
// Retention and legal hold are destination settings, not object
// metadata: the copy takes its own lock headers or the destination
// bucket's default instead.
if isCopyLockKey(k) {
continue
}
// Skip encryption-specific headers that might conflict with destination encryption type
skipHeader := false
@@ -503,11 +482,6 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request
// so REPLACE doesn't leak source values through the merge, as on self-copies.
dstEntry.Extended = mergeCopyMetadata(dstEntry.Extended, processedMetadata)
if errCode := s3a.applyCopyObjectLockHeaders(r, dstBucket, dstObject, dstEntry); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// For zero-size files or files without chunks, handle inline content
// This includes encrypted inline files that need decryption/re-encryption
if entry.Attributes.FileSize == 0 || len(entry.GetChunks()) == 0 {
@@ -616,39 +590,6 @@ func copyReplacesSourceEntry(sameDestination bool, dstVersioningState, srcVersio
return sameDestination && dstVersioningState == "" && srcVersionId == ""
}
// isCopyLockKey marks the extended attributes that carry object lock state; a
// copy never inherits them from the source.
func isCopyLockKey(key string) bool {
switch key {
case s3_constants.ExtObjectLockModeKey, s3_constants.ExtRetentionUntilDateKey, s3_constants.ExtLegalHoldKey:
return true
}
return false
}
// applyCopyObjectLockHeaders resolves the copied object's retention: explicit
// copy-request lock headers win, then the destination bucket's default, and
// the source's own retention never carries over.
func (s3a *S3ApiServer) applyCopyObjectLockHeaders(r *http.Request, dstBucket, dstObject string, dstEntry *filer_pb.Entry) s3err.ErrorCode {
objectLockEnabled, lockErr := s3a.isObjectLockEnabled(dstBucket)
if lockErr != nil && !errors.Is(lockErr, filer_pb.ErrNotFound) {
glog.Errorf("CopyObjectHandler: failed to check object lock for bucket %s: %v", dstBucket, lockErr)
return s3err.ErrInternalError
}
if validationErr := s3a.validateObjectLockHeaders(r, dstBucket, dstObject, objectLockEnabled); validationErr != nil {
glog.V(2).Infof("CopyObjectHandler: object lock header validation failed for %s/%s: %v", dstBucket, dstObject, validationErr)
return mapValidationErrorToS3Error(validationErr)
}
if !objectLockEnabled {
return s3err.ErrNone
}
if err := s3a.extractObjectLockMetadataFromRequest(r, dstEntry); err != nil {
glog.Errorf("CopyObjectHandler: failed to extract object lock metadata for %s/%s: %v", dstBucket, dstObject, err)
return mapValidationErrorToS3Error(err)
}
return s3err.ErrNone
}
func cloneProtoEntry(entry *filer_pb.Entry) *filer_pb.Entry {
if entry == nil {
return nil
@@ -990,10 +931,8 @@ func (s3a *S3ApiServer) CopyObjectPartHandler(w http.ResponseWriter, r *http.Req
// UploadPartCopy requires s3:GetObject on the source. The Auth middleware
// only verified s3:PutObject (s3:UploadPart) on the destination.
if errCode := s3a.authorizeCopySource(r, srcBucket, srcObject, srcVersionId); errCode != s3err.ErrNone {
if s3a.recheckPolicyForKeyWithLookup(r, http.MethodGet, srcBucket, srcObject, srcVersionId, string(s3_constants.ACTION_READ), "CopyObjectPartHandler") != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
s3err.WriteErrorResponse(w, r, errCode)
return
}
partIDString := r.URL.Query().Get("partNumber")
@@ -1062,11 +1001,6 @@ func (s3a *S3ApiServer) CopyObjectPartHandler(w http.ResponseWriter, r *http.Req
return
}
if errCode := s3a.recheckCopySourcePolicy(r, srcBucket, srcObject, srcVersionId, entry.Extended, "CopyObjectPartHandler"); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// Cache remote-only sources before copying; the part-copy paths below
// iterate entry.GetChunks() and would otherwise produce an empty part.
if entry.IsInRemoteOnly() {
-347
View File
@@ -1,347 +0,0 @@
package s3api
import (
"archive/zip"
"bytes"
"crypto/md5"
"encoding/base64"
"encoding/json"
"errors"
"hash"
"io"
"mime"
"net/http"
"path"
"strconv"
"strings"
"github.com/gorilla/mux"
"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/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
func isExtractZip(r *http.Request) bool {
if r.URL.Query().Get("extract") == "zip" ||
r.Header.Get(s3_constants.SeaweedFSExtract) == "zip" {
return true
}
// AWS Snowball auto-extract: header presence requests extraction.
if v := r.Header.Get(s3_constants.AmzMetaSnowballAutoExtract); v != "" && v != "false" {
return true
}
return false
}
func hasExplicitSseHeaders(r *http.Request) bool {
return r.Header.Get(s3_constants.AmzServerSideEncryption) != "" ||
r.Header.Get(s3_constants.AmzServerSideEncryptionAwsKmsKeyId) != "" ||
r.Header.Get(s3_constants.AmzServerSideEncryptionCustomerAlgorithm) != ""
}
type extractMemberResult struct {
Name string `json:"name"`
Size int64 `json:"size,omitempty"`
ETag string `json:"etag,omitempty"`
}
type extractZipResult struct {
Extracted []*extractMemberResult `json:"extracted"`
Errors []string `json:"errors,omitempty"`
}
// extractZipObject writes a PutObject zip body as one object per member under
// the object's directory, like AWS Snowball's x-amz-meta-snowball-auto-extract.
// The archive object itself is not stored.
func (s3a *S3ApiServer) extractZipObject(w http.ResponseWriter, r *http.Request, bucket, object string, dataReader io.Reader) {
if hasExplicitSseHeaders(r) {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
versioningState, err := s3a.getVersioningState(bucket)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
if !s3a.handleAutoCreateBucket(w, r, bucket, "PutObjectHandler") {
return
}
versioningState, err = s3a.getVersioningState(bucket)
}
if err != nil {
glog.Errorf("extractZipObject: versioning state for bucket %s: %v", bucket, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
}
versioningConfigured := versioningState != ""
r, aclCode := s3a.preparePutObjectACL(r, bucket)
if aclCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, aclCode)
return
}
objectLockEnabled, lockErr := s3a.isObjectLockEnabled(bucket)
if lockErr != nil && !errors.Is(lockErr, filer_pb.ErrNotFound) {
glog.Errorf("extractZipObject: object lock state for bucket %s: %v", bucket, lockErr)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
// Malformed or unauthorized lock headers fail the request outright, the
// same as a standalone PutObject; per-member permission re-checks happen in
// authorizeMember.
if vErr := s3a.validateObjectLockHeaders(r, bucket, object, objectLockEnabled); vErr != nil {
s3err.WriteErrorResponse(w, r, mapValidationErrorToS3Error(vErr))
return
}
// Checksums describing the archive body are verified while it spools, then
// scrubbed so members are validated against their own bytes instead.
requestQuery := parseRequestQuery(r)
archiveMd5 := r.Header.Get("Content-Md5")
var archiveMd5Hash hash.Hash
if archiveMd5 != "" {
archiveMd5Hash = md5.New()
dataReader = io.TeeReader(dataReader, archiveMd5Hash)
}
archiveChecksumAlgo, archiveChecksumHeader, checksumCode := detectRequestedChecksumAlgorithmQ(r, requestQuery)
if checksumCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, checksumCode)
return
}
var archiveChecksumHash hash.Hash
expectedArchiveChecksum := ""
if archiveChecksumAlgo != ChecksumAlgorithmNone {
if archiveChecksumHash = getCheckSumWriter(archiveChecksumAlgo); archiveChecksumHash != nil {
dataReader = io.TeeReader(dataReader, archiveChecksumHash)
}
expectedArchiveChecksum = lookupHeaderOrQuery(r, requestQuery, archiveChecksumHeader)
}
scrubArchiveChecksums(r)
// Content-Encoding describes the archive body on the wire; the members it
// fans out to are stored as-is.
r.Header.Del("Content-Encoding")
ra, size, cleanup, err := filer.SpoolZipReader(dataReader, 0)
if err != nil {
if errors.Is(err, filer.ErrZipTooLarge) {
s3err.WriteErrorResponse(w, r, s3err.ErrEntityTooLarge)
} else if strings.Contains(err.Error(), s3err.ErrMsgContentSha256Mismatch) {
s3err.WriteErrorResponse(w, r, s3err.ErrContentSHA256Mismatch)
} else {
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
}
return
}
defer cleanup()
if errCode := verifyArchiveChecksums(archiveMd5, archiveMd5Hash, expectedArchiveChecksum, archiveChecksumHash); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
zr, err := zip.NewReader(ra, size)
if err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
if err = filer.CheckZipLimits(zr); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
root := object
if !strings.HasSuffix(root, "/") {
root = path.Dir(root)
}
root = strings.TrimSuffix(root, "/")
if root == "." {
root = ""
}
identity, _ := s3_constants.GetIdentityFromContext(r).(*Identity)
result := &extractZipResult{}
join := func(rel string) string {
if root == "" {
return rel
}
return root + "/" + rel
}
// authorizeMember applies to a member key the same checks a standalone
// PutObject of that key would face: IAM, table layout, bucket policy,
// object-lock header permissions, and existing-entry protections.
authorizeMember := func(memberKey string) s3err.ErrorCode {
if code := s3a.iam.AuthorizeObjectWrite(r, identity, bucket, memberKey); code != s3err.ErrNone {
return code
}
if err := s3a.validateTableBucketObjectPath(bucket, memberKey); err != nil {
return s3err.ErrAccessDenied
}
if code, _ := s3a.checkPolicyWithEntry(r, bucket, memberKey, string(s3_constants.ACTION_WRITE), "", nil); code != s3err.ErrNone {
return code
}
// Lock headers validated for the request still need the dedicated
// permissions against the member's own key.
if r.Header.Get(s3_constants.AmzObjectLockMode) != "" || r.Header.Get(s3_constants.AmzObjectLockRetainUntilDate) != "" {
if !s3a.checkObjectLockHeaderPermission(r, bucket, memberKey, s3_constants.ACTION_PUT_OBJECT_RETENTION) {
return s3err.ErrAccessDenied
}
}
if r.Header.Get(s3_constants.AmzObjectLockLegalHold) != "" {
if !s3a.checkObjectLockHeaderPermission(r, bucket, memberKey, s3_constants.ACTION_PUT_OBJECT_LEGAL_HOLD) {
return s3err.ErrAccessDenied
}
}
if !versioningConfigured {
if lockErr := s3a.enforceObjectLockProtections(r, bucket, memberKey, "", s3a.evaluateGovernanceBypassRequest(r, bucket, memberKey)); lockErr != nil {
return s3err.ErrAccessDenied
}
}
return s3err.ErrNone
}
writeMember := func(rel string, memberReader io.Reader, memberSize int64) {
memberKey := join(rel)
if authCode := authorizeMember(memberKey); authCode != s3err.ErrNone {
result.Errors = append(result.Errors, memberKey+": "+s3err.GetAPIError(authCode).Code)
return
}
memberContentType := mime.TypeByExtension(path.Ext(memberKey))
if memberContentType == "" {
memberContentType = "application/octet-stream"
}
// Write helpers read the object from route vars, e.g. for the SSE-KMS
// encryption context ARN; point them at the member, not the archive.
memberReq := mux.SetURLVars(r, map[string]string{
"bucket": bucket,
"object": memberKey,
})
memberReq.Header.Set("Content-Type", memberContentType)
memberReq.ContentLength = memberSize
var etag string
var errCode s3err.ErrorCode
switch versioningState {
case s3_constants.VersioningEnabled:
_, etag, errCode, _ = s3a.putVersionedObject(memberReq, bucket, memberKey, memberReader, memberContentType)
case s3_constants.VersioningSuspended:
etag, errCode, _ = s3a.putSuspendedVersioningObject(memberReq, bucket, memberKey, memberReader, memberContentType)
default:
filePath := s3a.toFilerPath(bucket, memberKey)
lifecycleTTLSec := s3a.lifecycleTTLForObjectWrite(bucket, memberKey, memberSize)
etag, errCode, _ = s3a.putToFiler(memberReq, filePath, memberReader, bucket, memberKey, 1, lifecycleTTLSec, nil, false, "")
}
if errCode != s3err.ErrNone {
result.Errors = append(result.Errors, memberKey+": "+s3err.GetAPIError(errCode).Code)
return
}
result.Extracted = append(result.Extracted, &extractMemberResult{Name: memberKey, Size: memberSize, ETag: etag})
}
mkdirMember := func(memberKey string) {
if authCode := authorizeMember(memberKey); authCode != s3err.ErrNone {
result.Errors = append(result.Errors, memberKey+": "+s3err.GetAPIError(authCode).Code)
return
}
dirPath := s3a.toFilerPath(bucket, memberKey)
dirName, entryName := path.Dir(dirPath), path.Base(dirPath)
// mkdir replaces an existing marker outright, so a lock recorded on the
// entry itself is checked under the same lock the replacement takes,
// mirroring the explicit-directory branch of PutObjectHandler.
markerCode := s3a.withObjectWriteLock(bucket, memberKey, func() s3err.ErrorCode {
if !objectLockEnabled {
return s3err.ErrNone
}
existing, existErr := s3a.getEntry(dirName, entryName)
if existErr != nil {
return s3err.ErrNone
}
if lockErr := s3a.enforceObjectLockOnEntry(existing, bucket, memberKey, "", s3a.evaluateGovernanceBypassRequest(r, bucket, memberKey)); lockErr != nil {
return s3err.ErrAccessDenied
}
return s3err.ErrNone
}, func() s3err.ErrorCode {
mkErr := s3a.mkdir(dirName, entryName, func(entry *filer_pb.Entry) {
entry.Attributes.Mime = s3_constants.FolderMimeType
s3a.setObjectOwnerFromRequest(r, bucket, entry)
applyPutObjectACL(r, entry)
if lockErr := s3a.extractObjectLockMetadataFromRequest(r, entry); lockErr != nil {
glog.Errorf("extractZipObject: object lock metadata for %s/%s: %v", bucket, memberKey, lockErr)
}
})
return filerErrorToS3Error(mkErr)
})
if markerCode != s3err.ErrNone {
result.Errors = append(result.Errors, memberKey+": "+s3err.GetAPIError(markerCode).Code)
}
}
for _, zf := range zr.File {
if ctxErr := r.Context().Err(); ctxErr != nil {
result.Errors = append(result.Errors, ctxErr.Error())
break
}
rel, ok := filer.SanitizeZipMemberName(zf)
if !ok {
result.Errors = append(result.Errors, zf.Name+": unsafe member name")
continue
}
if zf.FileInfo().IsDir() {
mkdirMember(join(rel))
continue
}
memberReader, openErr := zf.Open()
if openErr != nil {
result.Errors = append(result.Errors, rel+": "+openErr.Error())
continue
}
writeMember(rel, memberReader, int64(zf.UncompressedSize64))
memberReader.Close()
}
w.Header().Set(s3_constants.SeaweedFSExtractedCount, strconv.Itoa(len(result.Extracted)))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(result)
}
// verifyArchiveChecksums compares the archive's supplied Content-Md5 and
// x-amz-checksum-* values against the bytes consumed while spooling. A nil
// hash means the client supplied nothing to check.
func verifyArchiveChecksums(expectedMd5 string, md5Hash hash.Hash, expectedChecksum string, checksumHash hash.Hash) s3err.ErrorCode {
if md5Hash != nil {
expected, err := base64.StdEncoding.DecodeString(expectedMd5)
if err != nil {
return s3err.ErrInvalidDigest
}
if sum := md5Hash.Sum(nil); !bytes.Equal(expected, sum) {
return s3err.ErrBadDigest
}
}
if checksumHash != nil && expectedChecksum != "" {
if sum := base64.StdEncoding.EncodeToString(checksumHash.Sum(nil)); expectedChecksum != sum {
return s3err.ErrBadDigest
}
}
return s3err.ErrNone
}
// scrubArchiveChecksums removes request checksums that describe the archive
// body; members are validated against their own bytes.
func scrubArchiveChecksums(r *http.Request) {
isChecksumHeader := func(k string) bool {
k = strings.ToLower(k)
return k == "content-md5" || k == "x-amz-trailer" || k == "x-amz-content-sha256" ||
strings.HasPrefix(k, "x-amz-checksum") || strings.HasPrefix(k, "x-amz-sdk-checksum")
}
for k := range r.Header {
if isChecksumHeader(k) {
delete(r.Header, k)
}
}
query := r.URL.Query()
for k := range query {
if isChecksumHeader(k) {
query.Del(k)
}
}
r.URL.RawQuery = query.Encode()
}
@@ -1,193 +0,0 @@
package s3api
import (
"archive/zip"
"bytes"
"crypto/md5"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
func makeZipArchive(t *testing.T, members map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, content := range members {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func newExtractTestServer(t *testing.T) (*S3ApiServer, *ambiguousPutFiler) {
t.Helper()
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}, apply: true}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
s3a.iam = NewIdentityAccessManagementWithStore(s3a.option, nil, "memory")
t.Cleanup(s3a.iam.Shutdown)
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
s3a.bucketConfigCache.Set("b", &BucketConfig{Name: "b"})
return s3a, filerImpl
}
func TestIsExtractZip(t *testing.T) {
for _, tc := range []struct {
name string
setup func(r *http.Request)
want bool
}{
{"none", func(r *http.Request) {}, false},
{"query", func(r *http.Request) {
q := r.URL.Query()
q.Set("extract", "zip")
r.URL.RawQuery = q.Encode()
}, true},
{"seaweed header", func(r *http.Request) {
r.Header.Set(s3_constants.SeaweedFSExtract, "zip")
}, true},
{"snowball header", func(r *http.Request) {
r.Header.Set(s3_constants.AmzMetaSnowballAutoExtract, "true")
}, true},
{"snowball false", func(r *http.Request) {
r.Header.Set(s3_constants.AmzMetaSnowballAutoExtract, "false")
}, false},
} {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPut, "/b/o", nil)
tc.setup(r)
if got := isExtractZip(r); got != tc.want {
t.Fatalf("isExtractZip = %v, want %v", got, tc.want)
}
})
}
}
func TestExtractZipPutObject(t *testing.T) {
s3a, filerImpl := newExtractTestServer(t)
body := makeZipArchive(t, map[string]string{
"a.txt": "hello",
"sub/b.txt": "world!",
})
r := httptest.NewRequest(http.MethodPut, "/b/dir/archive.zip", bytes.NewReader(body))
r.Header.Set(s3_constants.AmzMetaSnowballAutoExtract, "true")
rec := httptest.NewRecorder()
s3a.extractZipObject(rec, r, "b", "dir/archive.zip", r.Body)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get(s3_constants.SeaweedFSExtractedCount); got != "2" {
t.Fatalf("extracted count = %q, want 2", got)
}
var result extractZipResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if len(result.Errors) != 0 {
t.Fatalf("unexpected member errors: %v", result.Errors)
}
for _, want := range []string{"/buckets/b/dir/a.txt", "/buckets/b/dir/sub/b.txt"} {
entry, ok := filerImpl.entries[want]
if !ok {
t.Fatalf("member %s was not created; entries: %v", want, filerImpl.entries)
}
if entry.IsDirectory {
t.Fatalf("member %s stored as directory", want)
}
}
if _, ok := filerImpl.entries["/buckets/b/dir/archive.zip"]; ok {
t.Fatal("archive object itself should not be stored")
}
}
func TestExtractZipPutObjectArchiveChecksum(t *testing.T) {
s3a, filerImpl := newExtractTestServer(t)
body := makeZipArchive(t, map[string]string{"a.txt": "hello"})
md5sum := md5.Sum(body)
r := httptest.NewRequest(http.MethodPut, "/b/dir/archive.zip", bytes.NewReader(body))
r.Header.Set(s3_constants.SeaweedFSExtract, "zip")
r.Header.Set("Content-Md5", base64.StdEncoding.EncodeToString(md5sum[:]))
r.Header.Set(s3_constants.AmzChecksumSHA256, base64.StdEncoding.EncodeToString([]byte("not-the-sha256")))
rec := httptest.NewRecorder()
s3a.extractZipObject(rec, r, "b", "dir/archive.zip", r.Body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if len(filerImpl.entries) != 0 {
t.Fatalf("entries created despite checksum mismatch: %v", filerImpl.entries)
}
}
func TestExtractZipPutObjectGoodArchiveMd5(t *testing.T) {
s3a, filerImpl := newExtractTestServer(t)
body := makeZipArchive(t, map[string]string{"a.txt": "hello"})
md5sum := md5.Sum(body)
r := httptest.NewRequest(http.MethodPut, "/b/dir/archive.zip", bytes.NewReader(body))
r.Header.Set(s3_constants.SeaweedFSExtract, "zip")
r.Header.Set("Content-Md5", base64.StdEncoding.EncodeToString(md5sum[:]))
rec := httptest.NewRecorder()
s3a.extractZipObject(rec, r, "b", "dir/archive.zip", r.Body)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if _, ok := filerImpl.entries["/buckets/b/dir/a.txt"]; !ok {
t.Fatalf("member not created; entries: %v", filerImpl.entries)
}
}
func TestExtractZipPutObjectNotAZip(t *testing.T) {
s3a, filerImpl := newExtractTestServer(t)
r := httptest.NewRequest(http.MethodPut, "/b/dir/archive.zip", bytes.NewReader([]byte("not a zip")))
r.Header.Set(s3_constants.SeaweedFSExtract, "zip")
rec := httptest.NewRecorder()
s3a.extractZipObject(rec, r, "b", "dir/archive.zip", r.Body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if len(filerImpl.entries) != 0 {
t.Fatalf("entries created for invalid zip: %v", filerImpl.entries)
}
}
func TestExtractZipPutObjectRejectsExplicitSSE(t *testing.T) {
s3a, filerImpl := newExtractTestServer(t)
body := makeZipArchive(t, map[string]string{"a.txt": "hello"})
r := httptest.NewRequest(http.MethodPut, "/b/dir/archive.zip", bytes.NewReader(body))
r.Header.Set(s3_constants.SeaweedFSExtract, "zip")
r.Header.Set(s3_constants.AmzServerSideEncryption, "aws:kms")
rec := httptest.NewRecorder()
s3a.extractZipObject(rec, r, "b", "dir/archive.zip", r.Body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if len(filerImpl.entries) != 0 {
t.Fatalf("entries created despite explicit SSE headers: %v", filerImpl.entries)
}
}
+5 -26
View File
@@ -539,18 +539,6 @@ 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 != "" {
@@ -801,9 +789,6 @@ type ListingCursor struct {
// resolvePendingNulls settles trailing null objects whose .versions sibling
// has not streamed yet before a page is declared full.
resolvePendingNulls func() error
// 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
@@ -1139,21 +1124,15 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
// 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.
// prefix. Surface it as a directory marker for that explicit probe,
// identical to a directory created via PutObject with a trailing "/", so
// tools like hadoop-aws can find it. 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
}
entry.Attributes.Mime = s3_constants.FolderMimeType
eachEntryFn(dir, entry)
}
// println("doListFilerEntries2 dir", dir+"/"+entry.Name, "subNextMarker", subNextMarker)
@@ -3,11 +3,10 @@ package s3api
import (
"context"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDirectoryListedAsCommonPrefix tests that regular directories (without MIME)
@@ -68,8 +67,8 @@ func TestDirectoryListedAsCommonPrefix(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.
// "<dir>/" prefix. Such a directory is surfaced as a directory marker, identical to
// one created via PutObject with a trailing "/".
func TestEmptyDirectorySurfacedAsMarker(t *testing.T) {
s3a := &S3ApiServer{
option: &S3ApiServerOption{BucketsPath: "/buckets"},
@@ -97,10 +96,9 @@ func TestEmptyDirectorySurfacedAsMarker(t *testing.T) {
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")
assert.True(t, seen[0].IsDirectoryKeyObject(),
"empty directory must be marked as a directory key object so it lists as logs/")
assert.Equal(t, s3_constants.FolderMimeType, seen[0].Attributes.Mime)
}
}
@@ -184,30 +182,3 @@ func TestEmptyDirectoryHiddenInFlatListing(t *testing.T) {
assert.NoError(t, err)
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 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: ""}},
},
"/buckets/test/locks": {},
}}
s3a := newFailoverTestServer(t, startFakeFiler(t, filer))
s3a.option.BucketsPath = "/buckets"
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
s3a.bucketConfigCache.Set("test", &BucketConfig{Name: "test"})
response, err := s3a.listFilerEntries(context.Background(), listObjectsRequest{
bucket: "test",
prefix: "locks/",
delimiter: "/",
maxKeys: 1000,
})
require.NoError(t, err)
assert.Empty(t, response.Contents, "an empty directory is not a readable key")
require.Len(t, response.CommonPrefixes, 1)
assert.Equal(t, "locks/", response.CommonPrefixes[0].Prefix)
}
+3 -28
View File
@@ -154,11 +154,6 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request)
}
defer dataReader.Close()
if isExtractZip(r) {
s3a.extractZipObject(w, r, bucket, object, dataReader)
return
}
objectContentType := r.Header.Get("Content-Type")
if strings.HasSuffix(object, "/") && r.ContentLength <= 1024 {
// The filer mkdir below would implicitly create a missing bucket, so gate it
@@ -483,10 +478,6 @@ type putFinalize struct {
lockKey string
mutations []*filer_pb.ObjectMutation
afterCreate func(entry *filer_pb.Entry) s3err.ErrorCode
// conditionAltKey names the entry a routed WriteCondition falls back to
// when the write path is absent — a suspended-versioning write points it
// at the latest version so check and write stay under the filer lock
conditionAltKey string
}
const (
@@ -1019,19 +1010,15 @@ func (s3a *S3ApiServer) putToFiler(r *http.Request, filePath string, dataReader
routed := false
routedAmbiguous := false
var owner pb.ServerAddress
condAltKey := ""
if finalize != nil {
condAltKey = finalize.conditionAltKey
}
if owner = s3a.routableWriteOwner(bucket, object); owner != "" {
if cond, ok := routeWriteCondition(r, uniqueWritePath && condAltKey == ""); ok {
if cond, ok := routeWriteCondition(r, uniqueWritePath); ok {
// Routed mutations ride in the PUT's transaction (committing atomically),
// so lockKey is the object path they carry, not the version file path.
lockKey, finalizeMutations := filePath, []*filer_pb.ObjectMutation(nil)
if finalize != nil && len(finalize.mutations) > 0 {
lockKey, finalizeMutations = finalize.lockKey, finalize.mutations
}
resp, err := s3a.routedPut(owner, s3a.objectRouteKey(bucket, object), lockKey, filePath, entry, cond, "", condAltKey, finalizeMutations)
resp, err := s3a.routedPut(owner, s3a.objectRouteKey(bucket, object), lockKey, filePath, entry, cond, "", finalizeMutations)
switch {
case err != nil:
routedAmbiguous = true
@@ -1837,19 +1824,7 @@ func (s3a *S3ApiServer) putSuspendedVersioningObject(r *http.Request, bucket, ob
// naming a marker that is gone, and the read path heals that by promoting an older
// version, republishing the deleted key. Best-effort, as a stale flag self-heals on
// the next list.
// A conditional suspended write checks "the null slot if present, else the
// latest version". Naming the pointed-at version as the fallback lets the
// routed transaction evaluate that rule under the filer lock instead of
// splitting check and write across a gateway lock that routed writes skip.
conditionAltKey := ""
if headers, headersErr := parseConditionalHeaders(r); headersErr == s3err.ErrNone && headers.isSet {
// The filer resolves the latest pointer under the same lock that
// serializes the write, so the condition cannot read a version a
// concurrent delete has already displaced.
conditionAltKey = s3a.toFilerPath(bucket, normalizedObject+s3_constants.VersionsFolder)
}
etag, errCode, sseMetadata = s3a.putToFiler(r, filePath, body, bucket, normalizedObject, 1, 0, &putFinalize{
conditionAltKey: conditionAltKey,
afterCreate: func(entry *filer_pb.Entry) s3err.ErrorCode {
writtenETag := string(entry.Extended[s3_constants.ExtETagKey])
if err := s3a.finalizeSuspendedNullWrite("", bucket, normalizedObject, s3_constants.ExtETagKey, writtenETag); err != nil {
@@ -1857,7 +1832,7 @@ func (s3a *S3ApiServer) putSuspendedVersioningObject(r *http.Request, bucket, ob
}
return s3err.ErrNone
},
}, true, "")
}, false, "")
if errCode != s3err.ErrNone {
glog.Errorf("putSuspendedVersioningObject: failed to upload object: %v", errCode)
return "", errCode, SSEResponseMetadata{}
@@ -8,12 +8,9 @@ import (
"net"
"net/http"
"net/http/httptest"
"path"
"slices"
"strings"
"sync"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
@@ -21,12 +18,10 @@ import (
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
@@ -117,8 +112,6 @@ type ambiguousPutFiler struct {
mu sync.Mutex
entries map[string]*filer_pb.Entry
txnCount int
txnCheck func(req *filer_pb.ObjectTransactionRequest)
apply bool
createErr error
respError string
@@ -170,92 +163,6 @@ func (f *ambiguousPutFiler) LookupDirectoryEntry(_ context.Context, req *filer_p
return &filer_pb.LookupDirectoryEntryResponse{}, nil
}
// ObjectTransaction applies the WriteCondition against the entry the condition
// key names, the way a real filer evaluates it under the path lock, then
// applies PUT mutations. Routed-write tests use it to catch a condition sent
// for an entry the gateway's conditional check never looked at.
func (f *ambiguousPutFiler) ObjectTransaction(_ context.Context, req *filer_pb.ObjectTransactionRequest) (*filer_pb.ObjectTransactionResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.txnCount++
if f.txnCheck != nil {
f.txnCheck(req)
}
key := req.LockKey
if req.ConditionKey != "" {
key = req.ConditionKey
}
current := f.entries[key]
if current == nil && req.ConditionAltKey != "" {
// Mirrors the filer's fallback: the alt key is the object's .versions
// directory and the entry under test is the one its latest pointer
// names; a delete marker resolves to absent.
if versionsEntry := f.entries[req.ConditionAltKey]; versionsEntry != nil {
if latest := string(versionsEntry.Extended[s3_constants.ExtLatestVersionFileNameKey]); latest != "" {
key = req.ConditionAltKey + "/" + latest
current = f.entries[key]
if current != nil && string(current.Extended[s3_constants.ExtDeleteMarkerKey]) == "true" {
current = nil
}
}
}
}
for _, clause := range req.GetCondition().GetClauses() {
var md5Hex string
if current != nil && current.Attributes != nil {
md5Hex = fmt.Sprintf("%x", current.Attributes.Md5)
}
ok := true
switch clause.Kind {
case filer_pb.WriteCondition_IF_NOT_EXISTS:
ok = current == nil
case filer_pb.WriteCondition_IF_EXISTS:
ok = current != nil
case filer_pb.WriteCondition_IF_ETAG_MATCH:
ok = current != nil && slices.Contains(clause.Etags, md5Hex)
case filer_pb.WriteCondition_IF_ETAG_NOT_MATCH:
ok = current == nil || !slices.Contains(clause.Etags, md5Hex)
}
if !ok {
return &filer_pb.ObjectTransactionResponse{ErrorCode: filer_pb.FilerError_PRECONDITION_FAILED}, nil
}
}
for _, m := range req.Mutations {
if m.Type == filer_pb.ObjectMutation_PUT {
f.entries[m.Directory+"/"+m.Entry.Name] = proto.Clone(m.Entry).(*filer_pb.Entry)
}
}
return &filer_pb.ObjectTransactionResponse{}, nil
}
func (f *ambiguousPutFiler) ListEntries(req *filer_pb.ListEntriesRequest, stream filer_pb.SeaweedFiler_ListEntriesServer) error {
f.mu.Lock()
defer f.mu.Unlock()
for fullPath, entry := range f.entries {
if path.Dir(fullPath) != req.Directory {
continue
}
if req.Prefix != "" && !strings.HasPrefix(entry.Name, req.Prefix) {
continue
}
if req.StartFromFileName != "" && (entry.Name < req.StartFromFileName ||
(entry.Name == req.StartFromFileName && !req.InclusiveStartFrom)) {
continue
}
if err := stream.Send(&filer_pb.ListEntriesResponse{Entry: proto.Clone(entry).(*filer_pb.Entry)}); err != nil {
return err
}
}
return nil
}
func (f *ambiguousPutFiler) UpdateEntry(_ context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.entries[req.Directory+"/"+req.Entry.Name] = proto.Clone(req.Entry).(*filer_pb.Entry)
return &filer_pb.UpdateEntryResponse{}, nil
}
func (f *ambiguousPutFiler) LookupVolume(_ context.Context, req *filer_pb.LookupVolumeRequest) (*filer_pb.LookupVolumeResponse, error) {
resp := &filer_pb.LookupVolumeResponse{LocationsMap: map[string]*filer_pb.Locations{}}
for _, vid := range req.VolumeIds {
@@ -500,115 +407,3 @@ func TestFetchChunkDataDecryptsVolumeCipher(t *testing.T) {
t.Fatalf("ranged plain read = %q, want %q", got, plaintext[5:9])
}
}
// A suspended-versioning overwrite resolves the conditional check against the
// entry the read path serves: the null slot when present, else the latest
// version under .versions/. The routed transaction carries that version as the
// condition's fallback key so check and write stay under the filer lock —
// checking the empty null-version slot alone would reject a matching If-Match.
func TestSuspendedPutIfMatchRoutesWithConditionAltKey(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{
volume: volume,
entries: map[string]*filer_pb.Entry{
"/buckets/b": {
Name: "b",
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{},
Extended: map[string][]byte{s3_constants.ExtVersioningKey: []byte(s3_constants.VersioningSuspended)},
},
"/buckets/b/k.versions": {
Name: "k.versions",
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{},
Extended: map[string][]byte{
s3_constants.ExtLatestVersionIdKey: []byte("v1"),
s3_constants.ExtLatestVersionFileNameKey: []byte("v_v1"),
},
},
"/buckets/b/k.versions/v_v1": {
Name: "v_v1",
Attributes: &filer_pb.FuseAttributes{Md5: []byte{0xab, 0xc1, 0x23}, FileSize: 1},
Extended: map[string][]byte{
s3_constants.ExtETagKey: []byte("abc123"),
s3_constants.ExtVersionIdKey: []byte("v1"),
},
},
},
apply: true,
}
filerAddr := startFakeFiler(t, filerImpl)
s3a := newPutTestServer(t, filerAddr)
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
s3a.objectWriteLockClient = cluster.NewLockClient(s3a.option.GrpcDialOption, filerAddr)
s3a.objectWriteLockClient.SetRing([]pb.ServerAddress{filerAddr}, 1)
filerImpl.txnCheck = func(req *filer_pb.ObjectTransactionRequest) {
if req.ConditionAltKey != "/buckets/b/k.versions" {
t.Fatalf("condition alt key = %q, want /buckets/b/k.versions", req.ConditionAltKey)
}
}
r := httptest.NewRequest(http.MethodPut, "/b/k", strings.NewReader("new"))
r.Header.Set(s3_constants.IfMatch, `"abc123"`)
_, code, _ := s3a.putSuspendedVersioningObject(r, "b", "k", r.Body, "")
if code != s3err.ErrNone {
t.Fatalf("suspended If-Match PUT = %v", code)
}
if filerImpl.entries["/buckets/b/k"] == nil {
t.Fatal("null-version entry was not created")
}
if filerImpl.txnCount != 1 {
t.Fatalf("conditional suspended write should route one transaction, got %d", filerImpl.txnCount)
}
}
// When the latest version is a delete marker, a suspended If-None-Match: *
// write must treat the object as absent — the fallback resolves a tombstone
// to "no current version", not an existing one.
func TestSuspendedPutIfNoneMatchWithDeleteMarkerLatest(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{
volume: volume,
entries: map[string]*filer_pb.Entry{
"/buckets/b": {
Name: "b",
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{},
Extended: map[string][]byte{s3_constants.ExtVersioningKey: []byte(s3_constants.VersioningSuspended)},
},
"/buckets/b/k.versions": {
Name: "k.versions",
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{},
Extended: map[string][]byte{
s3_constants.ExtLatestVersionFileNameKey: []byte("v_v1"),
},
},
"/buckets/b/k.versions/v_v1": {
Name: "v_v1",
Attributes: &filer_pb.FuseAttributes{FileSize: 0},
Extended: map[string][]byte{
s3_constants.ExtVersionIdKey: []byte("v1"),
s3_constants.ExtDeleteMarkerKey: []byte("true"),
},
},
},
apply: true,
}
filerAddr := startFakeFiler(t, filerImpl)
s3a := newPutTestServer(t, filerAddr)
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
s3a.objectWriteLockClient = cluster.NewLockClient(s3a.option.GrpcDialOption, filerAddr)
s3a.objectWriteLockClient.SetRing([]pb.ServerAddress{filerAddr}, 1)
r := httptest.NewRequest(http.MethodPut, "/b/k", strings.NewReader("new"))
r.Header.Set(s3_constants.IfNoneMatch, "*")
_, code, _ := s3a.putSuspendedVersioningObject(r, "b", "k", r.Body, "")
if code != s3err.ErrNone {
t.Fatalf("suspended If-None-Match PUT over a delete-marker latest = %v, want success", code)
}
if filerImpl.entries["/buckets/b/k"] == nil {
t.Fatal("null-version entry was not created")
}
}
+2 -16
View File
@@ -77,14 +77,8 @@ func (s3a *S3ApiServer) RenameObjectHandler(w http.ResponseWriter, r *http.Reque
// is what the request URL names. The source arrives in a header and loses
// its key, so it needs both read and delete permission checked here.
if errCode := s3a.authorizeRenameSource(r, bucket, srcObject); errCode != s3err.ErrNone {
// Same tag-conditioned-Allow deferral as the copy path: the first
// phase ran without the source's tags, so a tag-gated Allow can still
// grant both the read and the delete the rename performs.
if s3a.recheckPolicyForKeyWithLookup(r, http.MethodGet, bucket, srcObject, "", string(s3_constants.ACTION_READ), "RenameObjectHandler") != s3err.ErrNone ||
s3a.recheckPolicyForKeyWithLookup(r, http.MethodDelete, bucket, srcObject, "", string(s3_constants.ACTION_WRITE), "RenameObjectHandler") != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
s3err.WriteErrorResponse(w, r, errCode)
return
}
versioningState, err := s3a.getVersioningState(bucket)
@@ -121,14 +115,6 @@ func (s3a *S3ApiServer) RenameObjectHandler(w http.ResponseWriter, r *http.Reque
if srcErrCode != s3err.ErrNone {
return srcErrCode
}
if errCode := s3a.recheckCopySourcePolicy(r, bucket, srcObject, "", entry.Extended, "RenameObjectHandler"); errCode != s3err.ErrNone {
return errCode
}
// The rename also deletes the source; a tag-gated Deny on the delete
// applies the same way it does to a direct DELETE.
if errCode := s3a.recheckPolicyWithObjectEntry(objectKeyPolicyRequest(r, http.MethodDelete, bucket, srcObject, ""), bucket, srcObject, string(s3_constants.ACTION_WRITE), entry.Extended, "RenameObjectHandler"); errCode != s3err.ErrNone {
return errCode
}
if errCode := validateSourceConditionalHeaders(r, entry, renameSourceConditionalHeaders); errCode != s3err.ErrNone {
return errCode
}
+2 -24
View File
@@ -74,7 +74,7 @@ func (s3a *S3ApiServer) GetObjectTaggingHandler(w http.ResponseWriter, r *http.R
target := util.FullPath(fmt.Sprintf("%s/%s", s3a.bucketDir(bucket), object))
dir, name := target.DirAndName()
entry, err = s3a.getEntry(dir, name)
tags, err := s3a.getTags(dir, name)
if err != nil {
if err == filer_pb.ErrNotFound {
glog.Errorf("GetObjectTaggingHandler %s: %v", r.URL, err)
@@ -85,10 +85,8 @@ func (s3a *S3ApiServer) GetObjectTaggingHandler(w http.ResponseWriter, r *http.R
}
return
}
}
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, s3_constants.S3_ACTION_GET_OBJECT_TAGGING, entry.Extended, "GetObjectTaggingHandler"); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
writeSuccessResponseXML(w, r, FromTags(tags))
return
}
@@ -185,11 +183,6 @@ func (s3a *S3ApiServer) PutObjectTaggingHandler(w http.ResponseWriter, r *http.R
target := util.FullPath(fmt.Sprintf("%s/%s", s3a.bucketDir(bucket), object))
dir, name := target.DirAndName()
if errCode := s3a.recheckTagConditionedPolicyWithLookup(r, bucket, object, s3_constants.S3_ACTION_PUT_OBJECT_TAGGING, "PutObjectTaggingHandler"); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
if err = s3a.setTags(dir, name, tags); err != nil {
if err == filer_pb.ErrNotFound {
glog.Errorf("PutObjectTaggingHandler setTags %s: %v", r.URL, err)
@@ -206,11 +199,6 @@ func (s3a *S3ApiServer) PutObjectTaggingHandler(w http.ResponseWriter, r *http.R
return
}
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, s3_constants.S3_ACTION_PUT_OBJECT_TAGGING, entry.Extended, "PutObjectTaggingHandler"); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// For versioned objects, resolve the directory holding the entry to update. A null
// version lives under its full key's parent directory, so a nested key must not fall
// back to the bucket root and rewrite a different object sharing the same basename.
@@ -313,11 +301,6 @@ func (s3a *S3ApiServer) DeleteObjectTaggingHandler(w http.ResponseWriter, r *htt
target := util.FullPath(fmt.Sprintf("%s/%s", s3a.bucketDir(bucket), object))
dir, name := target.DirAndName()
if errCode := s3a.recheckTagConditionedPolicyWithLookup(r, bucket, object, s3_constants.S3_ACTION_DELETE_OBJECT_TAGGING, "DeleteObjectTaggingHandler"); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
err := s3a.rmTags(dir, name)
if err != nil {
if err == filer_pb.ErrNotFound {
@@ -335,11 +318,6 @@ func (s3a *S3ApiServer) DeleteObjectTaggingHandler(w http.ResponseWriter, r *htt
return
}
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, s3_constants.S3_ACTION_DELETE_OBJECT_TAGGING, entry.Extended, "DeleteObjectTaggingHandler"); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// For versioned objects, resolve the directory holding the entry to update. A null
// version lives under its full key's parent directory, so a nested key must not fall
// back to the bucket root and rewrite a different object sharing the same basename.
-17
View File
@@ -79,20 +79,3 @@ func TestVeeamObjectLockBugFix(t *testing.T) {
assert.True(t, enabled, "Object Lock should be enabled when constant is used")
})
}
// Copied objects get their own retention from the request or the destination
// bucket's default; the source's lock keys never carry into the destination
// entry.
func TestCopyDoesNotInheritLockKeys(t *testing.T) {
// The destination-Extended copy loop filters on isCopyLockKey, so
// retention and legal hold never carry over from the source entry.
for _, key := range []string{
s3_constants.ExtObjectLockModeKey,
s3_constants.ExtRetentionUntilDateKey,
s3_constants.ExtLegalHoldKey,
} {
assert.True(t, isCopyLockKey(key), "%s must be filtered from a copy destination", key)
}
assert.False(t, isCopyLockKey(s3_constants.ExtAmzOwnerKey))
assert.False(t, isCopyLockKey("custom-key"))
}
+7 -14
View File
@@ -9,7 +9,6 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
@@ -158,11 +157,6 @@ func singleStrongETag(v string) (string, bool) {
// the write. The reached filer forwards to the real owner by route_key, keeping its
// per-path lock authoritative. Mirrors getObjectEntryRoutedByKey on the read path.
func (s3a *S3ApiServer) objectTxnOnFiler(owner pb.ServerAddress, req *filer_pb.ObjectTransactionRequest) (*filer_pb.ObjectTransactionResponse, error) {
// One id per logical transaction: failover retries reuse it so the owner
// replays the recorded outcome instead of re-applying the mutations.
if req.RequestId == "" {
req.RequestId = uuid.NewString()
}
var resp *filer_pb.ObjectTransactionResponse
txn := func(client filer_pb.SeaweedFilerClient) error {
var e error
@@ -189,7 +183,7 @@ func (s3a *S3ApiServer) objectTxnOnFiler(owner pb.ServerAddress, req *filer_pb.O
// object path plus a RECOMPUTE_LATEST finalize, so the version's PUT and its
// .versions pointer flip commit atomically (the recompute scans .versions/ after
// the PUT and sees the new version).
func (s3a *S3ApiServer) routedPut(owner pb.ServerAddress, routeKey, lockKey, filePath string, entry *filer_pb.Entry, cond *filer_pb.WriteCondition, conditionKey, conditionAltKey string, finalize []*filer_pb.ObjectMutation) (*filer_pb.ObjectTransactionResponse, error) {
func (s3a *S3ApiServer) routedPut(owner pb.ServerAddress, routeKey, lockKey, filePath string, entry *filer_pb.Entry, cond *filer_pb.WriteCondition, conditionKey string, finalize []*filer_pb.ObjectMutation) (*filer_pb.ObjectTransactionResponse, error) {
mutations := make([]*filer_pb.ObjectMutation, 0, 1+len(finalize))
mutations = append(mutations, &filer_pb.ObjectMutation{
Type: filer_pb.ObjectMutation_PUT,
@@ -198,12 +192,11 @@ func (s3a *S3ApiServer) routedPut(owner pb.ServerAddress, routeKey, lockKey, fil
})
mutations = append(mutations, finalize...)
return s3a.objectTxnOnFiler(owner, &filer_pb.ObjectTransactionRequest{
LockKey: lockKey,
RouteKey: routeKey,
Condition: cond,
ConditionKey: conditionKey,
ConditionAltKey: conditionAltKey,
Mutations: mutations,
LockKey: lockKey,
RouteKey: routeKey,
Condition: cond,
ConditionKey: conditionKey,
Mutations: mutations,
})
}
@@ -234,7 +227,7 @@ func (s3a *S3ApiServer) routedMkFile(owner pb.ServerAddress, routeKey, parentDir
cond, conditionKey = removal.condition, removal.conditionKey
finalize = []*filer_pb.ObjectMutation{removal.mutation}
}
resp, err := s3a.routedPut(owner, routeKey, filePath, filePath, entry, cond, conditionKey, "", finalize)
resp, err := s3a.routedPut(owner, routeKey, filePath, filePath, entry, cond, conditionKey, finalize)
if err != nil {
return err
}
@@ -139,7 +139,7 @@ func (s3a *S3ApiServer) routedMultipartFinalize(owner pb.ServerAddress, bucket,
routeKey = s3a.objectRouteKey(bucket, object)
}
conditionKey, condition := uploadExistsCondition(s3a.genUploadsFolder(bucket) + "/" + uploadID)
resp, err := s3a.routedPut(owner, routeKey, s3a.toFilerPath(bucket, object), versionDir+"/"+versionFileName, versionEntry, condition, conditionKey, "", []*filer_pb.ObjectMutation{
resp, err := s3a.routedPut(owner, routeKey, s3a.toFilerPath(bucket, object), versionDir+"/"+versionFileName, versionEntry, condition, conditionKey, []*filer_pb.ObjectMutation{
s3a.removeUploadDirMutation(bucket, uploadID),
s3a.latestPointerRecompute(bucket, object, useInvertedFormat, "", true),
})
+17 -5
View File
@@ -14,15 +14,27 @@ import (
)
// getRequestDataReader returns the appropriate reader for the request body.
// Chunked streaming uploads are decoded regardless of whether IAM is enabled;
// with no credentials to verify against, signature checking is skipped inside
// newChunkedReader.
// When IAM is disabled, it still processes chunked transfer encoding for
// authTypeStreamingUnsigned to strip checksum headers and extract the actual data.
// This fixes issues where chunked data with checksums would be stored incorrectly
// when IAM is not enabled.
func getRequestDataReader(s3a *S3ApiServer, r *http.Request) (io.ReadCloser, s3err.ErrorCode) {
var s3ErrCode s3err.ErrorCode
dataReader := r.Body
rAuthType := getRequestAuthType(r)
if rAuthType == authTypeStreamingSigned || rAuthType == authTypeStreamingUnsigned {
dataReader, s3ErrCode = s3a.iam.newChunkedReader(r)
if s3a.iam.isEnabled() {
if rAuthType == authTypeStreamingSigned || rAuthType == authTypeStreamingUnsigned {
dataReader, s3ErrCode = s3a.iam.newChunkedReader(r)
}
} else {
switch rAuthType {
case authTypeStreamingSigned:
s3ErrCode = s3err.ErrAuthNotSetup
case authTypeStreamingUnsigned:
// Even when IAM is disabled, we still need to handle chunked transfer encoding
// to strip checksum headers and process the data correctly
dataReader, s3ErrCode = s3a.iam.newChunkedReader(r)
}
}
if s3ErrCode != s3err.ErrNone {
return nil, s3ErrCode
+3 -3
View File
@@ -41,9 +41,9 @@ func TestGetRequestDataReader_ChunkedEncodingWithoutIAM(t *testing.T) {
{
name: "StreamingSignedWithoutIAM",
contentSha256: "STREAMING-AWS4-HMAC-SHA256-PAYLOAD",
expectedError: s3err.ErrNone,
shouldProcess: true,
description: "Streaming signed requests are decoded when IAM is disabled; signatures are skipped since there is nothing to verify them against",
expectedError: s3err.ErrAuthNotSetup,
shouldProcess: false,
description: "Streaming signed requests should fail when IAM is disabled",
},
{
name: "StreamingUnsignedWithoutIAM",
-74
View File
@@ -28,7 +28,6 @@ import (
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/s3_pb"
@@ -676,79 +675,6 @@ func (s3a *S3ApiServer) recheckPolicyWithObjectEntry(r *http.Request, bucket, ob
return errCode
}
// recheckCopySourcePolicy repeats the source object's policy check once its
// entry is fetched, so conditions on the object's existing tags apply to a
// CopyObject / UploadPartCopy / RenameObject source the same way they do to a
// plain GET. The synthesized GET keeps the destination's request shape (PUT,
// partNumber, ...) from resolving a different action.
func (s3a *S3ApiServer) recheckCopySourcePolicy(r *http.Request, bucket, object, versionId string, objectEntry map[string][]byte, handlerName string) s3err.ErrorCode {
keyReq := objectKeyPolicyRequest(r, http.MethodGet, bucket, object, versionId)
return s3a.recheckPolicyWithObjectEntry(keyReq, bucket, object, string(ACTION_READ), objectEntry, handlerName)
}
// recheckTagConditionedPolicyWithLookup repeats policy evaluation with the
// object's entry in hand so s3:ExistingObjectTag conditions apply to tagging
// writes that did not otherwise fetch the entry. It only does the extra
// lookup when the bucket has a policy; entry misses return ErrNone because
// the handler reports its own NoSuchKey.
func (s3a *S3ApiServer) recheckTagConditionedPolicyWithLookup(r *http.Request, bucket, object, action, handlerName string) s3err.ErrorCode {
if s3a.policyEngine == nil || !s3a.policyEngine.HasPolicyForBucket(bucket) {
return s3err.ErrNone
}
target := util.FullPath(fmt.Sprintf("%s/%s", s3a.bucketDir(bucket), object))
dir, name := target.DirAndName()
entry, err := s3a.getEntry(dir, name)
if err != nil {
// The tag conditions cannot be evaluated without the entry; failing
// open would let a tag-gated Deny slip through a transient miss.
if errors.Is(err, filer_pb.ErrNotFound) {
return s3err.ErrNone
}
glog.Errorf("%s: recheck lookup for %s/%s failed: %v", handlerName, bucket, object, err)
return s3err.ErrInternalError
}
if entry == nil {
return s3err.ErrNone
}
return s3a.recheckPolicyWithObjectEntry(r, bucket, object, action, entry.Extended, handlerName)
}
// recheckPolicyForKeyWithLookup re-decides a first-phase deny on one key for a
// synthesized method, resolving the entry the way the read path does (versions
// included) so s3:ExistingObjectTag conditions see the object's real tags.
// Unlike the deny-side rechecks, an indeterminate result does not grant: the
// caller reports the original verdict either way, so only a statement that
// matches with tags in hand can turn a deny into an allow, and a missed lookup
// never widens access or reveals whether the key exists.
func (s3a *S3ApiServer) recheckPolicyForKeyWithLookup(r *http.Request, method, bucket, object, versionId, action, handlerName string) s3err.ErrorCode {
if s3a.policyEngine == nil || !s3a.policyEngine.HasPolicyForBucket(bucket) {
return s3err.ErrAccessDenied
}
versioningState, err := s3a.getVersioningState(bucket)
if err != nil {
glog.Errorf("%s: recheck versioning state for %s: %v", handlerName, bucket, err)
return s3err.ErrAccessDenied
}
entry, err := s3a.resolveCopySourceEntry(bucket, object, versionId, versioningState)
entry = prefixObjectSource(entry)
if err != nil || entry == nil {
return s3err.ErrAccessDenied
}
keyReq := objectKeyPolicyRequest(r, method, bucket, object, versionId)
errCode, checked := s3a.checkPolicyWithEntry(keyReq, bucket, object, action, "", entry.Extended)
if errCode != s3err.ErrNone || !checked {
return s3err.ErrAccessDenied
}
// A bucket-policy Allow never overrides an explicit Deny in the caller's
// own attached or session policies — the first phase applied that veto,
// so the tag-aware recheck must too.
identity, _ := GetIdentityFromContext(r).(*Identity)
if s3a.iam != nil && s3a.iam.isActionExplicitlyDeniedByApplicablePolicies(keyReq, identity, Action(action), bucket, object) {
return s3err.ErrAccessDenied
}
return s3err.ErrNone
}
// classifyDomainNames classifies domains into path-style and virtual-host style domains.
// A domain is considered path-style if:
// 1. It contains a dot (has subdomains)
+14 -46
View File
@@ -238,12 +238,20 @@ func (h *S3TablesHandler) getAccountID(r *http.Request) string {
// Prefer stable claims from JWT/STS identities. Only "sub" is guaranteed durable per OIDC;
// preferred_username is ergonomic but can rotate and may orphan ownership data, while email
// is explicitly excluded to avoid storing PII in metadata.
if claims := identityClaimsMap(val); claims != nil {
// aws:userid is the issuer-namespaced (sub, iss) hash; a bare
// sub or preferred_username collides across providers.
for _, claimKey := range []string{"aws:userid", "sub", "preferred_username"} {
if principal := normalizePrincipalID(claimString(claims, claimKey)); principal != "" {
return principal
claimsField := val.FieldByName("Claims")
if claimsField.IsValid() && claimsField.Kind() == reflect.Map && !claimsField.IsNil() && claimsField.Type().Key().Kind() == reflect.String {
for _, claimKey := range []string{"sub", "preferred_username"} {
claimVal := claimsField.MapIndex(reflect.ValueOf(claimKey))
if !claimVal.IsValid() {
continue
}
if claimVal.Kind() == reflect.Interface && !claimVal.IsNil() {
claimVal = claimVal.Elem()
}
if claimVal.Kind() == reflect.String {
if principal := normalizePrincipalID(claimVal.String()); principal != "" {
return principal
}
}
}
}
@@ -283,46 +291,6 @@ func (h *S3TablesHandler) getAccountID(r *http.Request) string {
return h.accountID
}
// identityClaimsMap extracts the Claims map from an identity value, using
// reflection to avoid an import cycle with the s3api identity type.
func identityClaimsMap(val reflect.Value) map[string]interface{} {
claimsField := val.FieldByName("Claims")
if !claimsField.IsValid() || claimsField.Kind() != reflect.Map || claimsField.IsNil() || claimsField.Type().Key().Kind() != reflect.String {
return nil
}
claims, ok := claimsField.Interface().(map[string]interface{})
if !ok {
return nil
}
return claims
}
func claimString(claims map[string]interface{}, key string) string {
v, ok := claims[key]
if !ok || v == nil {
return ""
}
str, _ := v.(string)
return str
}
// trustedIssuers returns the OIDC issuers the auth layer asserted into the
// identity's claims; they re-anchor legacy owner checks in checkPermission.
func (h *S3TablesHandler) trustedIssuers(r *http.Request) []string {
identityRaw := s3_constants.GetIdentityFromContext(r)
if identityRaw == nil {
return nil
}
val := reflect.ValueOf(identityRaw)
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
if val.Kind() != reflect.Struct {
return nil
}
return strings.Fields(claimString(identityClaimsMap(val), "seaweed:oidc-issuers"))
}
// normalizePrincipalID collapses ARN and identity strings to a key that is stable within a single account.
// WARNING: this assumes identity names are unique per account; distinct principals such as
// arn:aws:iam::111:user/alice and arn:aws:iam::222:user/alice will both normalize to "alice".
@@ -65,7 +65,6 @@ func (h *S3TablesHandler) handleCreateTableBucket(w http.ResponseWriter, r *http
owner = DefaultAccountID
}
if !CheckPermissionWithContext("CreateTableBucket", principal, owner, "", "", &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
}) {
@@ -70,7 +70,6 @@ func (h *S3TablesHandler) handleGetTableBucket(w http.ResponseWriter, r *http.Re
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("GetTableBucket", principal, metadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
@@ -193,7 +192,6 @@ func (h *S3TablesHandler) handleListTableBuckets(w http.ResponseWriter, r *http.
bucketARN := h.generateTableBucketARN(metadata.OwnerAccountID, entry.Entry.Name)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("GetTableBucket", accountID, metadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: entry.Entry.Name,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
@@ -298,7 +296,6 @@ func (h *S3TablesHandler) handleDeleteTableBucket(w http.ResponseWriter, r *http
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("DeleteTableBucket", principal, metadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
@@ -171,18 +171,3 @@ func TestGetAccountIDNormalizesAccountIDARN(t *testing.T) {
assert.Equal(t, "bob", h.getAccountID(req), "expected ARN account ID to be normalized to the suffix")
}
func TestGetAccountIDPrefersIssuerScopedUserID(t *testing.T) {
h := NewS3TablesHandler()
id := &testIdentity{
Claims: map[string]interface{}{
"aws:userid": "issuerA-hash",
"sub": "alice-sub",
"preferred_username": "alice",
},
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(s3_constants.SetIdentityInContext(req.Context(), id))
assert.Equal(t, "issuerA-hash", h.getAccountID(req), "aws:userid should win over a bare sub")
}
@@ -349,7 +349,6 @@ func (h *S3TablesHandler) authorizeMaintenanceBucket(r *http.Request, filerClien
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
principal := h.getAccountID(r)
if !CheckPermissionWithContext(operation, principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
IdentityActions: getIdentityActions(r),
DefaultAllow: h.defaultAllowFor(r),
@@ -386,7 +385,6 @@ func (h *S3TablesHandler) authorizeMaintenanceTable(r *http.Request, filerClient
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
principal := h.getAccountID(r)
if !CheckPermissionWithContext(operation, principal, metadata.OwnerAccountID, bucketPolicy, tableARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
-5
View File
@@ -114,7 +114,6 @@ func (h *S3TablesHandler) handleCreateNamespace(w http.ResponseWriter, r *http.R
identityActions := getIdentityActions(r)
glog.Infof("S3Tables: CreateNamespace permission check - principal=%s, owner=%s, actions=%v", principal, bucketMetadata.OwnerAccountID, identityActions)
if !CheckPermissionWithContext("CreateNamespace", principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: namespaceName,
TableBucketTags: bucketTags,
@@ -256,7 +255,6 @@ func (h *S3TablesHandler) handleGetNamespace(w http.ResponseWriter, r *http.Requ
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("GetNamespace", principal, metadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: namespaceName,
TableBucketTags: bucketTags,
@@ -347,7 +345,6 @@ func (h *S3TablesHandler) handleUpdateNamespace(w http.ResponseWriter, r *http.R
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("UpdateNamespace", principal, metadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: namespaceName,
TableBucketTags: bucketTags,
@@ -450,7 +447,6 @@ func (h *S3TablesHandler) handleListNamespaces(w http.ResponseWriter, r *http.Re
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("ListNamespaces", principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
TableBucketTags: bucketTags,
IdentityActions: identityActions,
@@ -635,7 +631,6 @@ func (h *S3TablesHandler) handleDeleteNamespace(w http.ResponseWriter, r *http.R
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("DeleteNamespace", principal, metadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: namespaceName,
TableBucketTags: bucketTags,
-9
View File
@@ -92,7 +92,6 @@ func (h *S3TablesHandler) handlePutTableBucketPolicy(w http.ResponseWriter, r *h
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("PutTableBucketPolicy", principal, bucketMetadata.OwnerAccountID, "", bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
@@ -171,7 +170,6 @@ func (h *S3TablesHandler) handleGetTableBucketPolicy(w http.ResponseWriter, r *h
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("GetTableBucketPolicy", principal, bucketMetadata.OwnerAccountID, string(policy), bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
@@ -248,7 +246,6 @@ func (h *S3TablesHandler) handleDeleteTableBucketPolicy(w http.ResponseWriter, r
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("DeleteTableBucketPolicy", principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
@@ -348,7 +345,6 @@ func (h *S3TablesHandler) handlePutTablePolicy(w http.ResponseWriter, r *http.Re
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("PutTablePolicy", principal, metadata.OwnerAccountID, bucketPolicy, tableARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
@@ -457,7 +453,6 @@ func (h *S3TablesHandler) handleGetTablePolicy(w http.ResponseWriter, r *http.Re
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("GetTablePolicy", principal, metadata.OwnerAccountID, bucketPolicy, tableARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
@@ -548,7 +543,6 @@ func (h *S3TablesHandler) handleDeleteTablePolicy(w http.ResponseWriter, r *http
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("DeleteTablePolicy", principal, metadata.OwnerAccountID, bucketPolicy, tableARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
@@ -646,7 +640,6 @@ func (h *S3TablesHandler) handleTagResource(w http.ResponseWriter, r *http.Reque
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("TagResource", principal, ownerAccountID, bucketPolicy, resourceARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
TableBucketTags: bucketTags,
RequestTags: req.Tags,
@@ -767,7 +760,6 @@ func (h *S3TablesHandler) handleListTagsForResource(w http.ResponseWriter, r *ht
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("ListTagsForResource", principal, ownerAccountID, bucketPolicy, resourceARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
TableBucketTags: bucketTags,
ResourceTags: tags,
@@ -875,7 +867,6 @@ func (h *S3TablesHandler) handleUntagResource(w http.ResponseWriter, r *http.Req
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("UntagResource", principal, ownerAccountID, bucketPolicy, resourceARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
TableBucketTags: bucketTags,
TagKeys: req.TagKeys,
+113 -82
View File
@@ -419,20 +419,26 @@ func (h *S3TablesHandler) handleGetTable(w http.ResponseWriter, r *http.Request,
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespace+"/"+tableName)
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
identityActions := getIdentityActions(r)
allowed := CheckTableOrBucketPermission("GetTable", accountID,
metadata.OwnerAccountID, tablePolicy, tableARN,
bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: namespace,
TableName: tableName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
})
tableAllowed := CheckPermissionWithContext("GetTable", accountID, metadata.OwnerAccountID, tablePolicy, tableARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespace,
TableName: tableName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
})
bucketAllowed := CheckPermissionWithContext("GetTable", accountID, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespace,
TableName: tableName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
})
if !allowed {
if !tableAllowed && !bucketAllowed {
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return ErrAccessDenied
}
@@ -554,16 +560,21 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
bucketARN := h.generateTableBucketARN(bucketMeta.OwnerAccountID, bucketName)
identityActions := getIdentityActions(r)
if !CheckTableOrBucketPermission("ListTables", accountID,
nsMeta.OwnerAccountID, namespacePolicy, bucketARN,
bucketMeta.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: namespaceName,
TableBucketTags: bucketTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
}) {
nsAllowed := CheckPermissionWithContext("ListTables", accountID, nsMeta.OwnerAccountID, namespacePolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableBucketTags: bucketTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
})
bucketAllowed := CheckPermissionWithContext("ListTables", accountID, bucketMeta.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableBucketTags: bucketTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
})
if !nsAllowed && !bucketAllowed {
return ErrAccessDenied
}
@@ -603,7 +614,6 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
bucketARN := h.generateTableBucketARN(bucketMeta.OwnerAccountID, bucketName)
identityActions := getIdentityActions(r)
if !CheckPermissionWithContext("ListTables", accountID, bucketMeta.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
TableBucketTags: bucketTags,
IdentityActions: identityActions,
@@ -940,19 +950,25 @@ func (h *S3TablesHandler) handleDeleteTable(w http.ResponseWriter, r *http.Reque
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
allowed := CheckTableOrBucketPermission("DeleteTable", principal,
metadata.OwnerAccountID, tablePolicy, tableARN,
bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
})
if !allowed {
tableAllowed := CheckPermissionWithContext("DeleteTable", principal, metadata.OwnerAccountID, tablePolicy, tableARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
})
bucketAllowed := CheckPermissionWithContext("DeleteTable", principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
})
if !tableAllowed && !bucketAllowed {
if h.canReadEntry("GetTable", principal, metadata.OwnerAccountID, bucketMetadata.OwnerAccountID, tablePolicy, bucketPolicy, tableARN, bucketARN, bucketName, namespaceName, tableName, bucketTags, tableTags, identityActions, r) {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to delete table")
} else {
@@ -1115,7 +1131,6 @@ func (h *S3TablesHandler) readTableMetadata(ctx context.Context, client filer_pb
// cannot see the entry keeps the not-found veil.
func (h *S3TablesHandler) canReadEntry(readOp, principal, tableOwner, bucketOwner, tablePolicy, bucketPolicy, tableARN, bucketARN, bucketName, namespace, name string, bucketTags, tableTags map[string]string, identityActions []string, r *http.Request) bool {
ctx := &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: namespace,
TableName: name,
@@ -1124,9 +1139,8 @@ func (h *S3TablesHandler) canReadEntry(readOp, principal, tableOwner, bucketOwne
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
}
return CheckTableOrBucketPermission(readOp, principal,
tableOwner, tablePolicy, tableARN,
bucketOwner, bucketPolicy, bucketARN, ctx)
return CheckPermissionWithContext(readOp, principal, tableOwner, tablePolicy, tableARN, ctx) ||
CheckPermissionWithContext(readOp, principal, bucketOwner, bucketPolicy, bucketARN, ctx)
}
// handleRenameTable moves a table's catalog entry to a new namespace/name within
@@ -1297,19 +1311,25 @@ func (h *S3TablesHandler) renameCatalogEntry(w http.ResponseWriter, r *http.Requ
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
allowed := CheckTableOrBucketPermission(kind.renameOp, principal,
metadata.OwnerAccountID, tablePolicy, tableARN,
bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: srcNamespace,
TableName: srcName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
})
if !allowed {
tableAllowed := CheckPermissionWithContext(kind.renameOp, principal, metadata.OwnerAccountID, tablePolicy, tableARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: srcNamespace,
TableName: srcName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
})
bucketAllowed := CheckPermissionWithContext(kind.renameOp, principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: srcNamespace,
TableName: srcName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
})
if !tableAllowed && !bucketAllowed {
readTags := tableTags
if !kind.tagsForRead {
readTags = nil
@@ -1364,17 +1384,23 @@ func (h *S3TablesHandler) renameCatalogEntry(w http.ResponseWriter, r *http.Requ
// must also be allowed to create a table there (the source check alone lets a
// caller move tables into namespaces they don't control). Denials report the
// same not-found as a missing destination namespace.
if !CheckTableOrBucketPermission(kind.createOp, principal,
destNamespaceMetadata.OwnerAccountID, destNamespacePolicy, bucketARN,
bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: destNamespace,
TableName: destName,
TableBucketTags: bucketTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
}) {
destNamespaceAllowed := CheckPermissionWithContext(kind.createOp, principal, destNamespaceMetadata.OwnerAccountID, destNamespacePolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: destNamespace,
TableName: destName,
TableBucketTags: bucketTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
})
destBucketAllowed := CheckPermissionWithContext(kind.createOp, principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: destNamespace,
TableName: destName,
TableBucketTags: bucketTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
})
if !destNamespaceAllowed && !destBucketAllowed {
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchNamespace, fmt.Sprintf("namespace %s not found", destNamespace))
return NewAuthError(kind.renameOp, principal, "not authorized to create "+kind.noun+" in the destination namespace")
}
@@ -1569,20 +1595,26 @@ func (h *S3TablesHandler) handleUpdateTable(w http.ResponseWriter, r *http.Reque
principal := h.getAccountID(r)
identityActions := getIdentityActions(r)
allowed := CheckTableOrBucketPermission("UpdateTable", principal,
metadata.OwnerAccountID, tablePolicy, tableARN,
bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
})
tableAllowed := CheckPermissionWithContext("UpdateTable", principal, metadata.OwnerAccountID, tablePolicy, tableARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
})
bucketAllowed := CheckPermissionWithContext("UpdateTable", principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
})
if !allowed {
if !tableAllowed && !bucketAllowed {
if h.canReadEntry("GetTable", principal, metadata.OwnerAccountID, bucketMetadata.OwnerAccountID, tablePolicy, bucketPolicy, tableARN, bucketARN, bucketName, namespaceName, tableName, bucketTags, tableTags, identityActions, r) {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to update table")
} else {
@@ -1777,7 +1809,6 @@ func (h *S3TablesHandler) authorizeCreateTable(w http.ResponseWriter, r *http.Re
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
identityActions := getIdentityActions(r)
policyContext := &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
@@ -1787,9 +1818,9 @@ func (h *S3TablesHandler) authorizeCreateTable(w http.ResponseWriter, r *http.Re
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
}
if !CheckTableOrBucketPermission("CreateTable", accountID,
namespaceMetadata.OwnerAccountID, namespacePolicy, bucketARN,
bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, policyContext) {
nsAllowed := CheckPermissionWithContext("CreateTable", accountID, namespaceMetadata.OwnerAccountID, namespacePolicy, bucketARN, policyContext)
bucketAllowed := CheckPermissionWithContext("CreateTable", accountID, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, policyContext)
if !nsAllowed && !bucketAllowed {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to create table in this namespace")
return nil, ErrAccessDenied
}
+3 -4
View File
@@ -635,7 +635,6 @@ func (h *S3TablesHandler) loadAuthContext(r *http.Request, filerClient FilerClie
func (h *S3TablesHandler) authorizeViewOp(r *http.Request, operation, principal, resourceOwner, bucketOwner, resourcePolicy, bucketPolicy, resourceARN, bucketName, namespace, viewName string, bucketTags map[string]string) bool {
identityActions := getIdentityActions(r)
ctx := &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
TableBucketName: bucketName,
Namespace: namespace,
TableName: viewName,
@@ -643,10 +642,10 @@ func (h *S3TablesHandler) authorizeViewOp(r *http.Request, operation, principal,
IdentityActions: identityActions,
DefaultAllow: h.defaultAllowFor(r),
}
resourceAllowed := CheckPermissionWithContext(operation, principal, resourceOwner, resourcePolicy, resourceARN, ctx)
bucketARN := h.generateTableBucketARN(bucketOwner, bucketName)
return CheckTableOrBucketPermission(operation, principal,
resourceOwner, resourcePolicy, resourceARN,
bucketOwner, bucketPolicy, bucketARN, ctx)
bucketAllowed := CheckPermissionWithContext(operation, principal, bucketOwner, bucketPolicy, bucketARN, ctx)
return resourceAllowed || bucketAllowed
}
// singleClient adapts a bound filer client to the FilerClient interface so the
+3 -90
View File
@@ -6,7 +6,6 @@ import (
"strings"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
@@ -89,11 +88,6 @@ type PolicyContext struct {
KMSKeyArn string
StorageClass string
DefaultAllow bool
// TrustedIssuers lists the OIDC issuers the deployment is configured to
// trust, used to re-anchor owners/principals written before issuer-scoped
// account IDs. A bare subject is only resolvable when exactly one issuer
// is configured; more than one leaves it ambiguous, so it must not match.
TrustedIssuers []string
}
// CheckPermissionWithContext checks permission with optional resource and condition context.
@@ -113,67 +107,10 @@ func CheckPermissionWithContext(operation, principal, owner, resourcePolicy, res
return checkPermission(operation, principal, owner, resourcePolicy, resourceARN, ctx)
}
// CheckTableOrBucketPermission combines a table or namespace policy with its
// table bucket's policy into one decision: a matching Deny on either policy
// vetoes the request even when the other policy would allow it.
func CheckTableOrBucketPermission(operation, principal, resourceOwner, resourcePolicy, resourceARN, bucketOwner, bucketPolicy, bucketARN string, ctx *PolicyContext) bool {
if principal == "" {
return false
}
if principal == s3_constants.AccountAdminId {
return true
}
if matchesDenyStatement(operation, principal, resourcePolicy, resourceARN, ctx) ||
matchesDenyStatement(operation, principal, bucketPolicy, bucketARN, ctx) {
return false
}
// Each side fails closed when its owner is unknown, matching
// CheckPermissionWithContext; an ownerless side contributes nothing.
resourceOK := resourceOwner != "" &&
checkPermission(operation, principal, resourceOwner, resourcePolicy, resourceARN, ctx)
bucketOK := bucketOwner != "" &&
checkPermission(operation, principal, bucketOwner, bucketPolicy, bucketARN, ctx)
return resourceOK || bucketOK
}
// matchesDenyStatement reports whether the JSON policy carries a statement
// matching this request with Effect Deny. Malformed policies return false;
// the caller's normal evaluation still denies them.
func matchesDenyStatement(operation, principal, resourcePolicy, resourceARN string, ctx *PolicyContext) bool {
if resourcePolicy == "" {
return false
}
fullAction := operation
if !strings.Contains(operation, ":") {
fullAction = "s3tables:" + operation
}
var policy PolicyDocument
if err := json.Unmarshal([]byte(resourcePolicy), &policy); err != nil {
return false
}
for _, stmt := range policy.Statement {
if stmt.Effect != "Deny" ||
!matchesCaller(stmt.Principal, principal, ctx) ||
!matchesAction(stmt.Action, fullAction) ||
(resourceARN != "" && !matchesResource(stmt.Resource, resourceARN)) ||
!matchesConditions(stmt.Condition, ctx) {
continue
}
return true
}
return false
}
func checkPermission(operation, principal, owner, resourcePolicy, resourceARN string, ctx *PolicyContext) bool {
// An explicit Deny in the resource policy vetoes owner and identity
// grants as well as allows within the same document.
if matchesDenyStatement(operation, principal, resourcePolicy, resourceARN, ctx) {
return false
}
// Owner always has permission.
if principal == owner || matchesLegacySubject(owner, principal, ctx) {
// Owner always has permission
if principal == owner {
return true
}
@@ -208,7 +145,7 @@ func checkPermission(operation, principal, owner, resourcePolicy, resourceARN st
for _, stmt := range policy.Statement {
// Check if principal matches
if !matchesCaller(stmt.Principal, principal, ctx) {
if !matchesPrincipal(stmt.Principal, principal) {
continue
}
@@ -288,30 +225,6 @@ func hasAdminAction(actions []string) bool {
return false
}
// matchesCaller matches a statement's Principal against the caller's scoped
// principal or, for policies written before issuer scoping, a bare subject
// that resolves to it under the deployment's single trusted issuer.
func matchesCaller(principalSpec interface{}, principal string, ctx *PolicyContext) bool {
if matchesPrincipal(principalSpec, principal) {
return true
}
legacy, ok := principalSpec.(string)
return ok && matchesLegacySubject(legacy, principal, ctx)
}
// matchesLegacySubject reports whether a bare subject stored before issuer
// scoping belongs to the caller. Re-anchoring is only sound when exactly one
// OIDC issuer is configured: the stored subject is hashed under that issuer
// and compared to the caller's scoped principal. With multiple issuers a bare
// subject is ambiguous — matching it under the caller's own issuer would let
// a same-sub identity from any configured issuer claim it.
func matchesLegacySubject(legacy, principal string, ctx *PolicyContext) bool {
if ctx == nil || len(ctx.TrustedIssuers) != 1 {
return false
}
return sts.ComputeParentUser(legacy, ctx.TrustedIssuers[0]) == principal
}
// matchesPrincipal checks if the principal matches the statement's principal
func matchesPrincipal(principalSpec interface{}, principal string) bool {
if principalSpec == nil {
-106
View File
@@ -3,8 +3,6 @@ package s3tables
import (
"encoding/json"
"testing"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
)
func TestMatchesActionPattern(t *testing.T) {
@@ -253,107 +251,3 @@ func TestCheckPermissionWithDefaultAllow(t *testing.T) {
})
}
}
func TestCheckTableOrBucketPermissionDenyVeto(t *testing.T) {
tablePolicy := `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Deny",
"Principal": "*",
"Action": "s3tables:DeleteTable",
"Resource": "*"
}]
}`
bucketAllow := `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3tables:*",
"Resource": "*"
}]
}`
tableARN := "arn:aws:s3tables:us-east-1:acct:bucket/b/table/ns/t"
bucketARN := "arn:aws:s3tables:us-east-1:acct:bucket/b"
ctx := &PolicyContext{TableBucketName: "b", Namespace: "ns", TableName: "t"}
// A Deny on the table policy vetoes the bucket Allow.
if CheckTableOrBucketPermission("DeleteTable", "user123", "owner", tablePolicy, tableARN, "owner", bucketAllow, bucketARN, ctx) {
t.Error("table Deny should veto bucket Allow")
}
// The same Deny in the bucket policy vetoes a table Allow.
if CheckTableOrBucketPermission("DeleteTable", "user123", "owner", bucketAllow, tableARN, "owner", tablePolicy, bucketARN, ctx) {
t.Error("bucket Deny should veto table Allow")
}
// A table Deny also vetoes ownership of the table.
if CheckTableOrBucketPermission("DeleteTable", "owner", "owner", tablePolicy, tableARN, "owner", "", bucketARN, ctx) {
t.Error("table Deny should veto the owner grant")
}
// No Deny anywhere: either policy may allow.
if !CheckTableOrBucketPermission("DeleteTable", "user123", "other-owner", "", tableARN, "owner", bucketAllow, bucketARN, ctx) {
t.Error("bucket Allow should grant when no policy denies")
}
}
func TestCheckTableOrBucketPermissionOwnerlessFailsClosed(t *testing.T) {
ctx := &PolicyContext{
IdentityActions: []string{"s3tables:*"},
DefaultAllow: true,
}
allowPolicy := `{"Statement":[{"Effect":"Allow","Principal":"*","Action":"s3tables:*","Resource":"*"}]}`
if CheckTableOrBucketPermission("GetTable", "user1",
"", "", "arn:aws:s3tables:t",
"", "", "arn:aws:s3tables:b", ctx) {
t.Error("identity actions and default allow must not reach an ownerless resource")
}
// With no identity or default grants in play, an ownerless resource
// side contributes nothing even when its own policy allows.
plainCtx := &PolicyContext{}
if CheckTableOrBucketPermission("GetTable", "user1",
"", allowPolicy, "arn:aws:s3tables:t",
"owner", "", "arn:aws:s3tables:b", plainCtx) {
t.Error("an ownerless resource side must not grant even via an explicit allow")
}
if !CheckTableOrBucketPermission("GetTable", "user1",
"", "", "arn:aws:s3tables:t",
"owner", allowPolicy, "arn:aws:s3tables:b", plainCtx) {
t.Error("the bucket side can still grant when its owner is set")
}
}
// Owners written before issuer scoping hold a bare subject. Re-anchoring it
// is only unambiguous when exactly one OIDC issuer is configured; under two
// or more issuers a bare subject could belong to either, so it must not
// match anyone.
func TestCheckPermissionLegacyOwnerScopedToIssuer(t *testing.T) {
owner := "alice-sub"
principal := sts.ComputeParentUser(owner, "corp-idp")
if !CheckPermissionWithContext("GetTable", principal, owner, "", "arn", &PolicyContext{
TrustedIssuers: []string{"corp-idp"},
}) {
t.Error("single-issuer deployments must keep recognizing the legacy owner")
}
if CheckPermissionWithContext("GetTable", principal, owner, "", "arn", &PolicyContext{
TrustedIssuers: []string{"corp-idp", "evil-idp"},
}) {
t.Error("a bare subject is ambiguous across multiple issuers and must not match")
}
if CheckPermissionWithContext("GetTable", sts.ComputeParentUser(owner, "evil-idp"), owner, "", "arn", &PolicyContext{
TrustedIssuers: []string{"corp-idp", "evil-idp"},
}) {
t.Error("a same-sub caller from another issuer must not claim the legacy owner")
}
if CheckPermissionWithContext("GetTable", principal, owner, "", "arn", &PolicyContext{}) {
t.Error("legacy owner matching must not engage without a trusted issuer")
}
}
+7 -154
View File
@@ -18,7 +18,6 @@ import (
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
@@ -344,8 +343,6 @@ func (fs *FilerServer) ObjectTransaction(ctx context.Context, req *filer_pb.Obje
ConditionKey: req.ConditionKey,
RouteKey: req.RouteKey,
IsMoved: true,
RequestId: req.RequestId,
ConditionAltKey: req.ConditionAltKey,
}
var resp *filer_pb.ObjectTransactionResponse
handled, err := fs.forwardToWriteOwner(ctx, req.RouteKey, func(owner pb.ServerAddress) error {
@@ -368,28 +365,6 @@ func (fs *FilerServer) ObjectTransaction(ctx context.Context, req *filer_pb.Obje
pathLock := fs.entryLockTable.AcquireLock("ObjectTransaction", lockPath, util.ExclusiveLock)
defer fs.entryLockTable.ReleaseLock(lockPath, pathLock)
// Under the same lock the first application committed with, a failover
// replay of the same request replays its recorded outcome instead of
// re-applying mutations over whatever superseded it.
if resp, found := fs.recentObjectTxn(req.RequestId, lockPath); found {
glog.V(2).InfofCtx(ctx, "ObjectTransaction %s: replaying recorded outcome for request %s", req.LockKey, req.RequestId)
return resp, nil
}
resp := &filer_pb.ObjectTransactionResponse{}
// Outcomes are replayed rather than re-run: a mutation error may follow a
// partial apply, and a retry that re-runs could write the stale mutation
// over whatever superseded it, while replaying the recorded failure at
// least matches what the first attempt reported. The one exception is the
// condition lookup below: it fails before any mutation runs, so the retry
// must re-evaluate rather than replay an empty outcome.
record := true
defer func() {
if record {
fs.rememberObjectTxn(req.RequestId, lockPath, resp)
}
}()
if conditionIsSet(req.Condition) {
// The condition is evaluated against condition_key when set (e.g. a
// version entry whose WORM guards gate the delete), while the lock stays
@@ -399,151 +374,29 @@ func (fs *FilerServer) ObjectTransaction(ctx context.Context, req *filer_pb.Obje
conditionPath = util.FullPath(req.ConditionKey)
}
current, findErr := fs.filer.FindEntry(ctx, conditionPath)
if findErr == nil && current != nil && current.IsDirectory() && req.ConditionAltKey != "" {
// A directory is not the object: implicit directories can share a
// path with an S3 key, so treat it as an absent slot. Only the
// alt-key callers want object-slot semantics; an explicit
// condition_key may legitimately name a directory.
current, findErr = nil, filer_pb.ErrNotFound
}
// The caller can name a fallback evaluated when the primary is absent —
// a suspended-versioning write checks "the null slot if present, else
// the latest version" atomically under this lock. condition_alt_key is
// the object's .versions directory, and the latest it resolves to is
// read from the pointer maintained by the same lock, so a concurrent
// version delete cannot move which entry the condition sees.
if findErr == filer_pb.ErrNotFound && req.ConditionAltKey != "" {
current, conditionPath, findErr = fs.latestVersionedConditionEntry(ctx, util.FullPath(req.ConditionAltKey))
}
if findErr != nil && findErr != filer_pb.ErrNotFound {
record = false // a lookup error decides nothing; a retry must re-run, not replay
return nil, fmt.Errorf("ObjectTransaction condition %s: %w", conditionPath, findErr)
return &filer_pb.ObjectTransactionResponse{}, fmt.Errorf("ObjectTransaction condition %s: %w", conditionPath, findErr)
}
if findErr == filer_pb.ErrNotFound {
current = nil
}
if !writeConditionSatisfied(req.Condition, current) {
glog.V(3).InfofCtx(ctx, "ObjectTransaction %s: precondition failed", conditionPath)
resp.Error = "precondition failed"
resp.ErrorCode = filer_pb.FilerError_PRECONDITION_FAILED
return resp, nil
return &filer_pb.ObjectTransactionResponse{
Error: "precondition failed",
ErrorCode: filer_pb.FilerError_PRECONDITION_FAILED,
}, nil
}
}
for i, m := range req.Mutations {
if err := fs.applyObjectMutation(ctx, m, req.IsFromOtherCluster, req.Signatures); err != nil {
glog.V(2).InfofCtx(ctx, "ObjectTransaction %s mutation %d (%v): %v", lockPath, i, m.Type, err)
resp.Error = fmt.Sprintf("mutation %d: %v", i, err)
return resp, nil
return &filer_pb.ObjectTransactionResponse{Error: fmt.Sprintf("mutation %d: %v", i, err)}, nil
}
}
return resp, nil
}
const (
recentObjectTxnTTL = 2 * time.Minute
recentObjectTxnSweepCap = 10000
recentObjectTxnSweepPeriod = 30 * time.Second
)
type recentObjectTxn struct {
resp *filer_pb.ObjectTransactionResponse
expiresAt time.Time
}
// recentObjectTxnKey scopes an idempotency token to the transaction's lock
// path, so a token reused on a different object cannot replay this outcome.
func recentObjectTxnKey(requestID string, lockPath util.FullPath) string {
return requestID + "\x00" + string(lockPath)
}
// recentObjectTxn returns the recorded outcome of an already-applied request,
// so a failover replay of the same ObjectTransaction does not re-apply it.
func (fs *FilerServer) recentObjectTxn(requestID string, lockPath util.FullPath) (*filer_pb.ObjectTransactionResponse, bool) {
if requestID == "" {
return nil, false
}
fs.recentObjectTxnsMu.Lock()
defer fs.recentObjectTxnsMu.Unlock()
key := recentObjectTxnKey(requestID, lockPath)
txn, found := fs.recentObjectTxns[key]
if !found {
return nil, false
}
if time.Now().After(txn.expiresAt) {
delete(fs.recentObjectTxns, key)
return nil, false
}
return txn.resp, true
}
func (fs *FilerServer) rememberObjectTxn(requestID string, lockPath util.FullPath, resp *filer_pb.ObjectTransactionResponse) {
if requestID == "" || resp == nil {
return
}
fs.recentObjectTxnsMu.Lock()
defer fs.recentObjectTxnsMu.Unlock()
if fs.recentObjectTxns == nil {
fs.recentObjectTxns = make(map[string]recentObjectTxn)
}
// A live record is never evicted early: a replay that misses re-applies the
// transaction. The sweep is amortized so a hot cache does not scan on every
// write; between sweeps the map may exceed the cap, still bounded by the
// insert rate over the TTL.
now := time.Now()
if len(fs.recentObjectTxns) >= recentObjectTxnSweepCap {
if now.After(fs.recentObjectTxnsSweepAt) {
fs.recentObjectTxnsSweepAt = now.Add(recentObjectTxnSweepPeriod)
for id, txn := range fs.recentObjectTxns {
if now.After(txn.expiresAt) {
delete(fs.recentObjectTxns, id)
}
}
}
if len(fs.recentObjectTxns) >= recentObjectTxnSweepCap {
// Full of unexpired records: drop the new one rather than evict
// an outcome a replay may still need.
return
}
}
fs.recentObjectTxns[recentObjectTxnKey(requestID, lockPath)] = recentObjectTxn{
resp: resp,
expiresAt: now.Add(recentObjectTxnTTL),
}
}
// latestVersionedConditionEntry resolves the object a conditional write should
// be checked against inside a .versions directory: the entry the latest-version
// pointer names, or nothing when the pointer names a delete marker — a
// tombstone is not a current version. A missing pointer or a pointer to a file
// that is gone means the metadata cannot answer the question, which reports an
// error rather than guessing at either branch.
func (fs *FilerServer) latestVersionedConditionEntry(ctx context.Context, versionsDir util.FullPath) (*filer.Entry, util.FullPath, error) {
versionsEntry, err := fs.filer.FindEntry(ctx, versionsDir)
if err != nil {
return nil, versionsDir, err
}
if versionsEntry == nil {
return nil, versionsDir, filer_pb.ErrNotFound
}
latestName := string(versionsEntry.Extended[s3_constants.ExtLatestVersionFileNameKey])
if latestName == "" {
return nil, versionsDir, fmt.Errorf("%s has no latest-version pointer", versionsDir)
}
latestPath := util.NewFullPath(string(versionsDir), latestName)
entry, err := fs.filer.FindEntry(ctx, latestPath)
if err == filer_pb.ErrNotFound {
return nil, latestPath, fmt.Errorf("latest-version pointer %s names a missing entry", latestPath)
}
if err != nil {
return nil, latestPath, err
}
if string(entry.Extended[s3_constants.ExtDeleteMarkerKey]) == "true" {
return nil, latestPath, filer_pb.ErrNotFound
}
return entry, latestPath, nil
return &filer_pb.ObjectTransactionResponse{}, nil
}
// ObjectTransactionBatch applies several object transactions in one round trip,
@@ -2,11 +2,8 @@ package weed_server
import (
"context"
"fmt"
"net"
"os"
"strconv"
"strings"
"testing"
"time"
@@ -855,243 +852,3 @@ func TestObjectTransactionForwardsToOwner(t *testing.T) {
t.Errorf("non-owner must forward, not apply locally")
}
}
// A failover replay carries the same request_id. Under the path lock the owner
// replays the recorded outcome instead of re-applying the mutations over a
// newer entry.
func TestObjectTransactionRequestIdDedupesReplay(t *testing.T) {
now := time.Unix(1700000000, 0)
fs, store := newTxnTestServer(t, nil)
put := func(etag string) *filer_pb.ObjectMutation {
return &filer_pb.ObjectMutation{
Type: filer_pb.ObjectMutation_PUT,
Directory: "/buckets/bkt",
Entry: &filer_pb.Entry{
Name: "obj",
Attributes: &filer_pb.FuseAttributes{Mtime: now.Unix(), FileMode: 0644, Inode: 1},
Extended: map[string][]byte{"etag": []byte(etag)},
},
}
}
txn := func(requestID, etag string) *filer_pb.ObjectTransactionRequest {
return &filer_pb.ObjectTransactionRequest{
LockKey: "/buckets/bkt/obj",
RequestId: requestID,
Mutations: []*filer_pb.ObjectMutation{put(etag)},
}
}
respA, err := fs.ObjectTransaction(context.Background(), txn("req-a", "A"))
if err != nil || respA.Error != "" {
t.Fatalf("first put: err=%v resp=%q", err, respA.Error)
}
// a newer writer lands the same path
respB, err := fs.ObjectTransaction(context.Background(), txn("req-b", "B"))
if err != nil || respB.Error != "" {
t.Fatalf("second put: err=%v resp=%q", err, respB.Error)
}
// the replayed request replays A's outcome; the stored entry stays B
respReplay, err := fs.ObjectTransaction(context.Background(), txn("req-a", "A"))
if err != nil || respReplay.Error != "" {
t.Fatalf("replayed put: err=%v resp=%q", err, respReplay.Error)
}
entry := store.entries["/buckets/bkt/obj"]
if entry == nil {
t.Fatal("entry missing after replay")
}
if got := string(entry.Extended["etag"]); got != "B" {
t.Fatalf("replayed transaction re-applied the stale entry: etag = %q, want B", got)
}
}
// A recorded application-level failure replays to the retried request even
// after the world changes enough for the condition to pass, and its mutation
// still does not run.
func TestObjectTransactionRequestIdReplaysRecordedError(t *testing.T) {
now := time.Unix(1700000000, 0)
fs, store := newTxnTestServer(t, nil)
req := &filer_pb.ObjectTransactionRequest{
LockKey: "/buckets/bkt/obj",
RequestId: "req-fail",
Condition: &filer_pb.WriteCondition{
Clauses: []*filer_pb.WriteCondition_Clause{{Kind: filer_pb.WriteCondition_IF_EXISTS}},
},
Mutations: []*filer_pb.ObjectMutation{{
Type: filer_pb.ObjectMutation_PUT,
Directory: "/buckets/bkt",
Entry: &filer_pb.Entry{
Name: "obj",
Attributes: &filer_pb.FuseAttributes{Mtime: now.Unix(), FileMode: 0644, Inode: 1},
Extended: map[string][]byte{"etag": []byte("stale")},
},
}},
}
resp, err := fs.ObjectTransaction(context.Background(), req)
if err != nil || resp.ErrorCode != filer_pb.FilerError_PRECONDITION_FAILED {
t.Fatalf("precondition: err=%v code=%v", err, resp.GetErrorCode())
}
// another writer lands the object the precondition wanted
other := &filer_pb.ObjectTransactionRequest{
LockKey: "/buckets/bkt/obj",
RequestId: "req-other",
Mutations: []*filer_pb.ObjectMutation{{
Type: filer_pb.ObjectMutation_PUT,
Directory: "/buckets/bkt",
Entry: &filer_pb.Entry{
Name: "obj",
Attributes: &filer_pb.FuseAttributes{Mtime: now.Unix(), FileMode: 0644, Inode: 2},
Extended: map[string][]byte{"etag": []byte("live")},
},
}},
}
respOther, err := fs.ObjectTransaction(context.Background(), other)
if err != nil || respOther.Error != "" {
t.Fatalf("other put: err=%v resp=%q", err, respOther.Error)
}
// the retry replays the recorded failure instead of re-evaluating and
// overwriting the live entry
resp2, err := fs.ObjectTransaction(context.Background(), req)
if err != nil || resp2.ErrorCode != filer_pb.FilerError_PRECONDITION_FAILED {
t.Fatalf("replayed precondition: err=%v code=%v", err, resp2.GetErrorCode())
}
if got := string(store.entries["/buckets/bkt/obj"].Extended["etag"]); got != "live" {
t.Fatalf("replayed transaction overwrote the live entry: etag = %q", got)
}
}
// At capacity the cache drops new recordings rather than evicting a live
// outcome a replay may still need.
func TestObjectTransactionReplayCacheDropsNewAtCap(t *testing.T) {
fs, _ := newTxnTestServer(t, nil)
path := util.FullPath("/buckets/bkt/obj")
resp := &filer_pb.ObjectTransactionResponse{}
for i := 0; i < recentObjectTxnSweepCap; i++ {
fs.rememberObjectTxn(fmt.Sprintf("req-%d", i), path, resp)
}
fs.rememberObjectTxn("req-overflow", path, resp)
if _, found := fs.recentObjectTxn("req-0", path); !found {
t.Fatal("a live record was evicted to admit a new one")
}
if _, found := fs.recentObjectTxn("req-overflow", path); found {
t.Fatal("overflow record was recorded instead of dropped")
}
if len(fs.recentObjectTxns) > recentObjectTxnSweepCap {
t.Fatalf("cache exceeded cap: %d", len(fs.recentObjectTxns))
}
}
// A suspended-versioning conditional write falls back to the latest version
// under the transaction lock: a real latest satisfies existence checks, and a
// delete marker reads as absent.
func TestObjectTransactionConditionFallbackResolvesLatestPointer(t *testing.T) {
now := time.Unix(1700000000, 0)
seed := func(latestName, marker string) map[string]*filer.Entry {
entries := map[string]*filer.Entry{
"/buckets/bkt/obj/.versions": {
Attr: filer.Attr{Inode: 2, Mtime: now, Crtime: now, Mode: 0755},
Extended: map[string][]byte{s3_constants.ExtLatestVersionFileNameKey: []byte(latestName)},
},
}
ext := map[string][]byte{s3_constants.ExtETagKey: []byte("abc")}
if marker != "" {
ext[s3_constants.ExtDeleteMarkerKey] = []byte(marker)
}
entries["/buckets/bkt/obj/.versions/"+latestName] = &filer.Entry{
Attr: filer.Attr{Inode: 3, Mtime: now, Crtime: now, Mode: 0644},
Extended: ext,
}
return entries
}
req := func() *filer_pb.ObjectTransactionRequest {
return &filer_pb.ObjectTransactionRequest{
LockKey: "/buckets/bkt/obj",
ConditionAltKey: "/buckets/bkt/obj/.versions",
Condition: &filer_pb.WriteCondition{
Clauses: []*filer_pb.WriteCondition_Clause{{Kind: filer_pb.WriteCondition_IF_NOT_EXISTS}},
},
Mutations: []*filer_pb.ObjectMutation{{
Type: filer_pb.ObjectMutation_PUT,
Directory: "/buckets/bkt",
Entry: &filer_pb.Entry{Name: "obj", Attributes: &filer_pb.FuseAttributes{Mtime: now.Unix(), FileMode: 0644, Inode: 9}},
}},
}
}
fs, store := newTxnTestServer(t, seed("v_live", ""))
resp, err := fs.ObjectTransaction(context.Background(), req())
if err != nil || resp.ErrorCode != filer_pb.FilerError_PRECONDITION_FAILED {
t.Fatalf("latest version present: err=%v code=%v, want precondition failed", err, resp.GetErrorCode())
}
fs, store = newTxnTestServer(t, seed("v_gone", "true"))
resp, err = fs.ObjectTransaction(context.Background(), req())
if err != nil || resp.Error != "" {
t.Fatalf("delete-marker latest: err=%v resp=%q, want applied", err, resp.Error)
}
if store.entries["/buckets/bkt/obj"] == nil {
t.Fatal("mutation did not apply over a delete-marker latest")
}
}
// A bare directory at the object path — left over when /bkt/obj/child was
// created — is not the object, so the condition must fall back to the
// .versions resolution instead of counting the directory as an existing key.
func TestObjectTransactionConditionIgnoresDirectory(t *testing.T) {
now := time.Unix(1700000000, 0)
seed := func(marker string) map[string]*filer.Entry {
return map[string]*filer.Entry{
"/buckets/bkt/obj": {
Attr: filer.Attr{Inode: 1, Mtime: now, Crtime: now, Mode: 0755 | os.ModeDir},
},
"/buckets/bkt/obj/.versions": {
Attr: filer.Attr{Inode: 2, Mtime: now, Crtime: now, Mode: 0755},
Extended: map[string][]byte{s3_constants.ExtLatestVersionFileNameKey: []byte("v1")},
},
"/buckets/bkt/obj/.versions/v1": {
Attr: filer.Attr{Inode: 3, Mtime: now, Crtime: now, Mode: 0644},
Extended: map[string][]byte{s3_constants.ExtETagKey: []byte("abc"), s3_constants.ExtDeleteMarkerKey: []byte(marker)},
},
}
}
req := &filer_pb.ObjectTransactionRequest{
LockKey: "/buckets/bkt/obj",
ConditionAltKey: "/buckets/bkt/obj/.versions",
Condition: &filer_pb.WriteCondition{
Clauses: []*filer_pb.WriteCondition_Clause{{Kind: filer_pb.WriteCondition_IF_NOT_EXISTS}},
},
Mutations: []*filer_pb.ObjectMutation{{
Type: filer_pb.ObjectMutation_PUT,
Directory: "/buckets/bkt",
Entry: &filer_pb.Entry{Name: "obj", Attributes: &filer_pb.FuseAttributes{Mtime: now.Unix(), FileMode: 0644, Inode: 9}},
}},
}
fs, _ := newTxnTestServer(t, seed("true"))
resp, err := fs.ObjectTransaction(context.Background(), req)
if err != nil {
t.Fatalf("directory at key with delete-marker latest: %v", err)
}
// The condition must pass so the write reaches the type-conflict path;
// the directory itself rejects the PUT, which is the caller's business.
if resp.ErrorCode == filer_pb.FilerError_PRECONDITION_FAILED {
t.Fatalf("condition must not fail on a bare directory: resp=%q", resp.Error)
}
if !strings.Contains(resp.Error, "is a directory") {
t.Fatalf("want the dir/file type-conflict error, got %q", resp.Error)
}
fs, _ = newTxnTestServer(t, seed(""))
resp, err = fs.ObjectTransaction(context.Background(), req)
if err != nil || resp.ErrorCode != filer_pb.FilerError_PRECONDITION_FAILED {
t.Fatalf("directory at key with live latest: err=%v code=%v, want precondition failed", err, resp.GetErrorCode())
}
}
-8
View File
@@ -142,13 +142,6 @@ type FilerServer struct {
recentCopyRequestsMu sync.Mutex
recentCopyRequests map[string]recentCopyRequest
// outcomes of recently applied object transactions keyed by request_id, so
// a failover replay of the same transaction replays its outcome instead of
// re-applying its mutations over a newer entry
recentObjectTxnsMu sync.Mutex
recentObjectTxns map[string]recentObjectTxn
recentObjectTxnsSweepAt time.Time
// credential manager for IAM operations
CredentialManager *credential.CredentialManager
@@ -243,7 +236,6 @@ func NewFilerServer(defaultMux, readonlyMux *http.ServeMux, option *FilerOption)
subscribers: make(map[int32]*metadataSubscriber),
inFlightDataLimitCond: sync.NewCond(new(sync.Mutex)),
recentCopyRequests: make(map[string]recentCopyRequest),
recentObjectTxns: make(map[string]recentObjectTxn),
CredentialManager: option.CredentialManager,
entryLockTable: util.NewLockTable[util.FullPath](),
posixLocks: posixlock.NewManager(),
@@ -1,339 +0,0 @@
package weed_server
import (
"archive/zip"
"bytes"
"context"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path"
"strconv"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/operation"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/constants"
)
type FilerExtractResult struct {
Name string `json:"name,omitempty"`
Extracted []*FilerPostResult `json:"extracted,omitempty"`
Errors []string `json:"errors,omitempty"`
}
func isExtractZip(r *http.Request) bool {
return r.URL.Query().Get("extract") == "zip" ||
r.Header.Get(s3_constants.SeaweedFSExtract) == "zip"
}
// extractZipArchive writes a posted archive's members as individual entries
// under the target directory, like AWS Snowball's auto-extract. The archive
// itself is only stored when ?extract.keep=true.
func (fs *FilerServer) extractZipArchive(ctx context.Context, w http.ResponseWriter, r *http.Request) {
var reader io.Reader = r.Body
archiveName := path.Base(r.URL.Path)
if r.Method == http.MethodPost {
if mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); strings.HasPrefix(mediaType, "multipart/") {
multipartReader, multipartReaderErr := r.MultipartReader()
if multipartReaderErr != nil {
writeJsonError(w, r, http.StatusBadRequest, multipartReaderErr)
return
}
part1, part1Err := multipartReader.NextPart()
if part1Err != nil {
writeJsonError(w, r, http.StatusBadRequest, part1Err)
return
}
defer part1.Close()
reader = part1
if fileName := part1.FileName(); fileName != "" {
archiveName = path.Base(fileName)
}
}
}
ra, size, cleanup, err := filer.SpoolZipReader(reader, fs.option.TusMaxSize)
if err != nil {
code := http.StatusInternalServerError
if errors.Is(err, filer.ErrZipTooLarge) {
code = http.StatusRequestEntityTooLarge
}
writeJsonError(w, r, code, fmt.Errorf("read input: %w", err))
return
}
defer cleanup()
zr, err := zip.NewReader(ra, size)
if err != nil {
writeJsonError(w, r, http.StatusBadRequest, fmt.Errorf("extract zip: %w", err))
return
}
if err = filer.CheckZipLimits(zr); err != nil {
writeJsonError(w, r, http.StatusBadRequest, err)
return
}
root := r.URL.Path
if !strings.HasSuffix(root, "/") {
root = path.Dir(root)
}
root = strings.TrimSuffix(root, "/")
query := r.URL.Query()
chunkSize := fs.requestChunkSize(query)
// x-seaweedfs-destination picks storage rules for a logical destination
// while members land under the request path, mirroring PostHandler. The
// actual write path keeps its own read-only enforcement in
// saveExtractedEntry and mkdirExtracted.
ruleRoot := root
if headerDestination := r.Header.Get(s3_constants.SeaweedStorageDestinationHeader); headerDestination != "" {
ruleRoot = strings.TrimSuffix(headerDestination, "/")
}
// Members may land under storage rules different from the request path;
// resolve options per destination, keeping the request's explicit overrides.
memberStorageOption := func(rulePath, writePath string) (*operation.StorageOption, error) {
memberSO, detectErr := fs.detectStorageOption0(ctx, rulePath,
query.Get("collection"),
query.Get("replication"),
query.Get("ttl"),
query.Get("disk"),
query.Get("fsync"),
query.Get("dataCenter"),
query.Get("rack"),
query.Get("dataNode"),
query.Get("saveInside"),
)
if detectErr != nil {
return nil, detectErr
}
if memberSO.DiskType == "" {
memberSO.DiskType = fs.option.DiskType
}
if strings.HasPrefix(writePath, "/etc") {
memberSO.SaveInside = true
}
return memberSO, nil
}
result := &FilerExtractResult{Name: archiveName}
writeMember := func(rel string, memberReader io.Reader, memberSize int64, mtime time.Time, mode os.FileMode) {
destPath := root + "/" + rel
memberSO, soErr := memberStorageOption(ruleRoot+"/"+rel, destPath)
if soErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", rel, soErr))
return
}
memberResult, memberErr := fs.saveExtractedEntry(ctx, destPath, memberReader, memberSize, path.Base(rel), chunkSize, memberSO, mtime, mode)
if memberErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", rel, memberErr))
return
}
memberResult.Name = rel
result.Extracted = append(result.Extracted, memberResult)
}
mkdirMember := func(rel string, zf *zip.File) {
destPath := root + "/" + rel
memberSO, soErr := memberStorageOption(ruleRoot+"/"+rel, destPath)
if soErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", rel, soErr))
return
}
if dirErr := fs.mkdirExtracted(ctx, destPath, zf, memberSO); dirErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", rel, dirErr))
}
}
if query.Get("extract.keep") == "true" && archiveName != "." && archiveName != "/" {
mode := os.FileMode(0660)
if modeStr := query.Get("mode"); modeStr != "" {
if parsed, parseErr := strconv.ParseUint(modeStr, 8, 32); parseErr == nil {
mode = os.FileMode(parsed)
}
}
writeMember(archiveName, io.NewSectionReader(ra, 0, size), size, time.Now(), mode)
}
// Directory members first so their stored metadata is not overwritten by
// parents auto-created for an earlier file member.
for _, dirsOnly := range []bool{true, false} {
for _, zf := range zr.File {
if ctxErr := ctx.Err(); ctxErr != nil {
result.Errors = append(result.Errors, ctxErr.Error())
writeJsonQuiet(w, r, http.StatusCreated, result)
return
}
rel, ok := filer.SanitizeZipMemberName(zf)
if !ok {
if !dirsOnly {
result.Errors = append(result.Errors, fmt.Sprintf("%s: unsafe member name", zf.Name))
}
continue
}
if zf.FileInfo().IsDir() != dirsOnly {
continue
}
if dirsOnly {
mkdirMember(rel, zf)
continue
}
memberReader, openErr := zf.Open()
if openErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", rel, openErr))
continue
}
mode := os.FileMode(0660)
if perm := zf.Mode().Perm(); perm != 0 {
mode = perm
}
writeMember(rel, memberReader, int64(zf.UncompressedSize64), zf.Modified, mode)
memberReader.Close()
}
}
writeJsonQuiet(w, r, http.StatusCreated, result)
}
func (fs *FilerServer) mkdirExtracted(ctx context.Context, destPath string, zf *zip.File, so *operation.StorageOption) error {
if fs.filer.FilerConf.MatchStorageRule(destPath).ReadOnly {
return ErrReadOnly
}
existing, findErr := fs.filer.FindEntry(ctx, util.FullPath(destPath))
if findErr == nil {
if existing.IsDirectory() {
return nil
}
return fmt.Errorf("%s already exists as a file", destPath)
}
if !errors.Is(findErr, filer_pb.ErrNotFound) {
return findErr
}
mode := os.FileMode(0660)
if perm := zf.Mode().Perm(); perm != 0 {
mode = perm
}
mtime := zf.Modified
if mtime.IsZero() {
mtime = time.Now()
}
return fs.filer.CreateEntry(context.WithoutCancel(ctx), &filer.Entry{
FullPath: util.FullPath(destPath),
Attr: filer.Attr{
Mtime: mtime,
Crtime: time.Now(),
Mode: mode | os.ModeDir,
Uid: OS_UID,
Gid: OS_GID,
TtlSec: so.TtlSeconds,
},
}, nil, false, false, nil, false, so.MaxFileNameLength)
}
func (fs *FilerServer) saveExtractedEntry(ctx context.Context, destPath string, reader io.Reader, memberSize int64, fileName string, chunkSize int32, so *operation.StorageOption, mtime time.Time, mode os.FileMode) (*FilerPostResult, error) {
if fs.filer.FilerConf.MatchStorageRule(destPath).ReadOnly {
return nil, ErrReadOnly
}
wormEnforced, err := fs.wormEnforcedForEntry(ctx, destPath)
if err != nil {
return nil, err
}
if wormEnforced {
return nil, errors.New(constants.ErrMsgOperationNotPermitted)
}
if util.FullPath(destPath).IsLongerFileName(so.MaxFileNameLength) {
return nil, filer_pb.ErrEntryNameTooLong
}
contentType := mime.TypeByExtension(path.Ext(fileName))
// saveInside stores members as inline entry content like a normal
// multipart upload; the declared size keeps a corrupt archive from
// buffering more than it advertised.
if so.SaveInside {
if memberSize < 0 || memberSize > filer.ZipExtractMaxBytes {
return nil, fmt.Errorf("zip member %s size %d out of bounds", fileName, memberSize)
}
var buf bytes.Buffer
if _, err := buf.ReadFrom(io.LimitReader(reader, memberSize+1)); err != nil {
return nil, fmt.Errorf("read member: %w", err)
}
if int64(buf.Len()) > memberSize {
return nil, fmt.Errorf("zip member %s is larger than its declared size", fileName)
}
if mtime.IsZero() {
mtime = time.Now()
}
entry := &filer.Entry{
FullPath: util.FullPath(destPath),
Attr: filer.Attr{
Mtime: mtime,
Crtime: time.Now(),
Mode: mode,
Uid: OS_UID,
Gid: OS_GID,
TtlSec: so.TtlSeconds,
Mime: contentType,
Md5: util.Md5(buf.Bytes()),
FileSize: uint64(buf.Len()),
},
Content: buf.Bytes(),
}
if err := fs.filer.CreateEntry(context.WithoutCancel(ctx), entry, nil, false, false, nil, false, so.MaxFileNameLength); err != nil {
return nil, err
}
return &FilerPostResult{Name: fileName, Size: int64(buf.Len())}, nil
}
fileChunks, md5Hash, chunkOffset, uploadErr, smallContent := fs.uploadReaderToChunks(ctx, nil, reader, 0, chunkSize, fileName, contentType, false, so)
if uploadErr != nil {
return nil, uploadErr
}
if chunkOffset == 0 {
smallContent = []byte{}
}
mergedChunks, mergeErr := fs.maybeMergeChunks(ctx, so, fileChunks)
if mergeErr != nil {
glog.V(0).InfofCtx(ctx, "merge chunks %s: %v", destPath, mergeErr)
mergedChunks = fileChunks
}
mergedChunks, manifestErr := filer.MaybeManifestize(fs.saveAsChunk(ctx, so), fs.filer.DeleteChunksNotRecursive, mergedChunks)
if manifestErr != nil {
fs.filer.DeleteUncommittedChunks(ctx, mergedChunks)
return nil, manifestErr
}
if mtime.IsZero() {
mtime = time.Now()
}
entry := &filer.Entry{
FullPath: util.FullPath(destPath),
Attr: filer.Attr{
Mtime: mtime,
Crtime: time.Now(),
Mode: mode,
Uid: OS_UID,
Gid: OS_GID,
TtlSec: so.TtlSeconds,
Mime: contentType,
Md5: md5Hash.Sum(nil),
FileSize: uint64(chunkOffset),
},
Content: smallContent,
Chunks: mergedChunks,
}
if err := fs.filer.CreateEntry(context.WithoutCancel(ctx), entry, nil, false, false, nil, false, so.MaxFileNameLength); err != nil {
fs.filer.DeleteUncommittedChunks(ctx, mergedChunks)
return nil, err
}
return &FilerPostResult{Name: fileName, Size: chunkOffset}, nil
}
@@ -1,274 +0,0 @@
package weed_server
import (
"archive/zip"
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/util"
)
func buildTestZip(t *testing.T, members map[string]string, dirs []string, unsafe []string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, dir := range dirs {
h := &zip.FileHeader{Name: dir, Method: zip.Store, Modified: time.Unix(1700000000, 0)}
h.SetMode(0755 | 1<<31)
if _, err := zw.CreateHeader(h); err != nil {
t.Fatalf("create dir %s: %v", dir, err)
}
}
write := func(name, content string) {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("create %s: %v", name, err)
}
if _, err := w.Write([]byte(content)); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
for name, content := range members {
write(name, content)
}
for _, name := range unsafe {
write(name, "evil")
}
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
return buf.Bytes()
}
func newExtractTestServer(t *testing.T) (*FilerServer, *filer.Filer) {
store := newRenameTestStore()
f := newRenameTestFiler(t, store)
return &FilerServer{
filer: f,
option: &FilerOption{
MaxMB: 1,
SaveToFilerLimit: 1 << 20,
},
}, f
}
func TestExtractZipArchivePut(t *testing.T) {
fs, f := newExtractTestServer(t)
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
dirHeader := &zip.FileHeader{Name: "empty/", Method: zip.Store, Modified: time.Unix(1700000000, 0)}
dirHeader.SetMode(0755 | 1<<31)
if _, err := zw.CreateHeader(dirHeader); err != nil {
t.Fatal(err)
}
fileHeader := &zip.FileHeader{Name: "a/b.txt", Modified: time.Unix(1600000000, 0)}
fileHeader.SetMode(0600)
fw, err := zw.CreateHeader(fileHeader)
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte("hello")); err != nil {
t.Fatal(err)
}
pngW, err := zw.Create("c.png")
if err != nil {
t.Fatal(err)
}
if _, err := pngW.Write([]byte("\x89png")); err != nil {
t.Fatal(err)
}
evil, err := zw.Create("../evil.txt")
if err != nil {
t.Fatal(err)
}
if _, err := evil.Write([]byte("evil")); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
zipBytes := buf.Bytes()
r := httptest.NewRequest(http.MethodPut, "/dst/archive.zip?extract=zip", bytes.NewReader(zipBytes))
w := httptest.NewRecorder()
fs.PostHandler(w, r, int64(len(zipBytes)))
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201, body: %s", w.Code, w.Body.String())
}
var result FilerExtractResult
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("decode result: %v", err)
}
if len(result.Extracted) != 2 {
t.Fatalf("extracted %d members, want 2: %+v", len(result.Extracted), result)
}
if len(result.Errors) != 1 {
t.Fatalf("errors = %v, want 1 unsafe-name entry", result.Errors)
}
entry, err := f.FindEntry(r.Context(), util.FullPath("/dst/a/b.txt"))
if err != nil {
t.Fatalf("find extracted member: %v", err)
}
if string(entry.Content) != "hello" {
t.Fatalf("member content = %q", entry.Content)
}
if entry.Mtime.Unix() != 1600000000 {
t.Fatalf("member mtime = %v, want %v", entry.Mtime, time.Unix(1600000000, 0))
}
if entry.Mode.Perm() != 0600 {
t.Fatalf("member mode = %v, want 0600", entry.Mode.Perm())
}
dirEntry, err := f.FindEntry(r.Context(), util.FullPath("/dst/empty"))
if err != nil || !dirEntry.IsDirectory() {
t.Fatalf("dir member missing: %v %+v", err, dirEntry)
}
if dirEntry.Mtime.Unix() != 1700000000 {
t.Fatalf("dir mtime = %v, want %v", dirEntry.Mtime, time.Unix(1700000000, 0))
}
if _, err := f.FindEntry(r.Context(), util.FullPath("/dst/archive.zip")); err == nil {
t.Fatal("archive should not be stored without extract.keep")
}
if _, err := f.FindEntry(r.Context(), util.FullPath("/dst/../evil.txt")); err == nil {
t.Fatal("unsafe member should not be extracted")
}
if _, err := f.FindEntry(r.Context(), util.FullPath("/evil.txt")); err == nil {
t.Fatal("zip-slip member escaped the extraction root")
}
}
func TestExtractZipArchiveKeep(t *testing.T) {
fs, f := newExtractTestServer(t)
zipBytes := buildTestZip(t, map[string]string{"x.txt": "data"}, nil, nil)
r := httptest.NewRequest(http.MethodPut, "/dst/pack.zip?extract=zip&extract.keep=true", bytes.NewReader(zipBytes))
w := httptest.NewRecorder()
fs.PostHandler(w, r, int64(len(zipBytes)))
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
}
archive, err := f.FindEntry(r.Context(), util.FullPath("/dst/pack.zip"))
if err != nil {
t.Fatalf("archive not stored with extract.keep: %v", err)
}
if int64(len(archive.Content)) != int64(len(zipBytes)) {
t.Fatalf("archive size = %d, want %d", len(archive.Content), len(zipBytes))
}
if _, err := f.FindEntry(r.Context(), util.FullPath("/dst/x.txt")); err != nil {
t.Fatalf("member missing: %v", err)
}
}
func TestExtractZipArchiveNotAZip(t *testing.T) {
fs, _ := newExtractTestServer(t)
r := httptest.NewRequest(http.MethodPut, "/dst/notzip.bin?extract=zip", bytes.NewReader([]byte("not a zip")))
w := httptest.NewRecorder()
fs.PostHandler(w, r, 9)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
}
// A member larger than chunkSize must not take the inline fast path when
// SaveToFilerLimit exceeds chunkSize — the first chunk is not the whole member.
func TestExtractZipArchiveMemberLargerThanChunk(t *testing.T) {
store := newRenameTestStore()
f := newRenameTestFiler(t, store)
fs, _ := newInlineAppendUploadServer(t, f, 4<<20, 1) // inline limit exceeds the 1 MiB chunkSize
member := bytes.Repeat([]byte("z"), (1<<20)+1)
zipBytes := buildTestZip(t, map[string]string{"big.bin": string(member)}, nil, nil)
r := httptest.NewRequest(http.MethodPut, "/dst/pack.zip?extract=zip", bytes.NewReader(zipBytes))
w := httptest.NewRecorder()
fs.PostHandler(w, r, int64(len(zipBytes)))
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
}
var result FilerExtractResult
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("decode result: %v", err)
}
if len(result.Errors) != 0 {
t.Fatalf("member errors: %v", result.Errors)
}
entry, err := f.FindEntry(r.Context(), util.FullPath("/dst/big.bin"))
if err != nil {
t.Fatalf("member missing: %v", err)
}
if int64(entry.Attr.FileSize) != int64(len(member)) {
t.Fatalf("member stored truncated: %d bytes, want %d", entry.Attr.FileSize, len(member))
}
}
func TestExtractZipArchiveRawPost(t *testing.T) {
fs, f := newExtractTestServer(t)
zipBytes := buildTestZip(t, map[string]string{"raw.txt": "raw"}, nil, nil)
r := httptest.NewRequest(http.MethodPost, "/dst/?extract=zip", bytes.NewReader(zipBytes))
r.Header.Set("Content-Type", "application/zip")
w := httptest.NewRecorder()
fs.PostHandler(w, r, int64(len(zipBytes)))
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
}
entry, err := f.FindEntry(r.Context(), util.FullPath("/dst/raw.txt"))
if err != nil {
t.Fatalf("member missing: %v", err)
}
if string(entry.Content) != "raw" {
t.Fatalf("member content = %q", entry.Content)
}
}
func TestExtractZipArchiveMultipartPost(t *testing.T) {
fs, f := newExtractTestServer(t)
zipBytes := buildTestZip(t, map[string]string{"img/1.png": "png-bytes"}, nil, nil)
var body bytes.Buffer
mw := multipartWriter(t, &body, "bundle.zip", zipBytes)
r := httptest.NewRequest(http.MethodPost, "/assets/?extract=zip", &body)
r.Header.Set("Content-Type", mw)
w := httptest.NewRecorder()
fs.PostHandler(w, r, int64(body.Len()))
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
}
entry, err := f.FindEntry(r.Context(), util.FullPath("/assets/img/1.png"))
if err != nil {
t.Fatalf("member missing: %v", err)
}
if string(entry.Content) != "png-bytes" {
t.Fatalf("member content = %q", entry.Content)
}
if _, err := f.FindEntry(r.Context(), util.FullPath("/assets/bundle.zip")); err == nil {
t.Fatal("archive should not be stored")
}
}
func multipartWriter(t *testing.T, buf *bytes.Buffer, fileName string, data []byte) string {
t.Helper()
mw := multipart.NewWriter(buf)
part, err := mw.CreateFormFile("file", fileName)
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := part.Write(data); err != nil {
t.Fatalf("write part: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("close multipart: %v", err)
}
return mw.FormDataContentType()
}
@@ -141,8 +141,6 @@ func (fs *FilerServer) PostHandler(w http.ResponseWriter, r *http.Request, conte
fs.move(ctx, w, r, so)
} else if query.Has("cp.from") {
fs.copy(ctx, w, r, so)
} else if isExtractZip(r) {
fs.extractZipArchive(ctx, w, r)
} else {
fs.autoChunk(ctx, w, r, contentLength, so)
}
@@ -6,9 +6,7 @@ import (
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"path"
"strconv"
@@ -31,7 +29,13 @@ func (fs *FilerServer) autoChunk(ctx context.Context, w http.ResponseWriter, r *
// autoChunking can be set at the command-line level or as a query param. Query param overrides command-line
query := r.URL.Query()
chunkSize := fs.requestChunkSize(query)
parsedMaxMB, _ := strconv.ParseInt(query.Get("maxMB"), 10, 32)
maxMB := int32(parsedMaxMB)
if maxMB <= 0 && fs.option.MaxMB > 0 {
maxMB = int32(fs.option.MaxMB)
}
chunkSize := 1024 * 1024 * maxMB
var reply *FilerPostResult
var err error
@@ -174,22 +178,6 @@ func skipCheckParentDirEntry(r *http.Request) bool {
return r.URL.Query().Get("skipCheckParentDir") == "true"
}
// requestChunkSize resolves the auto-chunking size from the maxMB query param
// or the server default, clamped so the int32 chunk size cannot overflow into
// a non-positive value that would drop the upload's contents.
func (fs *FilerServer) requestChunkSize(query url.Values) int32 {
parsedMaxMB, _ := strconv.ParseInt(query.Get("maxMB"), 10, 32)
maxMB := parsedMaxMB
if maxMB <= 0 {
maxMB = int64(fs.option.MaxMB)
}
const maxChunkSizeMB = math.MaxInt32 / (1024 * 1024) // 2047
if maxMB > maxChunkSizeMB {
maxMB = maxChunkSizeMB
}
return int32(maxMB) * 1024 * 1024
}
func (fs *FilerServer) checkPermissions(ctx context.Context, r *http.Request, fileName string) error {
fullPath := fs.fixFilePath(ctx, r, fileName)
enforced, err := fs.wormEnforcedForEntry(ctx, fullPath)
@@ -149,9 +149,7 @@ func (fs *FilerServer) uploadReaderToChunks(ctx context.Context, r *http.Request
break
}
if chunkOffset == 0 && !isAppend {
// A short first read proves EOF, so the buffer holds the whole file.
// A full chunk may have more data behind it — store it chunked.
if dataSize < fs.option.SaveToFilerLimit && dataSize < int64(chunkSize) {
if dataSize < fs.option.SaveToFilerLimit {
chunkOffset += dataSize
smallContent = make([]byte, dataSize)
bytesBuffer.Read(smallContent)
-36
View File
@@ -14,7 +14,6 @@ import (
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"google.golang.org/grpc/status"
)
var numCPU = runtime.NumCPU()
@@ -97,16 +96,6 @@ func (vs *VolumeServer) VacuumVolumeCommit(ctx context.Context, req *volume_serv
return nil, err
}
release, err := vs.acquireVacuumCommitSlot(ctx)
if err != nil {
glog.Warningf("commit volume %d not started: %v", req.VolumeId, err)
return nil, status.FromContextError(err).Err()
}
defer release()
if err := vs.CheckMaintenanceMode(); err != nil {
return nil, err
}
start := time.Now()
defer func(start time.Time) {
stats.VolumeServerVacuumingHistogram.WithLabelValues("commit").Observe(time.Since(start).Seconds())
@@ -128,31 +117,6 @@ func (vs *VolumeServer) VacuumVolumeCommit(ctx context.Context, req *volume_serv
}
// acquireVacuumCommitSlot waits for a commit slot holding no volume lock; the
// wait counts against the caller's deadline.
func (vs *VolumeServer) acquireVacuumCommitSlot(ctx context.Context) (release func(), err error) {
if vs.vacuumCommitSlots == nil {
return func() {}, nil
}
vs.vacuumCommitsWaiting.Add(1)
select {
case vs.vacuumCommitSlots <- struct{}{}:
case <-ctx.Done():
err = ctx.Err()
}
vs.vacuumCommitsWaiting.Add(-1)
if err != nil {
return nil, err
}
release = func() { <-vs.vacuumCommitSlots }
// select picks randomly when the slot and the deadline are ready together.
if err := ctx.Err(); err != nil {
release()
return nil, err
}
return release, nil
}
func (vs *VolumeServer) VacuumVolumeCleanup(ctx context.Context, req *volume_server_pb.VacuumVolumeCleanupRequest) (*volume_server_pb.VacuumVolumeCleanupResponse, error) {
if err := vs.checkGrpcAdminAuth(ctx); err != nil {
return nil, err
-225
View File
@@ -1,225 +0,0 @@
package weed_server
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeVacuumCompactStream struct {
grpc.ServerStream
ctx context.Context
}
func (s *fakeVacuumCompactStream) Context() context.Context { return s.ctx }
func (s *fakeVacuumCompactStream) Send(*volume_server_pb.VacuumVolumeCompactResponse) error {
return nil
}
func newVacuumCommitTestServer(t *testing.T, maxParallelCommits int, vids ...needle.VolumeId) *VolumeServer {
t.Helper()
store := newTraversalTestStore(t.TempDir())
t.Cleanup(store.Close)
for _, vid := range vids {
require.NoError(t, store.AddVolume(vid, "", storage.NeedleMapInMemory, "000", "", 0, needle.GetCurrentVersion(), 0, types.HardDriveType, 0))
for i := 1; i <= 3; i++ {
n := new(needle.Needle)
n.Id = types.Uint64ToNeedleId(uint64(i))
n.Cookie = 0x1234
n.Data = []byte(fmt.Sprintf("volume-%d-needle-%d", vid, i))
n.Checksum = needle.NewCRC(n.Data)
_, err := store.WriteVolumeNeedle(vid, n, true, false)
require.NoError(t, err)
}
}
vs := &VolumeServer{store: store}
if maxParallelCommits > 0 {
vs.vacuumCommitSlots = make(chan struct{}, maxParallelCommits)
}
return vs
}
func compactForTest(vs *VolumeServer, vid needle.VolumeId) error {
stream := &fakeVacuumCompactStream{ctx: context.Background()}
return vs.VacuumVolumeCompact(&volume_server_pb.VacuumVolumeCompactRequest{VolumeId: uint32(vid)}, stream)
}
func maxConcurrentHolders(vs *VolumeServer, workers int) int32 {
var current, peak int32
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
release, err := vs.acquireVacuumCommitSlot(context.Background())
if err != nil {
return
}
n := atomic.AddInt32(&current, 1)
for {
p := atomic.LoadInt32(&peak)
if n <= p || atomic.CompareAndSwapInt32(&peak, p, n) {
break
}
}
time.Sleep(5 * time.Millisecond)
atomic.AddInt32(&current, -1)
release()
}()
}
wg.Wait()
return atomic.LoadInt32(&peak)
}
func TestVacuumCommitSlotsBoundConcurrentCommits(t *testing.T) {
assert.Equal(t, int32(1), maxConcurrentHolders(&VolumeServer{vacuumCommitSlots: make(chan struct{}, 1)}, 8))
assert.LessOrEqual(t, maxConcurrentHolders(&VolumeServer{vacuumCommitSlots: make(chan struct{}, 3)}, 8), int32(3))
}
func TestVacuumCommitSlotsUnlimitedByDefault(t *testing.T) {
vs := &VolumeServer{}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
for i := 0; i < 8; i++ {
_, err := vs.acquireVacuumCommitSlot(ctx)
require.NoError(t, err, "acquisition %d must not wait when no limit is configured", i)
}
}
func TestVacuumCommitSlotNotGrantedToExpiredContext(t *testing.T) {
vs := &VolumeServer{vacuumCommitSlots: make(chan struct{}, 1)}
ctx, cancel := context.WithCancel(context.Background())
cancel()
for i := 0; i < 100; i++ {
release, err := vs.acquireVacuumCommitSlot(ctx)
require.ErrorIs(t, err, context.Canceled)
require.Nil(t, release)
require.Empty(t, vs.vacuumCommitSlots, "a refused acquisition must not keep the slot")
}
}
func TestVacuumCommitLimitLeavesCompactionAndOtherServersAlone(t *testing.T) {
vids := []needle.VolumeId{1, 2, 3, 4}
vs := newVacuumCommitTestServer(t, 1, vids...)
other := newVacuumCommitTestServer(t, 1, 9)
vs.vacuumCommitSlots <- struct{}{}
errs := make(chan error, len(vids))
for _, vid := range vids {
go func(vid needle.VolumeId) {
errs <- compactForTest(vs, vid)
}(vid)
}
for range vids {
require.NoError(t, <-errs)
}
require.NoError(t, compactForTest(other, 9))
_, err := other.VacuumVolumeCommit(context.Background(), &volume_server_pb.VacuumVolumeCommitRequest{VolumeId: 9})
require.NoError(t, err, "a busy commit slot on one server must not block another server")
}
func TestQueuedVacuumCommitHoldsNoVolumeLockAndHonoursDeadline(t *testing.T) {
vid := needle.VolumeId(1)
vs := newVacuumCommitTestServer(t, 1, vid)
require.NoError(t, compactForTest(vs, vid))
vs.vacuumCommitSlots <- struct{}{}
committed := make(chan error, 1)
go func() {
_, err := vs.VacuumVolumeCommit(context.Background(), &volume_server_pb.VacuumVolumeCommitRequest{VolumeId: uint32(vid)})
committed <- err
}()
require.Eventually(t, func() bool { return vs.vacuumCommitsWaiting.Load() == 1 }, 5*time.Second, time.Millisecond,
"the commit must pass the entry checks and wait for a slot")
readDone := make(chan error, 1)
go func() {
n := &needle.Needle{Id: types.Uint64ToNeedleId(1), Cookie: 0x1234}
_, err := vs.store.ReadVolumeNeedle(vid, n, nil, nil)
readDone <- err
}()
select {
case err := <-readDone:
require.NoError(t, err)
case <-time.After(5 * time.Second):
t.Fatal("read blocked behind a queued commit")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
_, err := vs.VacuumVolumeCommit(ctx, &volume_server_pb.VacuumVolumeCommitRequest{VolumeId: uint32(vid)})
assert.Equal(t, codes.DeadlineExceeded, status.Code(err))
select {
case err := <-committed:
t.Fatalf("commit ran while the only slot was taken: %v", err)
default:
}
<-vs.vacuumCommitSlots
select {
case err := <-committed:
require.NoError(t, err)
case <-time.After(5 * time.Second):
t.Fatal("queued commit did not run after the slot freed")
}
assert.Empty(t, vs.vacuumCommitSlots, "the commit must return its slot")
}
func TestVacuumCommitReleasesSlotOnError(t *testing.T) {
vs := newVacuumCommitTestServer(t, 1)
_, err := vs.VacuumVolumeCommit(context.Background(), &volume_server_pb.VacuumVolumeCommitRequest{VolumeId: 42})
require.Error(t, err)
assert.Empty(t, vs.vacuumCommitSlots, "a failed commit must return its slot")
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
release, err := vs.acquireVacuumCommitSlot(ctx)
require.NoError(t, err)
release()
}
func TestQueuedVacuumCommitDoesNotStartInMaintenanceMode(t *testing.T) {
vid := needle.VolumeId(1)
vs := newVacuumCommitTestServer(t, 1, vid)
require.NoError(t, compactForTest(vs, vid))
revisionBefore := vs.store.GetVolume(vid).SuperBlock.CompactionRevision
vs.vacuumCommitSlots <- struct{}{}
committed := make(chan error, 1)
go func() {
_, err := vs.VacuumVolumeCommit(context.Background(), &volume_server_pb.VacuumVolumeCommitRequest{VolumeId: uint32(vid)})
committed <- err
}()
require.Eventually(t, func() bool { return vs.vacuumCommitsWaiting.Load() == 1 }, 5*time.Second, time.Millisecond,
"the commit must pass the entry maintenance check and wait for a slot")
require.NoError(t, vs.store.State.Update(&volume_server_pb.VolumeServerState{Maintenance: true}))
<-vs.vacuumCommitSlots
select {
case err := <-committed:
require.Error(t, err)
assert.Contains(t, err.Error(), "maintenance mode")
case <-time.After(5 * time.Second):
t.Fatal("queued commit did not return after the slot freed")
}
assert.Equal(t, revisionBefore, vs.store.GetVolume(vid).SuperBlock.CompactionRevision, "the volume must not be committed")
assert.Empty(t, vs.vacuumCommitSlots, "the refused commit must return its slot")
}
-7
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb"
@@ -56,8 +55,6 @@ type VolumeServer struct {
AllowUntrustedRemoteEndpoints bool
compactionBytePerSecond int64
maintenanceBytePerSecond int64
vacuumCommitSlots chan struct{}
vacuumCommitsWaiting atomic.Int32
metricsAddress string
metricsIntervalSec int
fileSizeLimitBytes int64
@@ -82,7 +79,6 @@ func NewVolumeServer(adminMux, publicMux *http.ServeMux, ip string,
readMode string,
compactionMBPerSecond int,
maintenanceMBPerSecond int,
maxParallelVacuumCommits int,
fileSizeLimitMB int,
concurrentUploadLimit int64,
concurrentDownloadLimit int64,
@@ -130,9 +126,6 @@ func NewVolumeServer(adminMux, publicMux *http.ServeMux, ip string,
whiteList: whiteList,
AllowUntrustedRemoteEndpoints: allowUntrustedRemoteEndpoints,
}
if maxParallelVacuumCommits > 0 {
vs.vacuumCommitSlots = make(chan struct{}, maxParallelVacuumCommits)
}
whiteList = append(whiteList, util.StringSplit(v.GetString("guard.white_list"), ",")...)
// Copy the caller's slice so subsequent external mutation cannot desync
+2 -6
View File
@@ -35,7 +35,7 @@ func (c *commandCollectionDelete) HasTag(CommandTag) bool {
func (c *commandCollectionDelete) Do(args []string, commandEnv *CommandEnv, writer io.Writer) (err error) {
colDeleteCommand := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
collectionName := colDeleteCommand.String("collection", "", "collection to delete. Use '"+CollectionDefault+"' for the empty-named collection.")
collectionName := colDeleteCommand.String("collection", "", "collection to delete. Use '_default_' for the empty-named collection.")
applyBalancing := colDeleteCommand.Bool("apply", false, "apply the collection")
// TODO: remove this alias
applyBalancingAlias := colDeleteCommand.Bool("force", false, "apply the collection (alias for -apply)")
@@ -55,13 +55,9 @@ func (c *commandCollectionDelete) Do(args []string, commandEnv *CommandEnv, writ
return fmt.Errorf("empty collection name is not allowed")
}
// The legacy "_default_" spelling stays accepted for compatibility;
// CollectionDefault is the shared keyword every other -collection flag
// uses for the empty-named collection.
if *collectionName == CollectionDefault+"_" {
if *collectionName == "_default_" {
*collectionName = ""
}
*collectionName = resolveCollectionName(*collectionName)
if !*applyBalancing {
fmt.Fprintf(writer, "collection '%s' will be deleted. Use -apply to apply the change.\n", *collectionName)

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