mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-17 12:05:52 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14acb0ffb9 | ||
|
|
e778f13ff4 | ||
|
|
f5e9c9cf2c | ||
|
|
1eb0c57e61 | ||
|
|
87867b667d |
@@ -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
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)},
|
||||
})
|
||||
|
||||
@@ -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)},
|
||||
})
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}}},
|
||||
|
||||
@@ -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,
|
||||
}}},
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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:])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¤t, 1)
|
||||
for {
|
||||
p := atomic.LoadInt32(&peak)
|
||||
if n <= p || atomic.CompareAndSwapInt32(&peak, p, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
atomic.AddInt32(¤t, -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user