mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-21 22:12:36 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e222158de9 | ||
|
|
bdd9b1a8a9 | ||
|
|
8e92be099a | ||
|
|
d7c608ec51 | ||
|
|
c0034c9848 | ||
|
|
0a08101acb | ||
|
|
080e094b7e | ||
|
|
f71620925d | ||
|
|
95c31f3840 | ||
|
|
780760fccc | ||
|
|
a3e97ec709 | ||
|
|
23d8c62844 | ||
|
|
4bdbfbaaa8 | ||
|
|
e73c23a4ee | ||
|
|
8c3d8fcea0 | ||
|
|
bb1ba8b247 | ||
|
|
593cfee85b | ||
|
|
03065155db |
@@ -53,6 +53,12 @@ fn needle_not_found(needle_id: NeedleId) -> Status {
|
||||
Status::not_found(format!("needle not found {}", needle_id.0))
|
||||
}
|
||||
|
||||
/// Mirrors Go's `isNotLeaderErr`: the master returns raft's NotLeaderError,
|
||||
/// which arrives as an Unknown-status message.
|
||||
fn is_not_leader_status(e: &Status) -> bool {
|
||||
e.message().contains("Not current leader")
|
||||
}
|
||||
|
||||
/// Deadline from the caller's `grpc-timeout` header (TimeoutValue +
|
||||
/// H|M|S|m|u|n unit, per the gRPC-over-HTTP2 spec). Tonic's GrpcTimeout layer
|
||||
/// drops an over-deadline handler future anyway; parsing it here lets a queued
|
||||
@@ -344,22 +350,46 @@ impl VolumeGrpcService {
|
||||
is_readonly: bool,
|
||||
) -> Result<(), Status> {
|
||||
// VolumeMarkReadonly mutates raft-replicated master topology, so it must
|
||||
// reach the leader. Prefer the live leader the heartbeat is talking to
|
||||
// (current_master_url), falling back to the static seed before the first
|
||||
// heartbeat — mirrors store_ec.rs and Go's vs.GetMaster(). Sending it to
|
||||
// the static seed fails "not current leader" after any master failover.
|
||||
let master_url = {
|
||||
let live = self.state.current_master_url.read().await.clone();
|
||||
if !live.is_empty() {
|
||||
live
|
||||
} else {
|
||||
self.state.master_url.clone()
|
||||
}
|
||||
};
|
||||
// reach the leader the heartbeat confirmed. On "Not current leader",
|
||||
// ask the seed masters who leads and retry there — mirrors Go's
|
||||
// notifyMasterVolumeReadonly.
|
||||
let master_url = self.state.current_master().await;
|
||||
if master_url.is_empty() {
|
||||
return Ok(());
|
||||
if !self.state.has_master {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(Status::unavailable("no master leader known"));
|
||||
}
|
||||
let grpc_addr = parse_grpc_address(&master_url).map_err(|e| {
|
||||
match self
|
||||
.volume_mark_readonly_on_master(&master_url, info, is_readonly)
|
||||
.await
|
||||
{
|
||||
Err(e) if is_not_leader_status(&e) => {
|
||||
let leader = super::heartbeat::lookup_leader_from_peers(
|
||||
&self.state.master_urls,
|
||||
self.state.outgoing_grpc_tls.as_ref(),
|
||||
)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
Status::unavailable(format!(
|
||||
"master {} rejected mark-readonly and no peer named a leader",
|
||||
master_url
|
||||
))
|
||||
})?;
|
||||
self.volume_mark_readonly_on_master(&leader, info, is_readonly)
|
||||
.await
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
async fn volume_mark_readonly_on_master(
|
||||
&self,
|
||||
master_url: &str,
|
||||
info: &MasterVolumeInfo,
|
||||
is_readonly: bool,
|
||||
) -> Result<(), Status> {
|
||||
let grpc_addr = parse_grpc_address(master_url).map_err(|e| {
|
||||
Status::internal(format!("invalid master address {}: {}", master_url, e))
|
||||
})?;
|
||||
let channel = connect_channel(
|
||||
@@ -5776,7 +5806,8 @@ impl VolumeCopyJob {
|
||||
(&self.dest.data_base_name, self.dest.has_remote_dat);
|
||||
let mut preallocate_size: i64 = 0;
|
||||
if !has_remote_dat {
|
||||
let grpc_addr = super::heartbeat::to_grpc_address(&state.master_url);
|
||||
let master = state.current_master().await;
|
||||
let grpc_addr = super::heartbeat::to_grpc_address(&master);
|
||||
// Race the master-configuration RPC against the caller's
|
||||
// response channel: a stalled master (or a slow leader
|
||||
// election) would otherwise hold the task and its .note
|
||||
@@ -5801,7 +5832,7 @@ impl VolumeCopyJob {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("get master {} configuration: {}", state.master_url, e);
|
||||
tracing::warn!("get master {} configuration: {}", master, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -603,7 +603,7 @@ async fn batch_delete_file_ids(
|
||||
let locations = lookup_volume(
|
||||
&state.http_client,
|
||||
&state.outgoing_http_scheme,
|
||||
&state.master_url,
|
||||
&state.current_master().await,
|
||||
volume_id,
|
||||
)
|
||||
.await
|
||||
@@ -675,7 +675,7 @@ async fn do_replicated_request(
|
||||
let locations = lookup_volume(
|
||||
&state.http_client,
|
||||
&state.outgoing_http_scheme,
|
||||
&state.master_url,
|
||||
&state.current_master().await,
|
||||
vid,
|
||||
)
|
||||
.await
|
||||
@@ -832,7 +832,7 @@ async fn proxy_or_redirect_to_target(
|
||||
let locations = match lookup_volume(
|
||||
&state.http_client,
|
||||
&state.outgoing_http_scheme,
|
||||
&state.master_url,
|
||||
&state.current_master().await,
|
||||
vid.0,
|
||||
)
|
||||
.await
|
||||
@@ -1238,7 +1238,7 @@ async fn proxy_missing_volume(
|
||||
let query_string = uri.query().unwrap_or("").to_string();
|
||||
if is_proxied_query(&query_string)
|
||||
|| state.read_mode == ReadMode::Local
|
||||
|| state.master_url.is_empty()
|
||||
|| state.current_master().await.is_empty()
|
||||
{
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
@@ -1282,7 +1282,7 @@ async fn check_download_limit(
|
||||
// Go tries proxy to replica once before the wait loop
|
||||
// (checkDownloadLimit); it does not retry on each wakeup.
|
||||
let should_try_replica =
|
||||
!is_proxied_query(&query_string) && !state.master_url.is_empty() && {
|
||||
!is_proxied_query(&query_string) && !state.current_master().await.is_empty() && {
|
||||
let store = state.store.read().unwrap();
|
||||
store
|
||||
.find_volume(vid)
|
||||
@@ -2960,7 +2960,9 @@ pub async fn post_handler(
|
||||
|
||||
// Replicate to remote volume servers if this volume has replicas.
|
||||
// Matches Go's GetWritableRemoteReplications: skip if copy_count == 1.
|
||||
if !is_replicate && write_result.is_ok() && !state.master_url.is_empty() {
|
||||
// No leader-known gate: when the lookup cannot run, the write must fail
|
||||
// rather than be acknowledged on fewer copies than required.
|
||||
if !is_replicate && write_result.is_ok() {
|
||||
let needs_replication = {
|
||||
let store = state.store.read().unwrap();
|
||||
store
|
||||
@@ -3358,7 +3360,7 @@ pub async fn delete_handler(
|
||||
};
|
||||
|
||||
let is_replicate = del_query.split('&').any(|p| p == "type=replicate");
|
||||
if !is_replicate && delete_result.is_ok() && !state.master_url.is_empty() {
|
||||
if !is_replicate && delete_result.is_ok() {
|
||||
let needs_replication = {
|
||||
let store = state.store.read().unwrap();
|
||||
store
|
||||
@@ -4232,7 +4234,7 @@ async fn read_remote_chunk_needle(
|
||||
let locations = lookup_volume(
|
||||
&state.http_client,
|
||||
&state.outgoing_http_scheme,
|
||||
&state.master_url,
|
||||
&state.current_master().await,
|
||||
vid.0,
|
||||
)
|
||||
.await?;
|
||||
@@ -6671,7 +6673,8 @@ mod tests {
|
||||
let s = Arc::get_mut(&mut state).unwrap();
|
||||
// Empty self_url makes `url.contains(self_url)` true for every peer.
|
||||
s.self_url = "198.51.100.8:8080".to_string();
|
||||
s.master_url = master_url;
|
||||
s.master_url = master_url.clone();
|
||||
s.current_master_url = tokio::sync::RwLock::new(master_url);
|
||||
state
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +155,11 @@ pub async fn run_heartbeat_with_state(
|
||||
}
|
||||
PostAction::SleepPulse => {
|
||||
new_leader = None;
|
||||
tokio::time::sleep(pulse).await;
|
||||
// The lookup spends part of the retry delay rather than
|
||||
// adding to it.
|
||||
let retry_at = tokio::time::Instant::now() + pulse;
|
||||
refresh_leader_from_peers(&config, &state, pulse).await;
|
||||
tokio::time::sleep_until(retry_at).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,10 +258,56 @@ pub async fn try_get_master_configuration(
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
/// Publish the leader the configured masters report, so lookups stop going
|
||||
/// to the master the heartbeat just lost while the loop retries. Without an
|
||||
/// answer inside `timeout` the published master is cleared: the lost master
|
||||
/// is dead or no longer leader, and a follower answers lookups from its own
|
||||
/// cache rather than refusing them. Mirrors Go's refreshLeaderFromPeers.
|
||||
async fn refresh_leader_from_peers(
|
||||
config: &HeartbeatConfig,
|
||||
state: &Arc<VolumeServerState>,
|
||||
timeout: Duration,
|
||||
) {
|
||||
let lookup = lookup_leader_from_peers(
|
||||
&config.master_addresses,
|
||||
state.outgoing_grpc_tls.as_ref(),
|
||||
);
|
||||
match tokio::time::timeout(timeout, lookup).await {
|
||||
Ok(Some(leader)) => publish_current_master(state, &leader).await,
|
||||
_ => state.current_master_url.write().await.clear(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask each seed master for the raft leader and return the first answer, or
|
||||
/// None when none replies with one. Mirrors Go's
|
||||
/// `operation.LookupRaftLeaderMaster`.
|
||||
pub async fn lookup_leader_from_peers(
|
||||
master_addrs: &[String],
|
||||
tls: Option<&super::grpc_client::OutgoingGrpcTlsConfig>,
|
||||
) -> Option<String> {
|
||||
for master_addr in master_addrs {
|
||||
let grpc_addr = to_grpc_address(master_addr);
|
||||
if let Ok(resp) = try_get_master_configuration(&grpc_addr, tls).await
|
||||
&& !resp.leader.is_empty()
|
||||
{
|
||||
return Some(resp.leader);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_stopping(state: &VolumeServerState) -> bool {
|
||||
*state.is_stopping.read().unwrap()
|
||||
}
|
||||
|
||||
/// Store the master lookups should go to, in the address form it was
|
||||
/// announced in — `host:port` or `host:port.grpcPort` — so an explicit gRPC
|
||||
/// port survives to the gRPC callers, mirroring Go's setCurrentMaster.
|
||||
/// The heartbeat calls this only for a master known to lead.
|
||||
async fn publish_current_master(state: &Arc<VolumeServerState>, master: &str) {
|
||||
*state.current_master_url.write().await = master.to_string();
|
||||
}
|
||||
|
||||
fn duplicate_uuid_retry_delay(retry_count: u32) -> Duration {
|
||||
Duration::from_secs((1u64 << retry_count) * 2)
|
||||
}
|
||||
@@ -430,14 +480,6 @@ async fn do_heartbeat(
|
||||
let mut response_stream = client.send_heartbeat(stream).await?.into_inner();
|
||||
|
||||
info!("Heartbeat stream established with {}", grpc_addr);
|
||||
// Publish the master we're now talking to in canonical http host:port
|
||||
// form so Ping admission can recognise it once a leader change moves us
|
||||
// off the seed list. Mirrors Go's vs.setCurrentMaster(masterAddress).
|
||||
{
|
||||
let normalised = super::volume_server::to_http_address(current_master).into_owned();
|
||||
let mut guard = state.current_master_url.write().await;
|
||||
*guard = normalised;
|
||||
}
|
||||
if is_stopping(state) {
|
||||
state.is_heartbeating.store(false, Ordering::Relaxed);
|
||||
send_deregister_heartbeat(config, state, &tx).await;
|
||||
@@ -451,13 +493,31 @@ async fn do_heartbeat(
|
||||
volume_tick.tick().await;
|
||||
ec_tick.tick().await;
|
||||
|
||||
let mut confirmed_leader = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
resp = response_stream.message() => {
|
||||
match resp {
|
||||
Ok(Some(hb_resp)) => {
|
||||
// Match Go ordering: DuplicatedUuids first, then volume
|
||||
// options, then leader redirect.
|
||||
// A reply names the leader or comes from it, so it
|
||||
// settles where lookups go before anything else on the
|
||||
// reply is handled: a follower reply carries no other
|
||||
// fields, and a leader stays the lookup target even
|
||||
// when it rejects this registration. Mirrors Go.
|
||||
if !hb_resp.leader.is_empty() {
|
||||
if super::volume_server::to_http_address(current_master)
|
||||
!= super::volume_server::to_http_address(&hb_resp.leader)
|
||||
{
|
||||
publish_current_master(state, &hb_resp.leader).await;
|
||||
return Ok(Some(hb_resp.leader));
|
||||
}
|
||||
} else if !confirmed_leader {
|
||||
// The leader answers every new stream without
|
||||
// naming a leader, so this peer is the one lookups
|
||||
// go to.
|
||||
confirmed_leader = true;
|
||||
publish_current_master(state, current_master).await;
|
||||
}
|
||||
if !hb_resp.duplicated_uuids.is_empty() {
|
||||
let uuids = hb_resp.duplicated_uuids.clone();
|
||||
let duplicate_dirs = off_runtime(config, state, move |_, state| {
|
||||
@@ -502,11 +562,6 @@ async fn do_heartbeat(
|
||||
if metrics_changed {
|
||||
state.metrics_notify.notify_waiters();
|
||||
}
|
||||
// Match Go: only redirect if leader is non-empty AND
|
||||
// different from the current master we're connected to.
|
||||
if !hb_resp.leader.is_empty() && current_master != hb_resp.leader {
|
||||
return Ok(Some(hb_resp.leader));
|
||||
}
|
||||
}
|
||||
Ok(None) => return Ok(None),
|
||||
Err(e) => return Err(Box::new(e)),
|
||||
|
||||
@@ -1139,16 +1139,9 @@ async fn cached_lookup_ec_shard_locations(
|
||||
state: &Arc<VolumeServerState>,
|
||||
vid: VolumeId,
|
||||
) -> io::Result<HashMap<ShardId, Vec<String>>> {
|
||||
let master = {
|
||||
let live = state.current_master_url.read().await.clone();
|
||||
if !live.is_empty() {
|
||||
live
|
||||
} else {
|
||||
state.master_url.clone()
|
||||
}
|
||||
};
|
||||
let master = state.current_master().await;
|
||||
if master.is_empty() {
|
||||
return Err(io::Error::other("no master configured for ec shard lookup"));
|
||||
return Err(io::Error::other("no master leader known for ec shard lookup"));
|
||||
}
|
||||
|
||||
let grpc_addr =
|
||||
|
||||
@@ -86,7 +86,8 @@ pub struct VolumeServerState {
|
||||
/// If true, FetchAndWriteNeedle skips remote S3 endpoint validation,
|
||||
/// allowing arbitrary (incl. loopback / link-local / metadata) hosts.
|
||||
pub allow_untrusted_remote_endpoints: bool,
|
||||
/// First master address for volume lookups (e.g., "localhost:9333").
|
||||
/// First configured master address (e.g., "localhost:9333"). A seed, not a
|
||||
/// lookup target: lookups go to `current_master_url`.
|
||||
pub master_url: String,
|
||||
/// Seed master addresses for UI rendering.
|
||||
pub master_urls: Vec<String>,
|
||||
@@ -94,10 +95,12 @@ pub struct VolumeServerState {
|
||||
/// Built once at construction so Ping admission stays O(1). Mirrors
|
||||
/// Go's `seedMasterSet` on `VolumeServer`.
|
||||
pub seed_master_set: std::collections::HashSet<String>,
|
||||
/// Current master this server is heartbeating with, in canonical http
|
||||
/// `host:port` form. Empty when no heartbeat connection is active. The
|
||||
/// heartbeat goroutine writes; admission reads — the lock keeps them
|
||||
/// from racing on a leader change. Mirrors Go's `currentMaster` plus
|
||||
/// Master this server takes as leader, in its announced
|
||||
/// `host:port[.grpcPort]` form — callers pick http or gRPC form per use.
|
||||
/// Empty until the heartbeat confirms one — a peer that answered a
|
||||
/// stream without naming a leader, or the leader a follower named — and
|
||||
/// cleared again when no master reports one. The heartbeat writes;
|
||||
/// admission and lookups read. Mirrors Go's `currentMaster` plus
|
||||
/// `currentMasterLock`.
|
||||
pub current_master_url: tokio::sync::RwLock<String>,
|
||||
/// This server's own address (ip:port) for filtering self from lookup results.
|
||||
@@ -140,6 +143,13 @@ pub struct VolumeServerState {
|
||||
}
|
||||
|
||||
impl VolumeServerState {
|
||||
/// The master lookups should go to: the leader the heartbeat confirmed,
|
||||
/// or empty while none is known, in its announced `host:port[.grpcPort]`
|
||||
/// form. Mirrors Go's `vs.getCurrentMaster()`.
|
||||
pub async fn current_master(&self) -> String {
|
||||
self.current_master_url.read().await.clone()
|
||||
}
|
||||
|
||||
/// Check if the server is in maintenance mode; return gRPC error if so.
|
||||
pub fn check_maintenance(&self) -> Result<(), tonic::Status> {
|
||||
if self.maintenance.load(Ordering::Relaxed) {
|
||||
@@ -209,7 +219,7 @@ impl VolumeServerState {
|
||||
return false;
|
||||
}
|
||||
let current = self.current_master_url.read().await.clone();
|
||||
if !current.is_empty() && current == key {
|
||||
if !current.is_empty() && to_http_address(¤t) == key {
|
||||
return true;
|
||||
}
|
||||
self.seed_master_set.contains(&key)
|
||||
|
||||
@@ -384,6 +384,16 @@ func processUpdateEvent(
|
||||
}
|
||||
glog.V(0).Infof("never replicated, uploading %s", remote_storage.FormatLocation(dest))
|
||||
}
|
||||
if proto.Equal(oldDest, dest) && isDroppedLocalCopy(message.NewEntry) {
|
||||
// The update dropped the local copy: remote.uncache, eviction under
|
||||
// storage pressure, or remote.meta.sync pulling a newer remote
|
||||
// version. Each clears the sync stamp, so shouldSendToRemote reads
|
||||
// the entry as dirty when the remote mtime shares its second. The
|
||||
// content is the object at this very key, and uploading the chunkless
|
||||
// entry would replace it with zero bytes.
|
||||
glog.V(2).Infof("skipping update of remote-only entry: %+v", resp)
|
||||
return nil
|
||||
}
|
||||
glog.V(2).Infof("update: %+v", resp)
|
||||
if !proto.Equal(oldDest, dest) {
|
||||
// A renamed entry that holds no local data now (the snapshot was
|
||||
@@ -488,6 +498,15 @@ func isRemoteOnly(entry *filer_pb.Entry) bool {
|
||||
return entry != nil && !filer.HasData(entry) && entry.IsInRemoteOnly()
|
||||
}
|
||||
|
||||
// isDroppedLocalCopy reports a remote-only entry whose size is still the
|
||||
// remote object's: its local copy was dropped and the object describes it.
|
||||
// Uncache and eviction keep the size, and remote.meta.sync sets it from the
|
||||
// object. A truncate drops the chunks too but changes the size, and the
|
||||
// truncated content has to reach the remote.
|
||||
func isDroppedLocalCopy(entry *filer_pb.Entry) bool {
|
||||
return isRemoteOnly(entry) && entry.GetAttributes().GetFileSize() == uint64(entry.RemoteEntry.RemoteSize)
|
||||
}
|
||||
|
||||
// completeRemoteOnlyRename finishes a rename whose content exists only on the
|
||||
// remote. When the destination object already holds what the entry's stamp
|
||||
// describes (the rename ran before, its offset was not persisted, and
|
||||
|
||||
@@ -1722,6 +1722,206 @@ func TestMetadataOnlyUpdateRestampsSyncTs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The droppedCopy fixtures are a small synced file under a mount: a Delta log
|
||||
// entry whose remote object was written in the second of its mtime, as a quick
|
||||
// sync of a small file is.
|
||||
const droppedCopyMtime = 1786096669
|
||||
|
||||
var (
|
||||
droppedCopyDir = "/buckets/gold/sales/_delta_log"
|
||||
droppedCopyName = "00000000000000000007.json"
|
||||
droppedCopyMount = &remote_pb.RemoteStorageLocation{Name: "s3", Bucket: "gold", Path: "/"}
|
||||
droppedCopyDest = &remote_pb.RemoteStorageLocation{Name: "s3", Bucket: "gold", Path: "/sales/_delta_log/" + droppedCopyName}
|
||||
droppedCopySynced = &filer_pb.RemoteEntry{StorageName: "s3", RemoteETag: `"abc"`, RemoteSize: 1024, RemoteMtime: droppedCopyMtime, LastLocalSyncTsNs: droppedCopyMtime*1e9 + 500}
|
||||
)
|
||||
|
||||
// droppedCopyStamp is the synced stamp with LastLocalSyncTsNs cleared, as
|
||||
// remote.uncache and the eviction leave it.
|
||||
func droppedCopyStamp() *filer_pb.RemoteEntry {
|
||||
stamp := proto.Clone(droppedCopySynced).(*filer_pb.RemoteEntry)
|
||||
stamp.LastLocalSyncTsNs = 0
|
||||
return stamp
|
||||
}
|
||||
|
||||
// droppedCopyEntry is the fixture file with the given stamp and chunks, sized
|
||||
// as its remote object.
|
||||
func droppedCopyEntry(stamp *filer_pb.RemoteEntry, chunks ...*filer_pb.FileChunk) *filer_pb.Entry {
|
||||
entry := entryWith(droppedCopyName, stamp, chunks...)
|
||||
entry.Attributes.FileSize = uint64(droppedCopySynced.RemoteSize)
|
||||
return entry
|
||||
}
|
||||
|
||||
func droppedCopyUpdate(oldEntry, newEntry *filer_pb.Entry) *filer_pb.SubscribeMetadataResponse {
|
||||
return &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: droppedCopyDir,
|
||||
EventNotification: &filer_pb.EventNotification{OldEntry: oldEntry, NewParentPath: droppedCopyDir, NewEntry: newEntry},
|
||||
}
|
||||
}
|
||||
|
||||
// TestDroppedLocalCopyIsNotUploaded: remote.uncache and the filer's eviction
|
||||
// under storage pressure drop a synced entry's chunks and clear its sync
|
||||
// stamp; remote.meta.sync does the same when it pulls a newer remote version.
|
||||
// With the remote mtime in the entry's second, shouldSendToRemote falls back
|
||||
// to the cleared stamp and reads the entry as dirty. The object at the same
|
||||
// key is the entry's only content, so uploading the chunkless entry would
|
||||
// replace it with a zero-byte object. The update is skipped, and the stamp
|
||||
// stays cleared so remote.cache and eviction still see the entry as uncached.
|
||||
func TestDroppedLocalCopyIsNotUploaded(t *testing.T) {
|
||||
// the eviction's entry, built the way uncacheRemoteEntry builds it, and
|
||||
// the event the way the filer's CreateEntry emits it
|
||||
cached := &filer.Entry{
|
||||
FullPath: util.NewFullPath(droppedCopyDir, droppedCopyName),
|
||||
Attr: filer.Attr{Mtime: time.Unix(droppedCopyMtime, 0), Mode: 0660, FileSize: 1024},
|
||||
Chunks: []*filer_pb.FileChunk{chunk("3,01", "e1")},
|
||||
Remote: droppedCopySynced,
|
||||
}
|
||||
evicted := cached.ShallowClone()
|
||||
evicted.Chunks = nil
|
||||
evicted.Remote = proto.Clone(cached.Remote).(*filer_pb.RemoteEntry)
|
||||
evicted.Remote.LastLocalSyncTsNs = 0
|
||||
|
||||
// remote.meta.sync found a newer object: its mtime becomes the entry's
|
||||
newer := &filer_pb.RemoteEntry{StorageName: "s3", RemoteETag: `"def"`, RemoteSize: 4096, RemoteMtime: droppedCopyMtime + 60}
|
||||
pulled := &filer_pb.Entry{Name: droppedCopyName, Attributes: &filer_pb.FuseAttributes{Mtime: droppedCopyMtime + 60, FileSize: 4096}, RemoteEntry: newer}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
oldEntry *filer_pb.Entry
|
||||
newEntry *filer_pb.Entry
|
||||
}{
|
||||
{name: "remote.uncache", oldEntry: droppedCopyEntry(droppedCopySynced, chunk("3,01", "e1")), newEntry: droppedCopyEntry(droppedCopyStamp())},
|
||||
{name: "eviction under storage pressure", oldEntry: cached.ToProtoEntry(), newEntry: evicted.ToProtoEntry()},
|
||||
{name: "remote.meta.sync pulls a newer version", oldEntry: entryWith(droppedCopyName, droppedCopySynced, chunk("3,01", "e1")), newEntry: pulled},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resp := droppedCopyUpdate(tt.oldEntry, tt.newEntry)
|
||||
if !shouldSendToRemote(tt.newEntry) {
|
||||
t.Fatal("precondition: the cleared stamp in the remote mtime's second reads as dirty, the bug's trigger")
|
||||
}
|
||||
if isMetadataOnlyUpdate(resp.Directory, resp.EventNotification) {
|
||||
t.Fatal("precondition: the dropped chunks make the update a content change")
|
||||
}
|
||||
|
||||
remote := &recordingRemote{}
|
||||
filerClient := &stubFilerClient{entry: tt.newEntry}
|
||||
if err := processUpdateEvent(filerClient, filerClient, "", remote, "/buckets/gold", droppedCopyMount, resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remote.writes) != 0 {
|
||||
t.Errorf("writes = %+v, want none: the remote object is the entry's content and an upload would zero it", remote.writes)
|
||||
}
|
||||
if len(remote.deletes) != 0 || len(remote.metadataUpdates) != 0 {
|
||||
t.Errorf("deletes = %+v, metadata updates = %+v, want the remote object left alone", remote.deletes, remote.metadataUpdates)
|
||||
}
|
||||
if filerClient.updates != 0 {
|
||||
t.Errorf("stamped the entry %d times, want 0: a stamp would mark the uncached entry as cached", filerClient.updates)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a metadata edit of a remote-only entry still reaches the remote", func(t *testing.T) {
|
||||
tagged := droppedCopyEntry(droppedCopyStamp())
|
||||
tagged.Extended = map[string][]byte{"X-Amz-Tagging-tier": []byte("gold")}
|
||||
resp := droppedCopyUpdate(droppedCopyEntry(droppedCopyStamp()), tagged)
|
||||
remote := &recordingRemote{}
|
||||
filerClient := &stubFilerClient{entry: tagged}
|
||||
if err := processUpdateEvent(filerClient, filerClient, "", remote, "/buckets/gold", droppedCopyMount, resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remote.metadataUpdates) != 1 || len(remote.writes) != 0 {
|
||||
t.Errorf("metadata updates = %+v, writes = %+v, want one metadata update and no upload", remote.metadataUpdates, remote.writes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSameSecondRewriteIsStillUploaded: a rewrite that lands in the remote
|
||||
// mtime's second holds local data, so the skip for dropped local copies does
|
||||
// not apply and it is uploaded and restamped (#11715).
|
||||
func TestSameSecondRewriteIsStillUploaded(t *testing.T) {
|
||||
stale := proto.Clone(droppedCopySynced).(*filer_pb.RemoteEntry)
|
||||
stale.LastLocalSyncTsNs = droppedCopyMtime*1e9 + 100
|
||||
rewrite := func(stamp *filer_pb.RemoteEntry) *filer_pb.Entry {
|
||||
entry := entryWith(droppedCopyName, stamp, chunk("4,02", "e2"))
|
||||
entry.Attributes.MtimeNs = 337
|
||||
return entry
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
oldEntry *filer_pb.Entry
|
||||
newEntry *filer_pb.Entry
|
||||
}{
|
||||
// Filer.UpdateEntry clears the stamp of a content change
|
||||
{name: "synced entry, stamp cleared by the filer", oldEntry: entryWith(droppedCopyName, droppedCopySynced, chunk("3,01", "e1")), newEntry: rewrite(droppedCopyStamp())},
|
||||
{name: "synced entry, stamp older than the rewrite", oldEntry: entryWith(droppedCopyName, stale, chunk("3,01", "e1")), newEntry: rewrite(stale)},
|
||||
{name: "uncached entry", oldEntry: entryWith(droppedCopyName, droppedCopyStamp()), newEntry: rewrite(droppedCopyStamp())},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.newEntry.Attributes.Mtime != tt.newEntry.RemoteEntry.RemoteMtime {
|
||||
t.Fatal("precondition: the rewrite lands in the remote mtime's second")
|
||||
}
|
||||
remote := &recordingRemote{}
|
||||
filerClient := &stubFilerClient{entry: tt.newEntry}
|
||||
if err := processUpdateEvent(filerClient, filerClient, "", remote, "/buckets/gold", droppedCopyMount, droppedCopyUpdate(tt.oldEntry, tt.newEntry)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remote.writes) != 1 || !proto.Equal(remote.writes[0], droppedCopyDest) {
|
||||
t.Fatalf("writes = %+v, want the rewrite uploaded to %s", remote.writes, remote_storage.FormatLocation(droppedCopyDest))
|
||||
}
|
||||
if !slices.Equal(remote.writtenChunks[0], []string{"4,02"}) {
|
||||
t.Errorf("uploaded chunks %v, want the rewrite's [4,02]", remote.writtenChunks[0])
|
||||
}
|
||||
if filerClient.updates != 1 {
|
||||
t.Errorf("stamped the entry %d times, want 1 after the upload", filerClient.updates)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTruncateToZeroIsUploaded: a FUSE truncate of a cached file to zero
|
||||
// (truncateEntry, or SetAttr with size 0) drops the chunks and keeps the
|
||||
// sized RemoteEntry, so the entry looks remote-only. Its size is no longer
|
||||
// the remote object's, and the empty file has to replace the object.
|
||||
func TestTruncateToZeroIsUploaded(t *testing.T) {
|
||||
truncated := func(mtime int64, mtimeNs int32) *filer_pb.Entry {
|
||||
// the filer clears the stamp of a content change
|
||||
entry := entryWith(droppedCopyName, droppedCopyStamp())
|
||||
entry.Attributes.Mtime, entry.Attributes.MtimeNs = mtime, mtimeNs
|
||||
return entry
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
newEntry *filer_pb.Entry
|
||||
}{
|
||||
{name: "in a later second", newEntry: truncated(droppedCopyMtime+5, 0)},
|
||||
{name: "in the remote mtime's second", newEntry: truncated(droppedCopyMtime, 337)},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if !isRemoteOnly(tt.newEntry) {
|
||||
t.Fatal("precondition: the truncated entry looks remote-only")
|
||||
}
|
||||
remote := &recordingRemote{}
|
||||
filerClient := &stubFilerClient{entry: tt.newEntry}
|
||||
resp := droppedCopyUpdate(droppedCopyEntry(droppedCopySynced, chunk("3,01", "e1")), tt.newEntry)
|
||||
if err := processUpdateEvent(filerClient, filerClient, "", remote, "/buckets/gold", droppedCopyMount, resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remote.writes) != 1 || !proto.Equal(remote.writes[0], droppedCopyDest) {
|
||||
t.Fatalf("writes = %+v, want the empty file uploaded to %s", remote.writes, remote_storage.FormatLocation(droppedCopyDest))
|
||||
}
|
||||
if len(remote.writtenChunks[0]) != 0 {
|
||||
t.Errorf("uploaded chunks %v, want none: a zero-byte object", remote.writtenChunks[0])
|
||||
}
|
||||
if filerClient.updates != 1 {
|
||||
t.Errorf("stamped the entry %d times, want 1 after the upload", filerClient.updates)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRemoteOnlyRenameKeepsAReusedOldKey(t *testing.T) {
|
||||
// A was renamed to B, then D to A before the sync got to the first
|
||||
// rename: the object at A is now D's, and the only copy of it
|
||||
|
||||
+8
-5
@@ -98,11 +98,13 @@ var fineGrainedActionMap = map[string]string{
|
||||
// prefixed (s3:Action) and unprefixed (Action) variants in init().
|
||||
var baseS3ActionMap = map[string]string{
|
||||
// Object operations
|
||||
"DeleteObject": s3_constants.ACTION_WRITE,
|
||||
"PutObject": s3_constants.ACTION_WRITE,
|
||||
"GetObject": s3_constants.ACTION_READ,
|
||||
"DeleteObjectVersion": s3_constants.ACTION_WRITE,
|
||||
"GetObjectVersion": s3_constants.ACTION_READ,
|
||||
"DeleteObject": s3_constants.ACTION_WRITE,
|
||||
"PutObject": s3_constants.ACTION_WRITE,
|
||||
"GetObject": s3_constants.ACTION_READ,
|
||||
"DeleteObjectVersion": s3_constants.ACTION_WRITE,
|
||||
"GetObjectVersion": s3_constants.ACTION_READ,
|
||||
"GetObjectAttributes": s3_constants.ACTION_READ,
|
||||
"GetObjectVersionAttributes": s3_constants.ACTION_READ,
|
||||
// Tagging operations
|
||||
"GetObjectTagging": s3_constants.ACTION_TAGGING,
|
||||
"GetObjectVersionTagging": s3_constants.ACTION_TAGGING,
|
||||
@@ -115,6 +117,7 @@ var baseS3ActionMap = map[string]string{
|
||||
"DeleteBucketTagging": s3_constants.ACTION_TAGGING,
|
||||
// ACL operations
|
||||
"PutObjectAcl": s3_constants.ACTION_WRITE_ACP,
|
||||
"PutObjectVersionAcl": s3_constants.ACTION_WRITE_ACP,
|
||||
"GetObjectAcl": s3_constants.ACTION_READ_ACP,
|
||||
"GetObjectVersionAcl": s3_constants.ACTION_READ_ACP,
|
||||
"PutBucketAcl": s3_constants.ACTION_WRITE_ACP,
|
||||
|
||||
@@ -2,10 +2,16 @@ package iamapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -141,3 +147,69 @@ func TestIamManagementAllowedForAdmin(t *testing.T) {
|
||||
"", "Action=CreateUser&UserName=new-user&Version=2010-05-08")
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
// The implicit UserName must come from the identity authentication verified,
|
||||
// never from re-parsing the Authorization header: a request can carry presigned
|
||||
// credentials for one user alongside a junk SigV4 header naming another.
|
||||
func TestImplicitUserNameUsesVerifiedIdentity(t *testing.T) {
|
||||
server := newAuthzTestServer(t)
|
||||
expires := strconv.FormatInt(time.Now().Add(10*time.Minute).Unix(), 10)
|
||||
sts := "POST\n\napplication/x-www-form-urlencoded\n" + expires + "\n/"
|
||||
mac := hmac.New(sha1.New, []byte("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"))
|
||||
mac.Write([]byte(sts))
|
||||
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
q := url.Values{"AWSAccessKeyId": {"AKIAIOSFODNN7EXAMPLE"}, "Expires": {expires}, "Signature": {sig}}
|
||||
|
||||
post := func(t *testing.T, body string) (int, string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPost, server.URL+"/?"+q.Encode(), strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIATESTFAKEADMIN0001/20261021/us-east-1/iam/aws4_request, SignedHeaders=host, Signature=00")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("%d %s", resp.StatusCode, b)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
for _, action := range []string{"ListAccessKeys", "CreateAccessKey", "GetUser"} {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
code, body := post(t, "Action="+action+"&Version=2010-05-08")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.NotContains(t, body, "iam_admin")
|
||||
assert.Contains(t, body, "power_user")
|
||||
})
|
||||
}
|
||||
|
||||
// ListUserPolicies is not self-service: a caller without IAM rights is
|
||||
// denied whichever name the implicit resolution lands on.
|
||||
t.Run("ListUserPolicies", func(t *testing.T) {
|
||||
code, body := post(t, "Action=ListUserPolicies&Version=2010-05-08")
|
||||
assert.Equal(t, http.StatusForbidden, code)
|
||||
assert.NotContains(t, body, "iam_admin")
|
||||
})
|
||||
|
||||
// A write aimed at the header-named admin's key resolves to the verified
|
||||
// caller instead: the key is not power_user's, so it is not found.
|
||||
t.Run("UpdateAccessKey", func(t *testing.T) {
|
||||
code, body := post(t, "Action=UpdateAccessKey&AccessKeyId=AKIATESTFAKEADMIN0001&Status=Inactive&Version=2010-05-08")
|
||||
assert.Equal(t, http.StatusNotFound, code)
|
||||
assert.Contains(t, body, "NoSuchEntity")
|
||||
})
|
||||
|
||||
t.Run("DeleteAccessKey", func(t *testing.T) {
|
||||
code, _ := post(t, "Action=DeleteAccessKey&AccessKeyId=AKIATESTFAKEADMIN0001&Version=2010-05-08")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
})
|
||||
|
||||
// The admin key the junk header named is untouched: still present and active.
|
||||
resp := postSignedIamAction(t, server.URL, "AKIATESTFAKEADMIN0001", "testAdminSecretFake0000000000000000000000",
|
||||
"", "Action=ListAccessKeys&Version=2010-05-08")
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(body), "AKIATESTFAKEADMIN0001")
|
||||
assert.Contains(t, string(body), "<Status>Active</Status>")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
|
||||
)
|
||||
@@ -1265,53 +1266,19 @@ func (iama *IamApiServer) DeleteAccessKey(s3cfg *iam_pb.S3ApiConfiguration, valu
|
||||
return resp
|
||||
}
|
||||
|
||||
// handleImplicitUsername adds username who signs the request to values if 'username' is not specified.
|
||||
// According to AWS documentation: "If you do not specify a user name, IAM determines the user name
|
||||
// implicitly based on the Amazon Web Services access key ID signing the request."
|
||||
// This function extracts the AccessKeyId from the SigV4 credential and looks up the corresponding
|
||||
// identity name in the credential store.
|
||||
// handleImplicitUsername adds the name of the authenticated caller to values
|
||||
// when 'UserName' is not specified, per AWS: "If you do not specify a user name,
|
||||
// IAM determines the user name implicitly based on the Amazon Web Services
|
||||
// access key ID signing the request." The name comes from the identity the
|
||||
// authentication middleware verified, which the request context carries; the
|
||||
// Authorization header itself is attacker-controlled and is never re-parsed.
|
||||
func (iama *IamApiServer) handleImplicitUsername(r *http.Request, values url.Values) {
|
||||
if len(r.Header["Authorization"]) == 0 || values.Get("UserName") != "" {
|
||||
if values.Get("UserName") != "" {
|
||||
return
|
||||
}
|
||||
// Log presence of auth header without exposing sensitive signature material
|
||||
glog.V(4).Infof("Authorization header present, extracting access key")
|
||||
// Parse AWS SigV4 Authorization header format:
|
||||
// "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/iam/aws4_request, ..."
|
||||
s := strings.Split(r.Header["Authorization"][0], "Credential=")
|
||||
if len(s) < 2 {
|
||||
return
|
||||
if name := s3_constants.GetIdentityNameFromContext(r); name != "" {
|
||||
values.Set("UserName", name)
|
||||
}
|
||||
s = strings.Split(s[1], ",")
|
||||
if len(s) < 1 {
|
||||
return
|
||||
}
|
||||
s = strings.Split(s[0], "/")
|
||||
if len(s) < 1 {
|
||||
return
|
||||
}
|
||||
// s[0] is the AccessKeyId
|
||||
accessKeyId := s[0]
|
||||
if accessKeyId == "" {
|
||||
return
|
||||
}
|
||||
// Nil-guard: ensure iam is initialized before lookup
|
||||
if iama.iam == nil {
|
||||
glog.V(4).Infof("IAM not initialized, cannot look up access key")
|
||||
return
|
||||
}
|
||||
// Look up the identity by access key to get the username
|
||||
identity, _, found := iama.iam.LookupByAccessKey(accessKeyId)
|
||||
if !found {
|
||||
// Mask access key in logs - show only first 4 chars
|
||||
maskedKey := accessKeyId
|
||||
if len(accessKeyId) > 4 {
|
||||
maskedKey = accessKeyId[:4] + "***"
|
||||
}
|
||||
glog.V(4).Infof("Access key %s not found in credential store", maskedKey)
|
||||
return
|
||||
}
|
||||
values.Set("UserName", identity.Name)
|
||||
}
|
||||
|
||||
// targetedCredentialManager returns the credential manager through which a change to the named
|
||||
@@ -1470,6 +1437,7 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
case "GetUser":
|
||||
iama.handleImplicitUsername(r, values)
|
||||
userName := values.Get("UserName")
|
||||
var err *IamError
|
||||
response, err = iama.GetUser(s3cfg, userName)
|
||||
|
||||
+14
-32
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -524,32 +525,13 @@ func executeRequest(req *http.Request, v interface{}) (*httptest.ResponseRecorde
|
||||
}
|
||||
|
||||
func TestHandleImplicitUsername(t *testing.T) {
|
||||
// Create a mock IamApiServer with credential store
|
||||
// The handleImplicitUsername function now looks up the username from the
|
||||
// credential store based on AccessKeyId, not from the region field in the auth header.
|
||||
// Note: Using obviously fake access keys to avoid CI secret scanner false positives
|
||||
// The implicit name is the authenticated identity recorded in the request
|
||||
// context by the auth middleware; request headers are never re-parsed.
|
||||
iama := &IamApiServer{}
|
||||
|
||||
// Create IAM directly as struct literal (same pattern as other tests)
|
||||
iam := &s3api.IdentityAccessManagement{}
|
||||
|
||||
// Load test credentials - map access key to identity name
|
||||
testConfig := &iam_pb.S3ApiConfiguration{
|
||||
Identities: []*iam_pb.Identity{
|
||||
{
|
||||
Name: "testuser1",
|
||||
Credentials: []*iam_pb.Credential{
|
||||
{AccessKey: "AKIATESTFAKEKEY000001", SecretKey: "testsecretfake"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(t, testConfig))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load test config: %v", err)
|
||||
}
|
||||
|
||||
iama := &IamApiServer{
|
||||
iam: iam,
|
||||
withIdentity := func(name string) *http.Request {
|
||||
r := &http.Request{Header: http.Header{}}
|
||||
return r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), name))
|
||||
}
|
||||
|
||||
var tests = []struct {
|
||||
@@ -557,14 +539,14 @@ func TestHandleImplicitUsername(t *testing.T) {
|
||||
values url.Values
|
||||
userName string
|
||||
}{
|
||||
// No authorization header - should not set username
|
||||
// No authenticated identity - should not set username
|
||||
{&http.Request{}, url.Values{}, ""},
|
||||
// Valid auth header with known access key - should look up and find "testuser1"
|
||||
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, "testuser1"},
|
||||
// Malformed auth header (no Credential=) - should not set username
|
||||
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 =AKIATESTFAKEKEY000001/20220420/test1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
|
||||
// Unknown access key - should not set username
|
||||
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTUNKNOWN000000/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
|
||||
// Authenticated identity in context - sets its name
|
||||
{withIdentity("testuser1"), url.Values{}, "testuser1"},
|
||||
// A client-controlled Authorization header naming another user is ignored
|
||||
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
|
||||
// An explicit UserName is left alone
|
||||
{withIdentity("testuser1"), url.Values{"UserName": {"other"}}, "other"},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
|
||||
@@ -3,6 +3,7 @@ package s3api
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -251,9 +252,10 @@ func TestAuthorizeCopySource_PresignedURLSessionTokenPreserved(t *testing.T) {
|
||||
}
|
||||
|
||||
// Presigned URL: session token is in the query string, no
|
||||
// X-Amz-Security-Token header. The destination URL also carries unrelated
|
||||
// SigV4 query params (X-Amz-Algorithm, etc.) that must not leak into the
|
||||
// synthetic source action resolution.
|
||||
// X-Amz-Security-Token header. The destination URL also carries the rest of
|
||||
// the SigV4 presign params, which ride along so auth-derived policy
|
||||
// conditions (s3:authType) match the real request; none of them steer
|
||||
// ResolveS3Action.
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
"http://s3.local/bucket-a/dst.bin?X-Amz-Algorithm=AWS4-HMAC-SHA256"+
|
||||
"&X-Amz-Credential=stsKey%2F20260518%2Fus-east-1%2Fs3%2Faws4_request"+
|
||||
@@ -267,8 +269,12 @@ func TestAuthorizeCopySource_PresignedURLSessionTokenPreserved(t *testing.T) {
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
assert.Equal(t, "presigned-session-token", capturedSessionToken,
|
||||
"session token from presigned-URL query must reach the IAM check")
|
||||
// versionId omitted, only the token should be present in the synthetic query.
|
||||
assert.Equal(t, "X-Amz-Security-Token=presigned-session-token", capturedRawQuery)
|
||||
capturedQuery, err := url.ParseQuery(capturedRawQuery)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "presigned-session-token", capturedQuery.Get("X-Amz-Security-Token"))
|
||||
assert.Equal(t, "AWS4-HMAC-SHA256", capturedQuery.Get("X-Amz-Algorithm"))
|
||||
assert.False(t, capturedQuery.Has("versionId"))
|
||||
assert.False(t, capturedQuery.Has("acl"))
|
||||
}
|
||||
|
||||
// TestAuthorizeCopySource_PreservesCopySourceHeader verifies that the synthetic
|
||||
|
||||
@@ -3025,11 +3025,21 @@ 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.
|
||||
// authQueryParams are the query keys carrying a request's authentication
|
||||
// material (SigV2 and SigV4 presign parameters). None steer ResolveS3Action,
|
||||
// so they can ride along on a synthesized request.
|
||||
var authQueryParams = []string{
|
||||
"AWSAccessKeyId", "Signature", "Expires",
|
||||
"X-Amz-Algorithm", "X-Amz-Credential", "X-Amz-Date", "X-Amz-Expires",
|
||||
"X-Amz-Signature", "X-Amz-SignedHeaders", "X-Amz-Security-Token",
|
||||
}
|
||||
|
||||
// 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.
|
||||
// versionId and the auth-material params ride along so auth-derived policy
|
||||
// conditions (s3:authType) evaluate as they did on the real request.
|
||||
func objectKeyPolicyRequest(r *http.Request, method, bucket, objectKey, versionId string) *http.Request {
|
||||
keyReq := new(http.Request)
|
||||
*keyReq = *r
|
||||
@@ -3042,9 +3052,9 @@ func objectKeyPolicyRequest(r *http.Request, method, bucket, objectKey, versionI
|
||||
if versionId != "" {
|
||||
keyQuery.Set("versionId", versionId)
|
||||
}
|
||||
if strings.Contains(r.URL.RawQuery, "X-Amz-Security-Token") {
|
||||
if token := r.URL.Query().Get("X-Amz-Security-Token"); token != "" {
|
||||
keyQuery.Set("X-Amz-Security-Token", token)
|
||||
for _, key := range authQueryParams {
|
||||
if vals, ok := r.URL.Query()[key]; ok {
|
||||
keyQuery[key] = vals
|
||||
}
|
||||
}
|
||||
if len(keyQuery) > 0 {
|
||||
|
||||
+47
-3
@@ -235,13 +235,15 @@ func buildPreflightResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse
|
||||
|
||||
// Set exposed headers
|
||||
if len(rule.ExposeHeaders) > 0 {
|
||||
response.ExposeHeaders = strings.Join(rule.ExposeHeaders, ", ")
|
||||
response.ExposeHeaders = formatExposeHeaders(rule.ExposeHeaders)
|
||||
}
|
||||
|
||||
// Set max age
|
||||
if rule.MaxAgeSeconds != nil {
|
||||
response.MaxAge = strconv.Itoa(*rule.MaxAgeSeconds)
|
||||
}
|
||||
|
||||
response.AllowCredentials = true
|
||||
}
|
||||
|
||||
return response
|
||||
@@ -250,7 +252,8 @@ func buildPreflightResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse
|
||||
// buildResponse builds a CORS response from a matching rule
|
||||
func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
|
||||
response := &CORSResponse{
|
||||
AllowOrigin: corsReq.Origin,
|
||||
AllowOrigin: corsReq.Origin,
|
||||
AllowCredentials: true,
|
||||
}
|
||||
|
||||
// Set allowed methods
|
||||
@@ -263,7 +266,7 @@ func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
|
||||
|
||||
// Set expose headers
|
||||
if len(rule.ExposeHeaders) > 0 {
|
||||
response.ExposeHeaders = strings.Join(rule.ExposeHeaders, ", ")
|
||||
response.ExposeHeaders = formatExposeHeaders(rule.ExposeHeaders)
|
||||
}
|
||||
|
||||
// Set max age
|
||||
@@ -274,6 +277,47 @@ func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
|
||||
return response
|
||||
}
|
||||
|
||||
// defaultExposeHeaders are emitted alongside "*" so credentialed CORS requests
|
||||
// can read standard response headers ("*" is treated literally when credentials
|
||||
// are included, per the Fetch spec).
|
||||
var defaultExposeHeaders = []string{
|
||||
"ETag",
|
||||
"Content-Length",
|
||||
"Content-Type",
|
||||
"Last-Modified",
|
||||
"Content-Range",
|
||||
"x-amz-request-id",
|
||||
"x-amz-version-id",
|
||||
"x-amz-tagging-count",
|
||||
}
|
||||
|
||||
// formatExposeHeaders formats the exposed headers slice into a comma-separated string.
|
||||
func formatExposeHeaders(exposeHeaders []string) string {
|
||||
var headers []string
|
||||
hasWildcard := false
|
||||
seen := map[string]bool{}
|
||||
for _, h := range exposeHeaders {
|
||||
if h == "*" {
|
||||
hasWildcard = true
|
||||
continue
|
||||
}
|
||||
if lower := strings.ToLower(h); !seen[lower] {
|
||||
seen[lower] = true
|
||||
headers = append(headers, h)
|
||||
}
|
||||
}
|
||||
if hasWildcard {
|
||||
for _, h := range defaultExposeHeaders {
|
||||
if lower := strings.ToLower(h); !seen[lower] {
|
||||
seen[lower] = true
|
||||
headers = append(headers, h)
|
||||
}
|
||||
}
|
||||
headers = append(headers, "*")
|
||||
}
|
||||
return strings.Join(headers, ", ")
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// matchesOrigin checks if the request origin matches any allowed origin
|
||||
|
||||
@@ -405,11 +405,12 @@ func TestEvaluateRequest(t *testing.T) {
|
||||
Method: "GET",
|
||||
},
|
||||
want: &CORSResponse{
|
||||
AllowOrigin: "http://example.com",
|
||||
AllowMethods: "GET, POST",
|
||||
AllowHeaders: "Content-Type",
|
||||
ExposeHeaders: "ETag",
|
||||
MaxAge: "3600",
|
||||
AllowOrigin: "http://example.com",
|
||||
AllowMethods: "GET, POST",
|
||||
AllowHeaders: "Content-Type",
|
||||
ExposeHeaders: "ETag",
|
||||
MaxAge: "3600",
|
||||
AllowCredentials: true,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -421,8 +422,9 @@ func TestEvaluateRequest(t *testing.T) {
|
||||
Method: "PUT",
|
||||
},
|
||||
want: &CORSResponse{
|
||||
AllowOrigin: "http://other.com",
|
||||
AllowMethods: "PUT",
|
||||
AllowOrigin: "http://other.com",
|
||||
AllowMethods: "PUT",
|
||||
AllowCredentials: true,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -447,11 +449,12 @@ func TestEvaluateRequest(t *testing.T) {
|
||||
AccessControlRequestHeaders: []string{"Content-Type"},
|
||||
},
|
||||
want: &CORSResponse{
|
||||
AllowOrigin: "http://example.com",
|
||||
AllowMethods: "GET, POST",
|
||||
AllowHeaders: "Content-Type",
|
||||
ExposeHeaders: "ETag",
|
||||
MaxAge: "3600",
|
||||
AllowOrigin: "http://example.com",
|
||||
AllowMethods: "GET, POST",
|
||||
AllowHeaders: "Content-Type",
|
||||
ExposeHeaders: "ETag",
|
||||
MaxAge: "3600",
|
||||
AllowCredentials: true,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -467,7 +470,30 @@ func TestEvaluateRequest(t *testing.T) {
|
||||
},
|
||||
want: &CORSResponse{
|
||||
AllowOrigin: "http://example.com",
|
||||
// No AllowMethods or AllowHeaders because the requested header is forbidden
|
||||
// No AllowMethods, AllowHeaders, or AllowCredentials because the requested header is forbidden
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard expose headers expands for credentialed responses",
|
||||
config: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"http://example.com"},
|
||||
AllowedMethods: []string{"GET"},
|
||||
ExposeHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
corsReq: &CORSRequest{
|
||||
Origin: "http://example.com",
|
||||
Method: "GET",
|
||||
},
|
||||
want: &CORSResponse{
|
||||
AllowOrigin: "http://example.com",
|
||||
AllowMethods: "GET",
|
||||
ExposeHeaders: "ETag, Content-Length, Content-Type, Last-Modified, Content-Range, x-amz-request-id, x-amz-version-id, x-amz-tagging-count, *",
|
||||
AllowCredentials: true,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -511,18 +537,20 @@ func TestApplyHeaders(t *testing.T) {
|
||||
{
|
||||
name: "complete response",
|
||||
corsResp: &CORSResponse{
|
||||
AllowOrigin: "http://example.com",
|
||||
AllowMethods: "GET, POST",
|
||||
AllowHeaders: "Content-Type",
|
||||
ExposeHeaders: "ETag",
|
||||
MaxAge: "3600",
|
||||
AllowOrigin: "http://example.com",
|
||||
AllowMethods: "GET, POST",
|
||||
AllowHeaders: "Content-Type",
|
||||
ExposeHeaders: "ETag",
|
||||
MaxAge: "3600",
|
||||
AllowCredentials: true,
|
||||
},
|
||||
want: map[string]string{
|
||||
"Access-Control-Allow-Origin": "http://example.com",
|
||||
"Access-Control-Allow-Methods": "GET, POST",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Expose-Headers": "ETag",
|
||||
"Access-Control-Max-Age": "3600",
|
||||
"Access-Control-Allow-Origin": "http://example.com",
|
||||
"Access-Control-Allow-Methods": "GET, POST",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Expose-Headers": "ETag",
|
||||
"Access-Control-Max-Age": "3600",
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -166,15 +166,25 @@ func resolveFromQueryParameters(query url.Values, method string, hasObject bool)
|
||||
}
|
||||
}
|
||||
|
||||
// ACL operations
|
||||
// ACL operations. A versionId retargets the request at a specific
|
||||
// version, so the version ACL action applies, not the current-object one.
|
||||
if query.Has("acl") {
|
||||
versioned := hasObject && query.Get("versionId") != ""
|
||||
switch method {
|
||||
case http.MethodGet, http.MethodHead:
|
||||
// HEAD has no acl route; it lands on HeadObjectHandler and should
|
||||
// resolve like a read — s3:GetObject, or s3:GetObjectVersion below.
|
||||
case http.MethodGet:
|
||||
if versioned {
|
||||
return s3_constants.S3_ACTION_GET_OBJECT_VERSION_ACL
|
||||
}
|
||||
if hasObject {
|
||||
return s3_constants.S3_ACTION_GET_OBJECT_ACL
|
||||
}
|
||||
return s3_constants.S3_ACTION_GET_BUCKET_ACL
|
||||
case http.MethodPut:
|
||||
if versioned {
|
||||
return s3_constants.S3_ACTION_PUT_OBJECT_VERSION_ACL
|
||||
}
|
||||
if hasObject {
|
||||
return s3_constants.S3_ACTION_PUT_OBJECT_ACL
|
||||
}
|
||||
@@ -220,6 +230,9 @@ func resolveFromQueryParameters(query url.Values, method string, hasObject bool)
|
||||
// Must be checked before versionId, because GET /bucket/key?attributes&versionId=xyz
|
||||
// is a GetObjectAttributes request, not a GetObjectVersion request
|
||||
if hasObject && query.Has("attributes") && method == http.MethodGet {
|
||||
if query.Get("versionId") != "" {
|
||||
return s3_constants.S3_ACTION_GET_OBJECT_VERSION_ATTRIBUTES
|
||||
}
|
||||
return s3_constants.S3_ACTION_GET_OBJECT_ATTRIBUTES
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ func TestResolveS3Action_AttributesBeforeVersionId(t *testing.T) {
|
||||
method: http.MethodGet,
|
||||
baseAction: s3_constants.ACTION_READ,
|
||||
object: "key",
|
||||
want: s3_constants.S3_ACTION_GET_OBJECT_ATTRIBUTES,
|
||||
want: s3_constants.S3_ACTION_GET_OBJECT_VERSION_ATTRIBUTES,
|
||||
},
|
||||
{
|
||||
name: "versionId only GET",
|
||||
@@ -146,6 +146,59 @@ func TestResolveS3Action_VersionTagging(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An ACL request carrying versionId targets a specific version and must
|
||||
// resolve to the version ACL action so a grant limited to the current-object
|
||||
// action cannot reach historical versions.
|
||||
func TestResolveS3Action_VersionAcl(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
object string
|
||||
query string
|
||||
want string
|
||||
}{
|
||||
{"get current acl", http.MethodGet, "key", "acl", s3_constants.S3_ACTION_GET_OBJECT_ACL},
|
||||
{"put current acl", http.MethodPut, "key", "acl", s3_constants.S3_ACTION_PUT_OBJECT_ACL},
|
||||
{"get version acl", http.MethodGet, "key", "acl&versionId=v1", s3_constants.S3_ACTION_GET_OBJECT_VERSION_ACL},
|
||||
{"head acl resolves as head object", http.MethodHead, "key", "acl", s3_constants.S3_ACTION_GET_OBJECT},
|
||||
{"head version acl resolves as head version", http.MethodHead, "key", "acl&versionId=v1", s3_constants.S3_ACTION_GET_OBJECT_VERSION},
|
||||
{"put version acl", http.MethodPut, "key", "acl&versionId=v1", s3_constants.S3_ACTION_PUT_OBJECT_VERSION_ACL},
|
||||
{"empty versionId stays current", http.MethodGet, "key", "acl&versionId=", s3_constants.S3_ACTION_GET_OBJECT_ACL},
|
||||
{"bucket acl ignores versionId", http.MethodGet, "", "acl&versionId=v1", s3_constants.S3_ACTION_GET_BUCKET_ACL},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r, _ := http.NewRequest(tt.method, "http://localhost/bucket/"+tt.object+"?"+tt.query, nil)
|
||||
if got := ResolveS3Action(r, s3_constants.ACTION_READ, "bucket", tt.object); got != tt.want {
|
||||
t.Errorf("ResolveS3Action() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An attributes request carrying versionId targets a specific version and
|
||||
// must resolve to the version attributes action.
|
||||
func TestResolveS3Action_VersionAttributes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
object string
|
||||
query string
|
||||
want string
|
||||
}{
|
||||
{"get current attributes", http.MethodGet, "key", "attributes", s3_constants.S3_ACTION_GET_OBJECT_ATTRIBUTES},
|
||||
{"get version attributes", http.MethodGet, "key", "attributes&versionId=v1", s3_constants.S3_ACTION_GET_OBJECT_VERSION_ATTRIBUTES},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r, _ := http.NewRequest(tt.method, "http://localhost/bucket/"+tt.object+"?"+tt.query, nil)
|
||||
if got := ResolveS3Action(r, s3_constants.ACTION_READ, "bucket", tt.object); got != tt.want {
|
||||
t.Errorf("ResolveS3Action() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Bucket subresources registered with ACTION_ADMIN must resolve to their own
|
||||
// S3 actions so a policy granting one of them does not need s3:*, and so no
|
||||
// broader grant sweeps them in.
|
||||
|
||||
@@ -4,17 +4,22 @@ package s3_constants
|
||||
// These match the official AWS S3 action format used in IAM and bucket policies
|
||||
const (
|
||||
// Object operations
|
||||
S3_ACTION_GET_OBJECT = "s3:GetObject"
|
||||
S3_ACTION_PUT_OBJECT = "s3:PutObject"
|
||||
S3_ACTION_DELETE_OBJECT = "s3:DeleteObject"
|
||||
S3_ACTION_DELETE_OBJECT_VERSION = "s3:DeleteObjectVersion"
|
||||
S3_ACTION_GET_OBJECT_VERSION = "s3:GetObjectVersion"
|
||||
S3_ACTION_GET_OBJECT_ATTRIBUTES = "s3:GetObjectAttributes"
|
||||
S3_ACTION_GET_OBJECT = "s3:GetObject"
|
||||
S3_ACTION_PUT_OBJECT = "s3:PutObject"
|
||||
S3_ACTION_DELETE_OBJECT = "s3:DeleteObject"
|
||||
S3_ACTION_DELETE_OBJECT_VERSION = "s3:DeleteObjectVersion"
|
||||
S3_ACTION_GET_OBJECT_VERSION = "s3:GetObjectVersion"
|
||||
S3_ACTION_GET_OBJECT_ATTRIBUTES = "s3:GetObjectAttributes"
|
||||
S3_ACTION_GET_OBJECT_VERSION_ATTRIBUTES = "s3:GetObjectVersionAttributes"
|
||||
|
||||
// Object ACL operations
|
||||
S3_ACTION_GET_OBJECT_ACL = "s3:GetObjectAcl"
|
||||
S3_ACTION_PUT_OBJECT_ACL = "s3:PutObjectAcl"
|
||||
|
||||
// Version-specific ACL operations (?acl&versionId)
|
||||
S3_ACTION_GET_OBJECT_VERSION_ACL = "s3:GetObjectVersionAcl"
|
||||
S3_ACTION_PUT_OBJECT_VERSION_ACL = "s3:PutObjectVersionAcl"
|
||||
|
||||
// Object tagging operations
|
||||
S3_ACTION_GET_OBJECT_TAGGING = "s3:GetObjectTagging"
|
||||
S3_ACTION_PUT_OBJECT_TAGGING = "s3:PutObjectTagging"
|
||||
|
||||
@@ -576,3 +576,97 @@ func TestRecheckObjectAclTagConditionedPolicy(t *testing.T) {
|
||||
assert.Equal(t, s3err.ErrNone,
|
||||
s3a.recheckPolicyWithObjectEntry(putReq, "b", "secret.txt", s3_constants.S3_ACTION_PUT_OBJECT_ACL, publicEntry, "test"))
|
||||
}
|
||||
|
||||
// A versioned GetObjectAttributes request is authorized against the version
|
||||
// read grant, not the attributes action the original request resolves to: an
|
||||
// explicit bucket-policy Deny on s3:GetObjectVersion blocks it, while a legacy
|
||||
// identity holding only the coarse Read grant is still accepted.
|
||||
func TestCheckVersionReadGrant(t *testing.T) {
|
||||
s3a := &S3ApiServer{
|
||||
policyEngine: NewBucketPolicyEngine(),
|
||||
iam: &IdentityAccessManagement{},
|
||||
}
|
||||
|
||||
denyPolicyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:*",
|
||||
"Resource": ["arn:aws:s3:::deny-bucket", "arn:aws:s3:::deny-bucket/*"]
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObjectVersion",
|
||||
"Resource": "arn:aws:s3:::deny-bucket/key"
|
||||
}
|
||||
]
|
||||
}`
|
||||
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy("deny-bucket", denyPolicyJSON))
|
||||
|
||||
allowPolicyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:*",
|
||||
"Resource": ["arn:aws:s3:::allow-bucket", "arn:aws:s3:::allow-bucket/*"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy("allow-bucket", allowPolicyJSON))
|
||||
|
||||
identity := &Identity{
|
||||
Name: "reader",
|
||||
Actions: []Action{s3_constants.ACTION_READ},
|
||||
PrincipalArn: "arn:aws:iam::000000000000:user/reader",
|
||||
}
|
||||
|
||||
attrsReq := func(bucket string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/"+bucket+"/key?attributes&versionId=v1", nil)
|
||||
return req.WithContext(s3_constants.SetIdentityInContext(req.Context(), identity))
|
||||
}
|
||||
|
||||
assert.Equal(t, s3err.ErrAccessDenied,
|
||||
s3a.checkVersionReadGrant(attrsReq("deny-bucket"), "deny-bucket", "key", "v1", nil))
|
||||
assert.Equal(t, s3err.ErrNone,
|
||||
s3a.checkVersionReadGrant(attrsReq("allow-bucket"), "allow-bucket", "key", "v1", nil))
|
||||
assert.Equal(t, s3err.ErrNone,
|
||||
s3a.checkVersionReadGrant(attrsReq("no-policy-bucket"), "no-policy-bucket", "key", "v1", nil))
|
||||
}
|
||||
|
||||
// Presigned-URL credentials ride along on the synthesized version-read
|
||||
// request, so an s3:authType=REST-QUERY-STRING policy condition decides the
|
||||
// recheck the same way it decided the real request.
|
||||
func TestCheckVersionReadGrantPreservesQueryAuth(t *testing.T) {
|
||||
s3a := &S3ApiServer{
|
||||
policyEngine: NewBucketPolicyEngine(),
|
||||
iam: &IdentityAccessManagement{},
|
||||
}
|
||||
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:*",
|
||||
"Resource": ["arn:aws:s3:::qs-bucket", "arn:aws:s3:::qs-bucket/*"],
|
||||
"Condition": {"StringEquals": {"s3:authType": "REST-QUERY-STRING"}}
|
||||
}]
|
||||
}`
|
||||
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy("qs-bucket", policyJSON))
|
||||
|
||||
presigned := httptest.NewRequest(http.MethodGet,
|
||||
"/qs-bucket/key?attributes&versionId=v1&AWSAccessKeyId=AK&Signature=sig&Expires=9999999999", nil)
|
||||
assert.Equal(t, s3err.ErrNone,
|
||||
s3a.checkVersionReadGrant(presigned, "qs-bucket", "key", "v1", nil))
|
||||
|
||||
keyReq := objectKeyPolicyRequest(presigned, http.MethodGet, "qs-bucket", "key", "v1")
|
||||
q := keyReq.URL.Query()
|
||||
assert.Equal(t, "AK", q.Get("AWSAccessKeyId"))
|
||||
assert.Equal(t, "v1", q.Get("versionId"))
|
||||
assert.False(t, q.Has("attributes"))
|
||||
}
|
||||
|
||||
@@ -2594,53 +2594,19 @@ func (e *EmbeddedIamApi) ListGroupPolicies(s3cfg *iam_pb.S3ApiConfiguration, val
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// handleImplicitUsername adds username who signs the request to values if 'username' is not specified.
|
||||
// According to AWS documentation: "If you do not specify a user name, IAM determines the user name
|
||||
// implicitly based on the Amazon Web Services access key ID signing the request."
|
||||
// This function extracts the AccessKeyId from the SigV4 credential and looks up the corresponding
|
||||
// identity name in the credential store.
|
||||
// handleImplicitUsername adds the name of the authenticated caller to values
|
||||
// when 'UserName' is not specified, per AWS: "If you do not specify a user name,
|
||||
// IAM determines the user name implicitly based on the Amazon Web Services
|
||||
// access key ID signing the request." The name comes from the identity the
|
||||
// authentication middleware verified, which the request context carries; the
|
||||
// Authorization header itself is attacker-controlled and is never re-parsed.
|
||||
func (e *EmbeddedIamApi) handleImplicitUsername(r *http.Request, values url.Values) {
|
||||
if len(r.Header["Authorization"]) == 0 || values.Get("UserName") != "" {
|
||||
if values.Get("UserName") != "" {
|
||||
return
|
||||
}
|
||||
// Log presence of auth header without exposing sensitive signature material
|
||||
glog.V(4).Infof("Authorization header present, extracting access key")
|
||||
// Parse AWS SigV4 Authorization header format:
|
||||
// "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/iam/aws4_request, ..."
|
||||
s := strings.Split(r.Header["Authorization"][0], "Credential=")
|
||||
if len(s) < 2 {
|
||||
return
|
||||
if name := s3_constants.GetIdentityNameFromContext(r); name != "" {
|
||||
values.Set("UserName", name)
|
||||
}
|
||||
s = strings.Split(s[1], ",")
|
||||
if len(s) < 1 {
|
||||
return
|
||||
}
|
||||
s = strings.Split(s[0], "/")
|
||||
if len(s) < 1 {
|
||||
return
|
||||
}
|
||||
// s[0] is the AccessKeyId
|
||||
accessKeyId := s[0]
|
||||
if accessKeyId == "" {
|
||||
return
|
||||
}
|
||||
// Nil-guard: ensure iam is initialized before lookup
|
||||
if e.iam == nil {
|
||||
glog.V(4).Infof("IAM not initialized, cannot look up access key")
|
||||
return
|
||||
}
|
||||
// Look up the identity by access key to get the username
|
||||
identity, _, found := e.iam.LookupByAccessKey(accessKeyId)
|
||||
if !found {
|
||||
// Mask access key in logs - show only first 4 chars
|
||||
maskedKey := accessKeyId
|
||||
if len(accessKeyId) > 4 {
|
||||
maskedKey = accessKeyId[:4] + "***"
|
||||
}
|
||||
glog.V(4).Infof("Access key %s not found in credential store", maskedKey)
|
||||
return
|
||||
}
|
||||
values.Set("UserName", identity.Name)
|
||||
}
|
||||
|
||||
// iamSelfServiceActions are actions that users can perform on their own resources without admin rights.
|
||||
|
||||
@@ -372,13 +372,13 @@ func TestEmbeddedIamGetUserImplicitUsername(t *testing.T) {
|
||||
t.Fatalf("failed to load iam config: %v", err)
|
||||
}
|
||||
|
||||
// GetUser request with NO UserName, but signed by accessKey.
|
||||
// GetUser request with NO UserName, carrying the identity authentication
|
||||
// verified for accessKey, the way the middleware records it.
|
||||
form := url.Values{}
|
||||
form.Set("Action", "GetUser")
|
||||
req, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+accessKey+
|
||||
"/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesig")
|
||||
req = req.WithContext(SetIdentityNameInContext(req.Context(), "TestUser"))
|
||||
|
||||
out := iamGetUserResponse{}
|
||||
rr, err := executeEmbeddedIamRequest(api, req, &out)
|
||||
@@ -1578,29 +1578,15 @@ func TestEmbeddedIamDeleteAccessKey(t *testing.T) {
|
||||
assert.Len(t, api.mockConfig.Identities[0].Credentials, 0)
|
||||
}
|
||||
|
||||
// TestEmbeddedIamHandleImplicitUsername tests implicit username extraction from authorization header
|
||||
// TestEmbeddedIamHandleImplicitUsername checks that the implicit user name is
|
||||
// taken from the authenticated identity in the request context, never from
|
||||
// re-parsing the Authorization header.
|
||||
func TestEmbeddedIamHandleImplicitUsername(t *testing.T) {
|
||||
// Create IAM with test credentials - the handleImplicitUsername function now looks
|
||||
// up the username from the credential store based on AccessKeyId
|
||||
// Note: Using obviously fake access keys to avoid secret scanner false positives
|
||||
iam := &IdentityAccessManagement{}
|
||||
testConfig := &iam_pb.S3ApiConfiguration{
|
||||
Identities: []*iam_pb.Identity{
|
||||
{
|
||||
Name: "testuser1",
|
||||
Credentials: []*iam_pb.Credential{
|
||||
{AccessKey: UserAccessKeyPrefix + "TESTFAKEKEY000001", SecretKey: "testsecretfake"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(testConfig))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load test config: %v", err)
|
||||
}
|
||||
embeddedApi := &EmbeddedIamApi{}
|
||||
|
||||
embeddedApi := &EmbeddedIamApi{
|
||||
iam: iam,
|
||||
withIdentity := func(name string) *http.Request {
|
||||
r := &http.Request{Header: http.Header{}}
|
||||
return r.WithContext(SetIdentityNameInContext(r.Context(), name))
|
||||
}
|
||||
|
||||
var tests = []struct {
|
||||
@@ -1608,14 +1594,14 @@ func TestEmbeddedIamHandleImplicitUsername(t *testing.T) {
|
||||
values url.Values
|
||||
userName string
|
||||
}{
|
||||
// No authorization header - should not set username
|
||||
// No authenticated identity - should not set username
|
||||
{&http.Request{}, url.Values{}, ""},
|
||||
// Valid auth header with known access key - should look up and find "testuser1"
|
||||
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=" + UserAccessKeyPrefix + "TESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, "testuser1"},
|
||||
// Malformed auth header (no Credential=) - should not set username
|
||||
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 =" + UserAccessKeyPrefix + "TESTFAKEKEY000001/20220420/test1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
|
||||
// Unknown access key - should not set username
|
||||
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=" + UserAccessKeyPrefix + "TESTUNKNOWN000000/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
|
||||
// Authenticated identity in context - sets its name
|
||||
{withIdentity("testuser1"), url.Values{}, "testuser1"},
|
||||
// A client-controlled Authorization header naming another user is ignored
|
||||
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=" + UserAccessKeyPrefix + "TESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
|
||||
// An explicit UserName is left alone
|
||||
{withIdentity("testuser1"), url.Values{"UserName": {"other"}}, "other"},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
|
||||
@@ -181,8 +181,10 @@ func (s3a *S3ApiServer) parseAndValidateRange(w http.ResponseWriter, r *http.Req
|
||||
return 0, totalSize, false, nil
|
||||
}
|
||||
|
||||
// Empty directory: 404. A file promoted to a directory keeps its data and stays retrievable.
|
||||
if entry.IsDirectory && totalSize == 0 {
|
||||
// Empty directory: 404. A file promoted to a directory keeps its data and
|
||||
// stays retrievable, and a stored directory-key marker is an object, not a
|
||||
// missing key — an unsatisfiable range answers 416.
|
||||
if entry.IsDirectory && totalSize == 0 && !entry.IsDirectoryKeyObject() {
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
|
||||
return 0, 0, false, newStreamErrorWithResponse(fmt.Errorf("directory object %s/%s cannot be retrieved", bucket, object))
|
||||
}
|
||||
@@ -533,6 +535,24 @@ func (s3a *S3ApiServer) serveDirectoryContent(w http.ResponseWriter, r *http.Req
|
||||
if r.Method == http.MethodHead && len(entry.Chunks) > 0 {
|
||||
contentLength = int64(entry.Attributes.FileSize)
|
||||
}
|
||||
statusCode := http.StatusOK
|
||||
if r.Header.Get("Range") != "" && requestedPartNumber(r) == 0 {
|
||||
// A ranged request answers like the object path does: 206 with its
|
||||
// Content-Range and the range's length, served on GET, reported on HEAD.
|
||||
totalSize := contentLength
|
||||
offset, size, isRangeRequest, rangeErr := s3a.parseAndValidateRange(w, r, entry, totalSize, bucket, object)
|
||||
if rangeErr != nil {
|
||||
return
|
||||
}
|
||||
if isRangeRequest {
|
||||
contentLength = size
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, totalSize))
|
||||
statusCode = http.StatusPartialContent
|
||||
if r.Method != http.MethodHead {
|
||||
content = content[offset : offset+size]
|
||||
}
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(contentLength, 10))
|
||||
|
||||
// Set last modified
|
||||
@@ -545,12 +565,12 @@ func (s3a *S3ApiServer) serveDirectoryContent(w http.ResponseWriter, r *http.Req
|
||||
|
||||
// For HEAD requests, don't write body
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.WriteHeader(statusCode)
|
||||
return
|
||||
}
|
||||
|
||||
// Write content
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.WriteHeader(statusCode)
|
||||
if len(content) > 0 {
|
||||
if _, err := w.Write(content); err != nil {
|
||||
glog.Errorf("serveDirectoryContent: failed to write response: %v", err)
|
||||
@@ -2745,6 +2765,17 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", startOffset, endOffset, totalSize))
|
||||
statusCode = http.StatusPartialContent
|
||||
glog.V(3).Infof("HeadObject: part %d is bytes %d-%d/%d", partNumber, startOffset, endOffset, totalSize)
|
||||
} else if r.Header.Get("Range") != "" {
|
||||
// A ranged HEAD reports the range like GET does: 206 with its Content-Range and length
|
||||
offset, size, isRangeRequest, rangeErr := s3a.parseAndValidateRange(w, r, objectEntryForSSE, totalSize, bucket, object)
|
||||
if rangeErr != nil {
|
||||
return
|
||||
}
|
||||
if isRangeRequest {
|
||||
responseSize = size
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, totalSize))
|
||||
statusCode = http.StatusPartialContent
|
||||
}
|
||||
}
|
||||
|
||||
s3a.setResponseHeaders(w, r, objectEntryForSSE, responseSize)
|
||||
|
||||
@@ -30,6 +30,10 @@ func (s3a *S3ApiServer) GetObjectAclHandler(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// Check for specific version ID in query parameters
|
||||
versionId := r.URL.Query().Get("versionId")
|
||||
if errCode := s3a.checkVersionIdConfigured(bucket, versionId); errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if versioning is configured for the bucket (Enabled or Suspended)
|
||||
versioningConfigured, err := s3a.isVersioningConfigured(bucket)
|
||||
@@ -89,7 +93,11 @@ func (s3a *S3ApiServer) GetObjectAclHandler(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, s3_constants.S3_ACTION_GET_OBJECT_ACL, entry.Extended, "GetObjectAclHandler"); errCode != s3err.ErrNone {
|
||||
action := s3_constants.S3_ACTION_GET_OBJECT_ACL
|
||||
if versionId != "" {
|
||||
action = s3_constants.S3_ACTION_GET_OBJECT_VERSION_ACL
|
||||
}
|
||||
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, action, entry.Extended, "GetObjectAclHandler"); errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
@@ -142,6 +150,10 @@ func (s3a *S3ApiServer) PutObjectAclHandler(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// Check for specific version ID in query parameters
|
||||
versionId := r.URL.Query().Get("versionId")
|
||||
if errCode := s3a.checkVersionIdConfigured(bucket, versionId); errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if versioning is configured for the bucket (Enabled or Suspended)
|
||||
versioningConfigured, err := s3a.isVersioningConfigured(bucket)
|
||||
@@ -201,7 +213,11 @@ func (s3a *S3ApiServer) PutObjectAclHandler(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, s3_constants.S3_ACTION_PUT_OBJECT_ACL, entry.Extended, "PutObjectAclHandler"); errCode != s3err.ErrNone {
|
||||
putAction := s3_constants.S3_ACTION_PUT_OBJECT_ACL
|
||||
if versionId != "" {
|
||||
putAction = s3_constants.S3_ACTION_PUT_OBJECT_VERSION_ACL
|
||||
}
|
||||
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, putAction, entry.Extended, "PutObjectAclHandler"); errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -129,6 +129,10 @@ func (s3a *S3ApiServer) GetObjectAttributesHandler(w http.ResponseWriter, r *htt
|
||||
|
||||
// Check for specific version ID
|
||||
versionId := r.URL.Query().Get("versionId")
|
||||
if errCode := s3a.checkVersionIdConfigured(bucket, versionId); errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
entry *filer_pb.Entry
|
||||
@@ -219,11 +223,24 @@ 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 {
|
||||
action := s3_constants.S3_ACTION_GET_OBJECT_ATTRIBUTES
|
||||
if versionId != "" {
|
||||
action = s3_constants.S3_ACTION_GET_OBJECT_VERSION_ATTRIBUTES
|
||||
}
|
||||
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, action, entry.Extended, "GetObjectAttributesHandler"); errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
|
||||
// AWS also asks for the version read grant on a versioned attributes
|
||||
// request: s3:GetObjectVersionAttributes alone is not sufficient.
|
||||
if versionId != "" {
|
||||
if errCode := s3a.checkVersionReadGrant(r, bucket, object, versionId, entry.Extended); 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) {
|
||||
|
||||
@@ -2,17 +2,21 @@ package s3api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -1146,7 +1150,9 @@ func (s3a *S3ApiServer) CopyObjectPartHandler(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
if uploadEntryHasSSE(uploadEntry) || sourceEntryIsEncrypted(entry) || uploadEntryHasChecksum(uploadEntry) {
|
||||
// Inline-Content sources have no chunks for the raw-byte path to copy;
|
||||
// the plaintext stream serves their range instead.
|
||||
if uploadEntryHasSSE(uploadEntry) || sourceEntryIsEncrypted(entry) || uploadEntryHasChecksum(uploadEntry) || len(entry.Content) > 0 {
|
||||
etag, sseMetadata, errCode := s3a.copyObjectPartViaReencryption(r, entry, startOffset, endOffset, dstBucket, dstObject, uploadID, partID, uploadEntry)
|
||||
if errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
@@ -1204,15 +1210,24 @@ func (s3a *S3ApiServer) CopyObjectPartHandler(w http.ResponseWriter, r *http.Req
|
||||
// For zero-size files or invalid ranges, create an empty part with size 0
|
||||
dstEntry.Attributes.FileSize = 0
|
||||
dstEntry.Chunks = nil
|
||||
dstEntry.Attributes.Md5 = md5.New().Sum(nil)
|
||||
} else {
|
||||
// Copy chunks that overlap with the range
|
||||
dstChunks, err := s3a.copyChunksForRange(entry, startOffset, endOffset, dstAssignPath)
|
||||
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, startOffset, endOffset, dstAssignPath)
|
||||
if err != nil {
|
||||
glog.Errorf("CopyObjectPartHandler copy chunks error: %v", err)
|
||||
s3err.WriteErrorResponse(w, r, s3a.mapCopyErrorToS3Error(err))
|
||||
return
|
||||
}
|
||||
dstEntry.Chunks = dstChunks
|
||||
dstEntry.Attributes.Md5 = partMd5
|
||||
}
|
||||
|
||||
// Mirror putToFiler: the part entry carries the MD5 of its bytes so
|
||||
// CopyPartResult, ListParts and complete's composite ETag all describe
|
||||
// the copied range rather than the source chunks.
|
||||
if len(dstEntry.Attributes.Md5) > 0 {
|
||||
dstEntry.Extended[s3_constants.ExtETagKey] = []byte(fmt.Sprintf("%x", dstEntry.Attributes.Md5))
|
||||
}
|
||||
|
||||
// Save the part entry to the multipart uploads folder
|
||||
@@ -1479,7 +1494,7 @@ func (s3a *S3ApiServer) copySingleChunk(chunk *filer_pb.FileChunk, dstPath strin
|
||||
// the source volume for compressed bytes, so a gzipped chunk is
|
||||
// forwarded to the destination without anyone having to decompress.
|
||||
if canStreamCopyChunk(chunk) {
|
||||
if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, 0, int64(chunk.Size), true /*isFullChunk*/, assignResult); err != nil {
|
||||
if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, 0, int64(chunk.Size), true /*isFullChunk*/, assignResult, nil); err != nil {
|
||||
return nil, fmt.Errorf("stream chunk: %w", err)
|
||||
}
|
||||
return dstChunk, nil
|
||||
@@ -1534,9 +1549,22 @@ func (s3a *S3ApiServer) copySingleChunkForRange(originalChunk, rangeChunk *filer
|
||||
// part-size = chunk-size assemble pattern hits this branch.
|
||||
if canStreamCopyChunk(originalChunk) {
|
||||
isFullChunk := offsetInChunk == 0 && rangeChunk.Size == originalChunk.Size
|
||||
if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, offsetInChunk, int64(rangeChunk.Size), isFullChunk, assignResult); err != nil {
|
||||
// A partial range stores different bytes than the source chunk, so
|
||||
// its ETag has to be recomputed; a whole-chunk copy reuses it.
|
||||
var digest hash.Hash
|
||||
if !isFullChunk {
|
||||
digest = md5.New()
|
||||
}
|
||||
if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, offsetInChunk, int64(rangeChunk.Size), isFullChunk, assignResult, digest); err != nil {
|
||||
return nil, fmt.Errorf("stream chunk range: %w", err)
|
||||
}
|
||||
if digest != nil {
|
||||
dstChunk.ETag = base64.StdEncoding.EncodeToString(digest.Sum(nil))
|
||||
// The range fetch returns raw bytes and the destination stores
|
||||
// them uncompressed, so the source chunk's gzip flag must not
|
||||
// carry over onto a partial copy.
|
||||
dstChunk.IsCompressed = false
|
||||
}
|
||||
return dstChunk, nil
|
||||
}
|
||||
|
||||
@@ -1609,8 +1637,16 @@ func parseRangeHeader(rangeHeader string, fileSize int64) (startOffset, endOffse
|
||||
return startOffset, endOffset, nil
|
||||
}
|
||||
|
||||
// copyChunksForRange copies chunks that overlap with the specified range
|
||||
func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, endOffset int64, dstPath string) ([]*filer_pb.FileChunk, error) {
|
||||
// partMd5Result carries the whole-part MD5 the copiedRangeMd5 goroutine
|
||||
// computes beside the per-chunk copies.
|
||||
type partMd5Result struct {
|
||||
sum []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// copyChunksForRange copies chunks that overlap with the specified range and
|
||||
// returns the MD5 of the copied bytes for the part entry's Attributes.Md5.
|
||||
func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, endOffset int64, dstPath string) ([]*filer_pb.FileChunk, []byte, error) {
|
||||
var relevantChunks []*filer_pb.FileChunk
|
||||
var originalChunks []*filer_pb.FileChunk
|
||||
|
||||
@@ -1641,6 +1677,36 @@ func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, e
|
||||
}
|
||||
}
|
||||
|
||||
// The part's Attributes.Md5 must cover the copied range in byte order,
|
||||
// and md5 cannot combine per-chunk digests. A single chunk's own digest
|
||||
// is the answer only when it supplies the whole range; anything else —
|
||||
// several chunks, or gaps a sparse source zero-fills — needs an ordered
|
||||
// pass the concurrent copies cannot feed, so a second stream of the
|
||||
// source range hashes beside them.
|
||||
partSize := endOffset - startOffset + 1
|
||||
singleChunkCovers := len(relevantChunks) == 1 &&
|
||||
relevantChunks[0].Offset == 0 && int64(relevantChunks[0].Size) == partSize
|
||||
|
||||
md5Ctx, cancelMd5 := context.WithCancel(context.Background())
|
||||
defer cancelMd5()
|
||||
|
||||
var partMd5 []byte
|
||||
var partMd5Ch chan partMd5Result
|
||||
switch {
|
||||
case singleChunkCovers:
|
||||
// resolved below from the copied chunk's own digest
|
||||
case len(entry.GetChunks()) == 0:
|
||||
// no chunks to read at all: the part is partSize zero bytes (the
|
||||
// stream helper returns empty for a chunkless entry, not zero-fill)
|
||||
partMd5 = md5Zeros(partSize)
|
||||
default:
|
||||
partMd5Ch = make(chan partMd5Result, 1)
|
||||
go func() {
|
||||
sum, err := s3a.copiedRangeMd5(md5Ctx, entry, startOffset, endOffset)
|
||||
partMd5Ch <- partMd5Result{sum, err}
|
||||
}()
|
||||
}
|
||||
|
||||
// Copy the relevant chunks using a specialized method for range copies
|
||||
dstChunks := make([]*filer_pb.FileChunk, len(relevantChunks))
|
||||
const defaultChunkCopyConcurrency = 4
|
||||
@@ -1664,11 +1730,79 @@ func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, e
|
||||
// Wait for all operations to complete and check for errors
|
||||
for i := 0; i < len(relevantChunks); i++ {
|
||||
if err := <-errChan; err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return dstChunks, nil
|
||||
// Reads resolve overlapping chunks by ModifiedTsNs, and the concurrent
|
||||
// copies stamped arbitrary finish times: rank the destination chunks by
|
||||
// the source's timestamps so the part resolves overlaps the way the
|
||||
// source did (and the way copiedRangeMd5 hashed it).
|
||||
if len(dstChunks) > 1 {
|
||||
order := make([]int, len(dstChunks))
|
||||
for i := range order {
|
||||
order[i] = i
|
||||
}
|
||||
slices.SortStableFunc(order, func(a, b int) int {
|
||||
return cmp.Compare(originalChunks[a].ModifiedTsNs, originalChunks[b].ModifiedTsNs)
|
||||
})
|
||||
now := time.Now().UnixNano()
|
||||
for rank, i := range order {
|
||||
dstChunks[i].ModifiedTsNs = now + int64(rank)
|
||||
}
|
||||
}
|
||||
|
||||
if partMd5Ch != nil {
|
||||
if res := <-partMd5Ch; res.err != nil {
|
||||
return nil, nil, fmt.Errorf("md5 of copied range: %w", res.err)
|
||||
} else {
|
||||
partMd5 = res.sum
|
||||
}
|
||||
} else if singleChunkCovers {
|
||||
if partMd5 = util.Base64Md5ToBytes(dstChunks[0].ETag); len(partMd5) != md5.Size {
|
||||
// TUS and mount writes leave chunks without an ETag; hash the
|
||||
// copied range when the inherited digest is missing or malformed.
|
||||
sum, err := s3a.copiedRangeMd5(md5Ctx, entry, startOffset, endOffset)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("md5 of copied range: %w", err)
|
||||
}
|
||||
partMd5 = sum
|
||||
}
|
||||
}
|
||||
|
||||
return dstChunks, partMd5, nil
|
||||
}
|
||||
|
||||
// md5Zeros hashes size zero bytes without a volume read — a copied range
|
||||
// that overlaps no source chunk assembles to all zeros.
|
||||
func md5Zeros(size int64) []byte {
|
||||
h := md5.New()
|
||||
var zeros [1 << 16]byte
|
||||
for size > 0 {
|
||||
n := min(size, int64(len(zeros)))
|
||||
h.Write(zeros[:n])
|
||||
size -= n
|
||||
}
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// copiedRangeMd5 streams the source range once and hashes its plaintext —
|
||||
// exactly the bytes the non-SSE part copy assembles on the destination.
|
||||
func (s3a *S3ApiServer) copiedRangeMd5(ctx context.Context, entry *filer_pb.Entry, startOffset, endOffset int64) ([]byte, error) {
|
||||
size := endOffset - startOffset + 1
|
||||
// The copy walks the chunks, so the hash must too: on an entry that also
|
||||
// carries inline Content the stream helper would prefer Content instead.
|
||||
chunked := &filer_pb.Entry{Chunks: entry.GetChunks()}
|
||||
reader, err := s3a.getEncryptedStreamFromVolumes(ctx, chunked, startOffset, size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
h := md5.New()
|
||||
if _, err := io.Copy(h, reader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.Sum(nil), nil
|
||||
}
|
||||
|
||||
// Helper methods for copy operations to avoid code duplication
|
||||
|
||||
@@ -160,7 +160,7 @@ func BenchmarkCopyChunk_Streamed(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := s3a.streamCopyChunkRange(context.Background(),
|
||||
env.srcSrv.URL, env.assign.FileId, 0, int64(size),
|
||||
true /*isFullChunk*/, env.assign); err != nil {
|
||||
true /*isFullChunk*/, env.assign, nil); err != nil {
|
||||
b.Fatalf("stream: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -9,6 +14,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
|
||||
)
|
||||
|
||||
func TestCopyEntryETagPrefersStoredExtendedETag(t *testing.T) {
|
||||
@@ -145,3 +151,292 @@ func TestParseRangeHeaderRejectsUnsatisfiableRange(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A part copied with x-amz-copy-source-range must report the MD5 of the copied
|
||||
// bytes: per destination chunk for a partial-chunk copy, and the whole-part MD5
|
||||
// the part entry's Attributes.Md5 carries for CopyPartResult / ListParts /
|
||||
// complete's composite ETag.
|
||||
func TestCopyChunksForRangeCopiedETags(t *testing.T) {
|
||||
volume := startFakeVolumeServer(t)
|
||||
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}}
|
||||
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
|
||||
util_http.InitGlobalHttpClient()
|
||||
|
||||
const chunkSize = 4 << 20
|
||||
payload := make([]byte, 2*chunkSize)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i*31 + 7)
|
||||
}
|
||||
b64md5 := func(data []byte) string {
|
||||
sum := md5.Sum(data)
|
||||
return base64.StdEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
fid0 := fmt.Sprintf("3,%016x%08x", 101, 202)
|
||||
fid1 := fmt.Sprintf("3,%016x%08x", 103, 204)
|
||||
volume.stored[fid0] = payload[:chunkSize]
|
||||
volume.stored[fid1] = payload[chunkSize:]
|
||||
entry := &filer_pb.Entry{
|
||||
Name: "src",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
FileSize: uint64(len(payload)),
|
||||
},
|
||||
Chunks: []*filer_pb.FileChunk{
|
||||
{FileId: fid0, Offset: 0, Size: chunkSize, ETag: b64md5(payload[:chunkSize])},
|
||||
{FileId: fid1, Offset: chunkSize, Size: chunkSize, ETag: b64md5(payload[chunkSize:])},
|
||||
},
|
||||
}
|
||||
|
||||
// A range spanning the first chunk fully plus 1 MiB of the second.
|
||||
multiEnd := int64(chunkSize + 1<<20 - 1)
|
||||
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, 0, multiEnd, "/dst")
|
||||
if err != nil {
|
||||
t.Fatalf("copyChunksForRange: %v", err)
|
||||
}
|
||||
if len(dstChunks) != 2 {
|
||||
t.Fatalf("copyChunksForRange returned %d chunks, want 2", len(dstChunks))
|
||||
}
|
||||
// A whole-chunk copy forwards the stored bytes, so the source ETag stays valid.
|
||||
if got, want := dstChunks[0].ETag, entry.Chunks[0].ETag; got != want {
|
||||
t.Errorf("whole-chunk copy ETag = %q, want source chunk ETag %q", got, want)
|
||||
}
|
||||
// A partial copy stores only the range bytes; its ETag must be their MD5.
|
||||
if got, want := dstChunks[1].ETag, b64md5(payload[chunkSize:multiEnd+1]); got != want {
|
||||
t.Errorf("partial-chunk copy ETag = %q, want copied-bytes MD5 %q", got, want)
|
||||
}
|
||||
// The copied bytes landed on the destination volume (inside the multipart framing).
|
||||
if stored := volume.stored[dstChunks[1].GetFileIdString()]; !bytes.Contains(stored, payload[chunkSize:multiEnd+1]) {
|
||||
t.Error("destination volume does not hold the copied range bytes")
|
||||
}
|
||||
// The whole-part MD5 hashes every copied byte once, in range order.
|
||||
wantPartMd5 := md5.Sum(payload[:multiEnd+1])
|
||||
if !bytes.Equal(partMd5, wantPartMd5[:]) {
|
||||
t.Errorf("part Md5 = %x, want %x", partMd5, wantPartMd5)
|
||||
}
|
||||
partEntry := &filer_pb.Entry{
|
||||
Attributes: &filer_pb.FuseAttributes{Md5: partMd5},
|
||||
Chunks: dstChunks,
|
||||
}
|
||||
if got, want := copyEntryETag(partEntry), fmt.Sprintf("%x", wantPartMd5); got != want {
|
||||
t.Errorf("copyEntryETag = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// A range inside one chunk: the part's ETag is the MD5 of those bytes.
|
||||
innerChunks, innerMd5, err := s3a.copyChunksForRange(entry, 100, 199, "/dst")
|
||||
if err != nil {
|
||||
t.Fatalf("copyChunksForRange inner range: %v", err)
|
||||
}
|
||||
if len(innerChunks) != 1 {
|
||||
t.Fatalf("inner range returned %d chunks, want 1", len(innerChunks))
|
||||
}
|
||||
if got, want := innerChunks[0].ETag, b64md5(payload[100:200]); got != want {
|
||||
t.Errorf("inner-range chunk ETag = %q, want copied-bytes MD5 %q", got, want)
|
||||
}
|
||||
wantInnerMd5 := md5.Sum(payload[100:200])
|
||||
if !bytes.Equal(innerMd5, wantInnerMd5[:]) {
|
||||
t.Errorf("inner-range part Md5 = %x, want %x", innerMd5, wantInnerMd5)
|
||||
}
|
||||
}
|
||||
|
||||
// A range over a sparse source zero-fills the gaps, so the part MD5 has to
|
||||
// hash the logical range — not just the one chunk that overlaps it.
|
||||
func TestCopyChunksForRangeSparseETags(t *testing.T) {
|
||||
volume := startFakeVolumeServer(t)
|
||||
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}}
|
||||
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
|
||||
util_http.InitGlobalHttpClient()
|
||||
|
||||
fid := fmt.Sprintf("3,%016x%08x", 105, 206)
|
||||
volume.stored[fid] = []byte("abc")
|
||||
sum := md5.Sum([]byte("abc"))
|
||||
entry := &filer_pb.Entry{
|
||||
Name: "sparse",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
FileSize: 6,
|
||||
},
|
||||
Chunks: []*filer_pb.FileChunk{
|
||||
{FileId: fid, Offset: 3, Size: 3, ETag: base64.StdEncoding.EncodeToString(sum[:])},
|
||||
},
|
||||
}
|
||||
|
||||
// bytes 0-5 read as "\x00\x00\x00abc"; the part MD5 must cover the zeros too.
|
||||
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, 0, 5, "/dst")
|
||||
if err != nil {
|
||||
t.Fatalf("copyChunksForRange sparse: %v", err)
|
||||
}
|
||||
if len(dstChunks) != 1 || dstChunks[0].Offset != 3 {
|
||||
t.Fatalf("sparse copy chunks = %+v, want one chunk at offset 3", dstChunks)
|
||||
}
|
||||
wantMd5 := md5.Sum([]byte("\x00\x00\x00abc"))
|
||||
if !bytes.Equal(partMd5, wantMd5[:]) {
|
||||
t.Errorf("sparse part Md5 = %x, want %x (zero-filled range)", partMd5, wantMd5)
|
||||
}
|
||||
|
||||
// A range over a pure hole copies nothing but still reads as zeros.
|
||||
holeChunks, holeMd5, err := s3a.copyChunksForRange(entry, 0, 2, "/dst")
|
||||
if err != nil {
|
||||
t.Fatalf("copyChunksForRange hole: %v", err)
|
||||
}
|
||||
if len(holeChunks) != 0 {
|
||||
t.Fatalf("hole copy returned %d chunks, want 0", len(holeChunks))
|
||||
}
|
||||
wantHoleMd5 := md5.Sum([]byte{0, 0, 0})
|
||||
if !bytes.Equal(holeMd5, wantHoleMd5[:]) {
|
||||
t.Errorf("hole part Md5 = %x, want %x (all zeros)", holeMd5, wantHoleMd5)
|
||||
}
|
||||
}
|
||||
|
||||
// Destination chunks must resolve overlaps the way the source did: reads
|
||||
// pick winners by ModifiedTsNs, so the copied chunks keep the source's
|
||||
// precedence even though they were uploaded concurrently.
|
||||
func TestCopyChunksForRangePreservesSourcePrecedence(t *testing.T) {
|
||||
volume := startFakeVolumeServer(t)
|
||||
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}}
|
||||
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
|
||||
util_http.InitGlobalHttpClient()
|
||||
|
||||
etagOf := func(b []byte) string {
|
||||
sum := md5.Sum(b)
|
||||
return base64.StdEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
fidA := fmt.Sprintf("3,%016x%08x", 107, 208)
|
||||
fidB := fmt.Sprintf("3,%016x%08x", 109, 210)
|
||||
volume.stored[fidA] = []byte("aaaa")
|
||||
volume.stored[fidB] = []byte("bbbb")
|
||||
|
||||
// A covers [0,4), B covers [2,6); B's range is copied into a chunk that
|
||||
// overlaps A's copy on [2,4), and the source says A wins there.
|
||||
entry := &filer_pb.Entry{
|
||||
Name: "overlap",
|
||||
Attributes: &filer_pb.FuseAttributes{FileSize: 6},
|
||||
Chunks: []*filer_pb.FileChunk{
|
||||
{FileId: fidA, Offset: 0, Size: 4, ModifiedTsNs: 20, ETag: etagOf([]byte("aaaa"))},
|
||||
{FileId: fidB, Offset: 2, Size: 4, ModifiedTsNs: 10, ETag: etagOf([]byte("bbbb"))},
|
||||
},
|
||||
}
|
||||
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, 0, 5, "/dst")
|
||||
if err != nil {
|
||||
t.Fatalf("copyChunksForRange overlap: %v", err)
|
||||
}
|
||||
if len(dstChunks) != 2 {
|
||||
t.Fatalf("overlap copy returned %d chunks, want 2", len(dstChunks))
|
||||
}
|
||||
if dstChunks[0].ModifiedTsNs <= dstChunks[1].ModifiedTsNs {
|
||||
t.Errorf("destination precedence inverted: chunk from ts=20 got %d, chunk from ts=10 got %d",
|
||||
dstChunks[0].ModifiedTsNs, dstChunks[1].ModifiedTsNs)
|
||||
}
|
||||
// The source resolves to "aaaa"+"bb"; the part MD5 must match that view.
|
||||
wantMd5 := md5.Sum([]byte("aaaabb"))
|
||||
if !bytes.Equal(partMd5, wantMd5[:]) {
|
||||
t.Errorf("overlap part Md5 = %x, want %x", partMd5, wantMd5)
|
||||
}
|
||||
}
|
||||
|
||||
// An inline-Content source has no chunks for the raw-byte copy; the plaintext
|
||||
// stream path must write the range's real bytes and tag the part with their
|
||||
// MD5, not a zero-fill digest.
|
||||
func TestCopyObjectPartInlineContent(t *testing.T) {
|
||||
volume := startFakeVolumeServer(t)
|
||||
filerImpl := &ambiguousPutFiler{volume: volume, apply: true, entries: map[string]*filer_pb.Entry{}}
|
||||
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
|
||||
util_http.InitGlobalHttpClient()
|
||||
|
||||
content := []byte("inline-content")
|
||||
entry := &filer_pb.Entry{
|
||||
Name: "src",
|
||||
Attributes: &filer_pb.FuseAttributes{FileSize: uint64(len(content))},
|
||||
Content: content,
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPut, "/b/dst?partNumber=1&uploadId=u1", nil)
|
||||
etag, _, code := s3a.copyObjectPartViaReencryption(r, entry, 2, 5, "b", "dst", "u1", 1, &filer_pb.Entry{})
|
||||
if code != s3err.ErrNone {
|
||||
t.Fatalf("copyObjectPartViaReencryption: %v", code)
|
||||
}
|
||||
wantMd5 := md5.Sum(content[2:6])
|
||||
if got := strings.Trim(etag, `"`); got != fmt.Sprintf("%x", wantMd5) {
|
||||
t.Errorf("etag = %q, want %x", got, wantMd5)
|
||||
}
|
||||
var partEntry *filer_pb.Entry
|
||||
for key, e := range filerImpl.entries {
|
||||
if strings.Contains(key, "u1") {
|
||||
partEntry = e
|
||||
}
|
||||
}
|
||||
if partEntry == nil || len(partEntry.Chunks) == 0 {
|
||||
t.Fatalf("no part entry with chunks stored: %+v", filerImpl.entries)
|
||||
}
|
||||
found := false
|
||||
for _, stored := range volume.stored {
|
||||
if bytes.Contains(stored, content[2:6]) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("destination volume does not hold the copied content bytes")
|
||||
}
|
||||
}
|
||||
|
||||
// An empty part must carry the empty-body MD5 on the stored entry too, so
|
||||
// ListParts and complete's composite ETag see the same digest CopyPartResult
|
||||
// reported.
|
||||
func TestWriteEmptyCopyPartStoresMd5(t *testing.T) {
|
||||
volume := startFakeVolumeServer(t)
|
||||
filerImpl := &ambiguousPutFiler{volume: volume, apply: true, entries: map[string]*filer_pb.Entry{}}
|
||||
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
|
||||
util_http.InitGlobalHttpClient()
|
||||
|
||||
etag, code := s3a.writeEmptyCopyPart("b", "u1", 3)
|
||||
if code != s3err.ErrNone {
|
||||
t.Fatalf("writeEmptyCopyPart: %v", code)
|
||||
}
|
||||
const emptyMD5Hex = "d41d8cd98f00b204e9800998ecf8427e"
|
||||
if etag != emptyMD5Hex {
|
||||
t.Fatalf("writeEmptyCopyPart etag = %q, want %q", etag, emptyMD5Hex)
|
||||
}
|
||||
var partEntry *filer_pb.Entry
|
||||
for key, e := range filerImpl.entries {
|
||||
if strings.Contains(key, "u1") {
|
||||
partEntry = e
|
||||
}
|
||||
}
|
||||
if partEntry == nil {
|
||||
t.Fatalf("no part entry stored: %+v", filerImpl.entries)
|
||||
}
|
||||
if got := string(partEntry.Extended[s3_constants.ExtETagKey]); got != emptyMD5Hex {
|
||||
t.Errorf("stored ExtETagKey = %q, want %q", got, emptyMD5Hex)
|
||||
}
|
||||
if got := fmt.Sprintf("%x", partEntry.Attributes.Md5); got != emptyMD5Hex {
|
||||
t.Errorf("stored Attributes.Md5 = %q, want %q", got, emptyMD5Hex)
|
||||
}
|
||||
}
|
||||
|
||||
// A whole-range chunk copy normally reuses the source chunk's ETag as the
|
||||
// part MD5; TUS and mount writes leave chunks without one, so the part has
|
||||
// to hash the copied bytes instead of saving an empty digest.
|
||||
func TestCopyChunksForRangeMissingChunkETag(t *testing.T) {
|
||||
volume := startFakeVolumeServer(t)
|
||||
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}}
|
||||
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
|
||||
util_http.InitGlobalHttpClient()
|
||||
|
||||
fid := fmt.Sprintf("3,%016x%08x", 111, 212)
|
||||
volume.stored[fid] = []byte("etagless")
|
||||
entry := &filer_pb.Entry{
|
||||
Name: "src",
|
||||
Attributes: &filer_pb.FuseAttributes{FileSize: 8},
|
||||
Chunks: []*filer_pb.FileChunk{
|
||||
{FileId: fid, Offset: 0, Size: 8},
|
||||
},
|
||||
}
|
||||
|
||||
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, 0, 7, "/dst")
|
||||
if err != nil {
|
||||
t.Fatalf("copyChunksForRange: %v", err)
|
||||
}
|
||||
if len(dstChunks) != 1 {
|
||||
t.Fatalf("copyChunksForRange returned %d chunks, want 1", len(dstChunks))
|
||||
}
|
||||
wantMd5 := md5.Sum([]byte("etagless"))
|
||||
if !bytes.Equal(partMd5, wantMd5[:]) {
|
||||
t.Errorf("part Md5 = %x, want %x (hashed copied range)", partMd5, wantMd5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -419,14 +420,19 @@ func (s3a *S3ApiServer) writeEmptyCopyPart(dstBucket, uploadID string, partID in
|
||||
return "", s3err.ErrInternalError
|
||||
}
|
||||
}
|
||||
const emptyMD5Hex = "d41d8cd98f00b204e9800998ecf8427e"
|
||||
if err := s3a.mkFile(uploadDir, partName, nil, func(e *filer_pb.Entry) {
|
||||
if e.Attributes == nil {
|
||||
e.Attributes = &filer_pb.FuseAttributes{}
|
||||
}
|
||||
e.Attributes.FileSize = 0
|
||||
e.Attributes.Md5 = md5.New().Sum(nil)
|
||||
if e.Extended == nil {
|
||||
e.Extended = map[string][]byte{}
|
||||
}
|
||||
e.Extended[s3_constants.ExtETagKey] = []byte(emptyMD5Hex)
|
||||
}); err != nil {
|
||||
return "", s3err.ErrInternalError
|
||||
}
|
||||
const emptyMD5Hex = "d41d8cd98f00b204e9800998ecf8427e"
|
||||
return emptyMD5Hex, s3err.ErrNone
|
||||
}
|
||||
|
||||
@@ -73,12 +73,19 @@ func canStreamCopyChunk(chunk *filer_pb.FileChunk) bool {
|
||||
// Trusting a hint that disagrees with the wire bytes (e.g. caller says
|
||||
// "compressed" but the volume returned raw bytes for a partial range)
|
||||
// would label raw bytes as gzip and corrupt reads on the destination.
|
||||
//
|
||||
// digestWriter, when non-nil, receives the plaintext bytes as they are
|
||||
// forwarded so the caller can derive the destination chunk's ETag. It is
|
||||
// not fed when the wire bytes are gzip — only possible on the full-chunk
|
||||
// pass-through, whose logical content still matches the source chunk's
|
||||
// own ETag and needs no recomputation.
|
||||
func (s3a *S3ApiServer) streamCopyChunkRange(
|
||||
ctx context.Context,
|
||||
srcUrl, srcFileId string,
|
||||
offset, size int64,
|
||||
isFullChunk bool,
|
||||
assignResult *filer_pb.AssignVolumeResponse,
|
||||
digestWriter io.Writer,
|
||||
) error {
|
||||
// Range header takes int64, but mirror the int32 bound from
|
||||
// downloadChunkData so the two paths reject the same inputs.
|
||||
@@ -166,7 +173,11 @@ func (s3a *S3ApiServer) streamCopyChunkRange(
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := io.Copy(fw, srcResp.Body); err != nil {
|
||||
src := io.Reader(srcResp.Body)
|
||||
if digestWriter != nil && !bodyIsGzipped {
|
||||
src = io.TeeReader(srcResp.Body, digestWriter)
|
||||
}
|
||||
if _, err := io.Copy(fw, src); err != nil {
|
||||
if shouldLogStreamError(err) {
|
||||
glog.V(2).Infof("stream copy %s offset=%d size=%d: %v",
|
||||
srcUrl, offset, size, err)
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestCopyChunksReadOnlyIsAccessDenied(t *testing.T) {
|
||||
t.Fatalf("copyChunks err %v maps to %v, want %v", err, code, s3err.ErrAccessDenied)
|
||||
}
|
||||
|
||||
_, err = s3a.copyChunksForRange(entry, 0, 7, "/buckets/b/dst")
|
||||
_, _, err = s3a.copyChunksForRange(entry, 0, 7, "/buckets/b/dst")
|
||||
if code := s3a.mapCopyErrorToS3Error(err); code != s3err.ErrAccessDenied {
|
||||
t.Fatalf("copyChunksForRange err %v maps to %v, want %v", err, code, s3err.ErrAccessDenied)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ func (s3a *S3ApiServer) GetObjectTaggingHandler(w http.ResponseWriter, r *http.R
|
||||
|
||||
// Check for specific version ID in query parameters
|
||||
versionId := r.URL.Query().Get("versionId")
|
||||
if errCode := s3a.checkVersionIdConfigured(bucket, versionId); errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if versioning is configured for the bucket (Enabled or Suspended)
|
||||
versioningConfigured, err := s3a.isVersioningConfigured(bucket)
|
||||
@@ -87,7 +91,11 @@ func (s3a *S3ApiServer) GetObjectTaggingHandler(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
}
|
||||
|
||||
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, s3_constants.S3_ACTION_GET_OBJECT_TAGGING, entry.Extended, "GetObjectTaggingHandler"); errCode != s3err.ErrNone {
|
||||
action := s3_constants.S3_ACTION_GET_OBJECT_TAGGING
|
||||
if versionId != "" {
|
||||
action = s3_constants.S3_ACTION_GET_OBJECT_VERSION_TAGGING
|
||||
}
|
||||
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, action, entry.Extended, "GetObjectTaggingHandler"); errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
@@ -140,6 +148,10 @@ func (s3a *S3ApiServer) PutObjectTaggingHandler(w http.ResponseWriter, r *http.R
|
||||
|
||||
// Check for specific version ID in query parameters
|
||||
versionId := r.URL.Query().Get("versionId")
|
||||
if errCode := s3a.checkVersionIdConfigured(bucket, versionId); errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if versioning is configured for the bucket (Enabled or Suspended)
|
||||
versioningConfigured, err := s3a.isVersioningConfigured(bucket)
|
||||
@@ -185,7 +197,11 @@ 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, versionId, s3_constants.S3_ACTION_PUT_OBJECT_TAGGING, "PutObjectTaggingHandler"); errCode != s3err.ErrNone {
|
||||
putAction := s3_constants.S3_ACTION_PUT_OBJECT_TAGGING
|
||||
if versionId != "" {
|
||||
putAction = s3_constants.S3_ACTION_PUT_OBJECT_VERSION_TAGGING
|
||||
}
|
||||
if errCode := s3a.recheckTagConditionedPolicyWithLookup(r, bucket, object, versionId, putAction, "PutObjectTaggingHandler"); errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
@@ -206,7 +222,11 @@ 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 {
|
||||
putAction := s3_constants.S3_ACTION_PUT_OBJECT_TAGGING
|
||||
if versionId != "" {
|
||||
putAction = s3_constants.S3_ACTION_PUT_OBJECT_VERSION_TAGGING
|
||||
}
|
||||
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, putAction, entry.Extended, "PutObjectTaggingHandler"); errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
@@ -268,6 +288,10 @@ func (s3a *S3ApiServer) DeleteObjectTaggingHandler(w http.ResponseWriter, r *htt
|
||||
|
||||
// Check for specific version ID in query parameters
|
||||
versionId := r.URL.Query().Get("versionId")
|
||||
if errCode := s3a.checkVersionIdConfigured(bucket, versionId); errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if versioning is configured for the bucket (Enabled or Suspended)
|
||||
versioningConfigured, err := s3a.isVersioningConfigured(bucket)
|
||||
@@ -313,7 +337,11 @@ 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, versionId, s3_constants.S3_ACTION_DELETE_OBJECT_TAGGING, "DeleteObjectTaggingHandler"); errCode != s3err.ErrNone {
|
||||
deleteAction := s3_constants.S3_ACTION_DELETE_OBJECT_TAGGING
|
||||
if versionId != "" {
|
||||
deleteAction = s3_constants.S3_ACTION_DELETE_OBJECT_VERSION_TAGGING
|
||||
}
|
||||
if errCode := s3a.recheckTagConditionedPolicyWithLookup(r, bucket, object, versionId, deleteAction, "DeleteObjectTaggingHandler"); errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
@@ -335,7 +363,11 @@ 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 {
|
||||
deleteAction := s3_constants.S3_ACTION_DELETE_OBJECT_TAGGING
|
||||
if versionId != "" {
|
||||
deleteAction = s3_constants.S3_ACTION_DELETE_OBJECT_VERSION_TAGGING
|
||||
}
|
||||
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, deleteAction, entry.Extended, "DeleteObjectTaggingHandler"); errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -40,3 +40,33 @@ func TestParseAndValidateRangeRejectsMalformedNumericOffsets(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadObjectRange(t *testing.T) {
|
||||
s3a, f := newAnonymousReadTestServer(t)
|
||||
f.entries["/buckets/b/o"] = anonymousReadEntry(anonymousReadGrant("READ"))
|
||||
for _, tc := range []struct {
|
||||
rangeHeader, contentRange, contentLength string
|
||||
code int
|
||||
}{
|
||||
{"", "", "11", http.StatusOK},
|
||||
{"bytes=1-4", "bytes 1-4/11", "4", http.StatusPartialContent},
|
||||
{"bytes=-3", "bytes 8-10/11", "3", http.StatusPartialContent},
|
||||
{"bytes=5-", "bytes 5-10/11", "6", http.StatusPartialContent},
|
||||
{"bytes=20-", "bytes */11", "", http.StatusRequestedRangeNotSatisfiable},
|
||||
} {
|
||||
headers := map[string]string{}
|
||||
if tc.rangeHeader != "" {
|
||||
headers["Range"] = tc.rangeHeader
|
||||
}
|
||||
rr := serveAnonymousRead(s3a, http.MethodHead, "o", "", headers)
|
||||
if rr.Code != tc.code {
|
||||
t.Errorf("Range %q: status %d, want %d", tc.rangeHeader, rr.Code, tc.code)
|
||||
}
|
||||
if got := rr.Header().Get("Content-Range"); got != tc.contentRange {
|
||||
t.Errorf("Range %q: Content-Range %q, want %q", tc.rangeHeader, got, tc.contentRange)
|
||||
}
|
||||
if got := rr.Header().Get("Content-Length"); tc.contentLength != "" && got != tc.contentLength {
|
||||
t.Errorf("Range %q: Content-Length %q, want %q", tc.rangeHeader, got, tc.contentLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,6 +678,21 @@ func (s3a *S3ApiServer) recheckPolicyWithObjectEntry(r *http.Request, bucket, ob
|
||||
return errCode
|
||||
}
|
||||
|
||||
// checkVersionReadGrant enforces the version read grant AWS requires alongside
|
||||
// a version-scoped action such as s3:GetObjectVersionAttributes. The check runs
|
||||
// on a request shaped like a plain versioned GET so the s3:GetObjectVersion
|
||||
// action resolves from the request instead of the caller's subresource, and a
|
||||
// legacy Read grant still applies.
|
||||
func (s3a *S3ApiServer) checkVersionReadGrant(r *http.Request, bucket, object, versionId string, objectEntry map[string][]byte) s3err.ErrorCode {
|
||||
versionReadReq := objectKeyPolicyRequest(r, http.MethodGet, bucket, object, versionId)
|
||||
if identity, ok := GetIdentityFromContext(r).(*Identity); ok && identity != nil {
|
||||
if errCode := s3a.iam.VerifyActionPermission(versionReadReq, identity, ACTION_READ, bucket, object); errCode != s3err.ErrNone {
|
||||
return errCode
|
||||
}
|
||||
}
|
||||
return s3a.recheckPolicyWithObjectEntry(versionReadReq, bucket, object, ACTION_READ, objectEntry, "checkVersionReadGrant")
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -459,6 +459,70 @@ func isAuthError(err error) bool {
|
||||
return errors.As(err, &authErr) || errors.Is(err, ErrAccessDenied)
|
||||
}
|
||||
|
||||
// authorizeAbsentTarget reports whether the caller may learn the target
|
||||
// resolved to nothing, so a caller without a grant gets the same response a
|
||||
// denied existing target would give. The bucket named by the target still
|
||||
// applies whatever metadata and policy load; with nothing readable to
|
||||
// evaluate, only an identity-level grant on the operation, the admin, or a
|
||||
// default-allow deployment passes.
|
||||
func (h *S3TablesHandler) authorizeAbsentTarget(r *http.Request, filerClient FilerClient, operation, bucketName, namespace, name, resourceARN string) bool {
|
||||
principal := h.getAccountID(r)
|
||||
if principal == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
var bucketOwner, bucketPolicy string
|
||||
var bucketTags map[string]string
|
||||
if bucketName != "" {
|
||||
bucketPath := GetTableBucketPath(bucketName)
|
||||
_ = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
if data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata); err == nil {
|
||||
var meta tableBucketMetadata
|
||||
if json.Unmarshal(data, &meta) == nil {
|
||||
bucketOwner = meta.OwnerAccountID
|
||||
}
|
||||
}
|
||||
if policy, err := h.readPolicy(r, client, bucketPath); err == nil {
|
||||
bucketPolicy = policy
|
||||
}
|
||||
bucketTags, _ = h.readTags(r.Context(), client, bucketPath)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
bucketARN := h.generateTableBucketARN(bucketOwner, bucketName)
|
||||
// Judge the target by the canonical ARN the surviving bucket resolves to:
|
||||
// the request ARN's account and region are the caller's to choose and must
|
||||
// not decide whether a policy Deny applies.
|
||||
canonicalARN := resourceARN
|
||||
if bucketOwner != "" {
|
||||
if name != "" {
|
||||
canonicalARN = h.generateTableARN(bucketOwner, bucketName, namespace+"/"+name)
|
||||
} else {
|
||||
canonicalARN = bucketARN
|
||||
}
|
||||
}
|
||||
ctx := &PolicyContext{
|
||||
TrustedIssuers: h.trustedIssuers(r),
|
||||
IsLocalAccountID: h.localAccountIDs,
|
||||
TableBucketName: bucketName,
|
||||
Namespace: namespace,
|
||||
TableName: name,
|
||||
TableBucketTags: bucketTags,
|
||||
IdentityActions: getIdentityActions(r),
|
||||
DefaultAllow: h.defaultAllowFor(r),
|
||||
}
|
||||
if resourceARN != "" &&
|
||||
matchesDenyStatement(operation, principal, bucketPolicy, canonicalARN, ctx) {
|
||||
return false
|
||||
}
|
||||
if CheckTableOrBucketPermission(operation, principal, "", "", canonicalARN,
|
||||
bucketOwner, bucketPolicy, bucketARN, ctx) {
|
||||
return true
|
||||
}
|
||||
return bucketOwner == "" && checkPermission(operation, principal, "", "", resourceARN, ctx)
|
||||
}
|
||||
|
||||
func (h *S3TablesHandler) readTags(ctx context.Context, client filer_pb.SeaweedFilerClient, path string) (map[string]string, error) {
|
||||
data, err := h.getExtendedAttribute(ctx, client, path, ExtendedKeyTags)
|
||||
if err != nil {
|
||||
|
||||
@@ -58,7 +58,7 @@ func (h *S3TablesHandler) handleGetTableBucket(w http.ResponseWriter, r *http.Re
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, filer_pb.ErrNotFound) {
|
||||
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
|
||||
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
|
||||
} else {
|
||||
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to get table bucket: %v", err))
|
||||
|
||||
@@ -3,6 +3,7 @@ package s3tables
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
@@ -211,3 +212,165 @@ func TestDeniedTableWritesDoNotRevealViews(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A caller without a grant must get the same answer whether a list target
|
||||
// exists or not, and whether the bucket behind it exists at all.
|
||||
func TestAbsentListTargetsMatchDeniedAnswers(t *testing.T) {
|
||||
existing, manager := startRenameManager(t)
|
||||
missing := s3tablestest.Start(t)
|
||||
manager.SetTrusted(false)
|
||||
manager.SetDefaultAllow(false)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fs *s3tablestest.MemFiler
|
||||
operation string
|
||||
denied interface{}
|
||||
request interface{}
|
||||
}{
|
||||
{"ListTables missing namespace", existing, "ListTables",
|
||||
&ListTablesRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}},
|
||||
&ListTablesRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ghost"}}},
|
||||
{"ListTables missing bucket", missing, "ListTables",
|
||||
&ListTablesRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}},
|
||||
&ListTablesRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}}},
|
||||
{"ListTables missing bucket, all namespaces", missing, "ListTables",
|
||||
&ListTablesRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}},
|
||||
&ListTablesRequest{TableBucketARN: mustBucketARN(t)}},
|
||||
{"ListViews missing namespace", existing, "ListViews",
|
||||
&ListViewsRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}},
|
||||
&ListViewsRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ghost"}}},
|
||||
{"CreateNamespace missing bucket", missing, "CreateNamespace",
|
||||
&CreateNamespaceRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"newns"}},
|
||||
&CreateNamespaceRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"newns"}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
denied := runUnauthorizedRequest(t, manager, existing, tc.operation, tc.denied)
|
||||
assert.Equal(t, 403, denied.status)
|
||||
got := runUnauthorizedRequest(t, manager, tc.fs, tc.operation, tc.request)
|
||||
assert.Equal(t, denied, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A denied tag operation and the same operation on a missing table must answer
|
||||
// alike; a granted caller still gets the truthful not-found.
|
||||
func TestAbsentTagTargetsMatchDeniedAnswers(t *testing.T) {
|
||||
existing, manager := startRenameManager(t)
|
||||
manager.SetTrusted(false)
|
||||
manager.SetDefaultAllow(false)
|
||||
|
||||
existingARN, err := BuildTableARN(DefaultRegion, DefaultAccountID, renameTestBucket, "ns", "t")
|
||||
require.NoError(t, err)
|
||||
missingARN, err := BuildTableARN(DefaultRegion, DefaultAccountID, renameTestBucket, "ns", "ghost")
|
||||
require.NoError(t, err)
|
||||
|
||||
requestFor := func(operation, arn string) interface{} {
|
||||
switch operation {
|
||||
case "TagResource":
|
||||
return &TagResourceRequest{ResourceARN: arn, Tags: map[string]string{"k": "v"}}
|
||||
case "UntagResource":
|
||||
return &UntagResourceRequest{ResourceARN: arn, TagKeys: []string{"k"}}
|
||||
}
|
||||
return &ListTagsForResourceRequest{ResourceARN: arn}
|
||||
}
|
||||
|
||||
for _, operation := range []string{"TagResource", "ListTagsForResource", "UntagResource"} {
|
||||
t.Run(operation, func(t *testing.T) {
|
||||
denied := runUnauthorizedRequest(t, manager, existing, operation, requestFor(operation, existingARN))
|
||||
assert.Equal(t, 403, denied.status)
|
||||
got := runUnauthorizedRequest(t, manager, existing, operation, requestFor(operation, missingARN))
|
||||
assert.Equal(t, denied, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A directory that holds a plain S3 bucket name is not a table bucket: every
|
||||
// caller gets the same 404 an absent name gets, not a 500 that reveals the
|
||||
// name resolves in the filer.
|
||||
func TestPlainBucketReadsAsAbsent(t *testing.T) {
|
||||
existing, manager := startRenameManager(t)
|
||||
manager.SetTrusted(false)
|
||||
manager.SetDefaultAllow(false)
|
||||
existing.Put(TablesPath, "plainbkt", nil)
|
||||
|
||||
plainARN, err := BuildBucketARN(DefaultRegion, DefaultAccountID, "plainbkt")
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tc := range []struct {
|
||||
operation string
|
||||
request interface{}
|
||||
}{
|
||||
{"GetTableBucket", &GetTableBucketRequest{TableBucketARN: plainARN}},
|
||||
{"ListNamespaces", &ListNamespacesRequest{TableBucketARN: plainARN}},
|
||||
} {
|
||||
t.Run(tc.operation, func(t *testing.T) {
|
||||
got := runUnauthorizedRequest(t, manager, existing, tc.operation, tc.request)
|
||||
assert.Equal(t, 404, got.status)
|
||||
assert.Equal(t, ErrCodeNoSuchBucket, got.body.Type)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A caller with an identity-level grant still learns that a name is absent.
|
||||
func TestGrantedCallerLearnsAbsence(t *testing.T) {
|
||||
missing := s3tablestest.Start(t)
|
||||
manager := NewManager()
|
||||
manager.SetTrusted(false)
|
||||
manager.SetDefaultAllow(false)
|
||||
|
||||
got := runRequestAs(t, manager, missing, "ListTables", &ListTablesRequest{
|
||||
TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}},
|
||||
[]string{"s3tables:ListTables"})
|
||||
assert.Equal(t, 200, got.status)
|
||||
|
||||
missingTableARN, err := BuildTableARN(DefaultRegion, DefaultAccountID, renameTestBucket, "ns", "ghost")
|
||||
require.NoError(t, err)
|
||||
got = runRequestAs(t, manager, missing, "ListTagsForResource",
|
||||
&ListTagsForResourceRequest{ResourceARN: missingTableARN},
|
||||
[]string{"s3tables:ListTagsForResource"})
|
||||
assert.Equal(t, 404, got.status)
|
||||
assert.Equal(t, ErrCodeNoSuchTable, got.body.Type)
|
||||
}
|
||||
|
||||
// A request ARN that names a foreign account still resolves to the local
|
||||
// names, so the absent-target answer must match the denial the canonical ARN
|
||||
// gets; otherwise a reformatted ARN restores the existence oracle.
|
||||
func TestAbsentTagTargetDenySurvivesForeignAccountARN(t *testing.T) {
|
||||
fs, manager := startRenameManager(t)
|
||||
manager.SetTrusted(false)
|
||||
manager.SetDefaultAllow(false)
|
||||
|
||||
bucketMeta, _ := json.Marshal(tableBucketMetadata{Name: renameTestBucket, OwnerAccountID: DefaultAccountID})
|
||||
fs.Put(TablesPath, renameTestBucket, map[string][]byte{
|
||||
ExtendedKeyTableBucket: []byte("{}"),
|
||||
ExtendedKeyMetadata: bucketMeta,
|
||||
ExtendedKeyPolicy: []byte(fmt.Sprintf(
|
||||
`{"Statement":[{"Effect":"Deny","Principal":"*","Action":"s3tables:*","Resource":%q}]}`,
|
||||
mustBucketARN(t)+"/table/*")),
|
||||
})
|
||||
|
||||
foreignARN := func(table string) string {
|
||||
return fmt.Sprintf("arn:aws:s3tables:%s:999999999999:bucket/%s/table/ns/%s",
|
||||
DefaultRegion, renameTestBucket, table)
|
||||
}
|
||||
requestFor := func(operation, arn string) interface{} {
|
||||
switch operation {
|
||||
case "TagResource":
|
||||
return &TagResourceRequest{ResourceARN: arn, Tags: map[string]string{"k": "v"}}
|
||||
case "UntagResource":
|
||||
return &UntagResourceRequest{ResourceARN: arn, TagKeys: []string{"k"}}
|
||||
}
|
||||
return &ListTagsForResourceRequest{ResourceARN: arn}
|
||||
}
|
||||
|
||||
for _, operation := range []string{"TagResource", "ListTagsForResource", "UntagResource"} {
|
||||
t.Run(operation, func(t *testing.T) {
|
||||
actions := []string{"s3tables:" + operation}
|
||||
denied := runRequestAs(t, manager, fs, operation, requestFor(operation, foreignARN("t")), actions)
|
||||
assert.Equal(t, 403, denied.status)
|
||||
got := runRequestAs(t, manager, fs, operation, requestFor(operation, foreignARN("ghost")), actions)
|
||||
assert.Equal(t, denied, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,10 @@ func (h *S3TablesHandler) handleCreateNamespace(w http.ResponseWriter, r *http.R
|
||||
return ErrAccessDenied
|
||||
}
|
||||
if errors.Is(err, filer_pb.ErrNotFound) {
|
||||
if !h.authorizeAbsentTarget(r, filerClient, "CreateNamespace", bucketName, namespaceName, "", "") {
|
||||
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to create namespace in this bucket")
|
||||
return ErrAccessDenied
|
||||
}
|
||||
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
|
||||
} else {
|
||||
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to check table bucket: %v", err))
|
||||
@@ -481,7 +485,7 @@ func (h *S3TablesHandler) handleListNamespaces(w http.ResponseWriter, r *http.Re
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, filer_pb.ErrNotFound) {
|
||||
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
|
||||
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
|
||||
} else {
|
||||
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to list namespaces: %v", err))
|
||||
|
||||
@@ -659,6 +659,9 @@ func (h *S3TablesHandler) handleTagResource(w http.ResponseWriter, r *http.Reque
|
||||
// Read metadata for ownership check
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMetadata)
|
||||
if err != nil {
|
||||
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
|
||||
return h.absentTagTargetError(r, client, "TagResource", "not authorized to tag resource", req.ResourceARN, rType)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -793,6 +796,9 @@ func (h *S3TablesHandler) handleListTagsForResource(w http.ResponseWriter, r *ht
|
||||
// Read metadata for ownership check
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMetadata)
|
||||
if err != nil {
|
||||
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
|
||||
return h.absentTagTargetError(r, client, "ListTagsForResource", "not authorized to list tags for resource", req.ResourceARN, rType)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -913,6 +919,9 @@ func (h *S3TablesHandler) handleUntagResource(w http.ResponseWriter, r *http.Req
|
||||
// Read metadata for ownership check
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMetadata)
|
||||
if err != nil {
|
||||
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
|
||||
return h.absentTagTargetError(r, client, "UntagResource", "not authorized to untag resource", req.ResourceARN, rType)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1017,6 +1026,22 @@ func (h *S3TablesHandler) handleUntagResource(w http.ResponseWriter, r *http.Req
|
||||
return nil
|
||||
}
|
||||
|
||||
// absentTagTargetError resolves a tag operation when the resource's catalog
|
||||
// metadata did not load: a caller the operation would refuse gets its denial,
|
||||
// everyone else the same not-found an absent name gets.
|
||||
func (h *S3TablesHandler) absentTagTargetError(r *http.Request, client filer_pb.SeaweedFilerClient, operation, deniedMessage, resourceARN string, rType ResourceType) error {
|
||||
var bucketName, namespace, name string
|
||||
if rType == ResourceTypeTable {
|
||||
bucketName, namespace, name, _ = parseTableFromARN(resourceARN)
|
||||
} else {
|
||||
bucketName, _ = parseBucketNameFromARN(resourceARN)
|
||||
}
|
||||
if !h.authorizeAbsentTarget(r, &singleClient{client}, operation, bucketName, namespace, name, resourceARN) {
|
||||
return NewAuthError(operation, h.getAccountID(r), deniedMessage)
|
||||
}
|
||||
return filer_pb.ErrNotFound
|
||||
}
|
||||
|
||||
// resolveResourcePath determines the resource path and extended attribute key from a resource ARN
|
||||
func (h *S3TablesHandler) resolveResourcePath(resourceARN string) (path string, key string, rType ResourceType, err error) {
|
||||
// Try parsing as table ARN first
|
||||
|
||||
@@ -515,11 +515,14 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
|
||||
bucketTags := map[string]string{}
|
||||
|
||||
// Fetch namespace metadata and policy
|
||||
targetAbsent := false
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyMetadata)
|
||||
if err != nil {
|
||||
return err // Not Found handled by caller
|
||||
}
|
||||
if err := json.Unmarshal(data, &nsMeta); err != nil {
|
||||
if !errors.Is(err, filer_pb.ErrNotFound) && !errors.Is(err, ErrAttributeNotFound) {
|
||||
return err
|
||||
}
|
||||
targetAbsent = true
|
||||
} else if err := json.Unmarshal(data, &nsMeta); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -527,7 +530,7 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
|
||||
policyData, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyPolicy)
|
||||
if err == nil {
|
||||
namespacePolicy = string(policyData)
|
||||
} else if !errors.Is(err, ErrAttributeNotFound) {
|
||||
} else if !errors.Is(err, ErrAttributeNotFound) && !errors.Is(err, filer_pb.ErrNotFound) {
|
||||
return fmt.Errorf("failed to fetch namespace policy: %w", err)
|
||||
}
|
||||
|
||||
@@ -537,6 +540,8 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
|
||||
if err := json.Unmarshal(data, &bucketMeta); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal bucket metadata: %w", err)
|
||||
}
|
||||
} else if errors.Is(err, filer_pb.ErrNotFound) {
|
||||
targetAbsent = true
|
||||
} else if !errors.Is(err, ErrAttributeNotFound) {
|
||||
return fmt.Errorf("failed to fetch bucket metadata: %w", err)
|
||||
}
|
||||
@@ -544,15 +549,24 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
|
||||
policyData, err = h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyPolicy)
|
||||
if err == nil {
|
||||
bucketPolicy = string(policyData)
|
||||
} else if !errors.Is(err, ErrAttributeNotFound) {
|
||||
} else if !errors.Is(err, ErrAttributeNotFound) && !errors.Is(err, filer_pb.ErrNotFound) {
|
||||
return fmt.Errorf("failed to fetch bucket policy: %w", err)
|
||||
}
|
||||
if tags, err := h.readTags(r.Context(), client, bucketPath); err != nil {
|
||||
return fmt.Errorf("failed to read bucket tags: %w", err)
|
||||
if !errors.Is(err, filer_pb.ErrNotFound) {
|
||||
return fmt.Errorf("failed to read bucket tags: %w", err)
|
||||
}
|
||||
} else if tags != nil {
|
||||
bucketTags = tags
|
||||
}
|
||||
|
||||
if targetAbsent {
|
||||
if !h.authorizeAbsentTarget(r, &singleClient{client}, "ListTables", bucketName, namespaceName, "", "") {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
return filer_pb.ErrNotFound
|
||||
}
|
||||
|
||||
bucketARN := h.generateTableBucketARN(bucketMeta.OwnerAccountID, bucketName)
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckTableOrBucketPermission("ListTables", accountID,
|
||||
@@ -583,7 +597,13 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
|
||||
// Fetch bucket metadata and policy
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch bucket metadata: %w", err)
|
||||
if !errors.Is(err, filer_pb.ErrNotFound) && !errors.Is(err, ErrAttributeNotFound) {
|
||||
return fmt.Errorf("failed to fetch bucket metadata: %w", err)
|
||||
}
|
||||
if !h.authorizeAbsentTarget(r, &singleClient{client}, "ListTables", bucketName, "", "", "") {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
return filer_pb.ErrNotFound
|
||||
}
|
||||
if err := json.Unmarshal(data, &bucketMeta); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal bucket metadata: %w", err)
|
||||
|
||||
@@ -279,7 +279,13 @@ func (h *S3TablesHandler) handleListViews(w http.ResponseWriter, r *http.Request
|
||||
var nsMeta namespaceMetadata
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyMetadata)
|
||||
if err != nil {
|
||||
return err
|
||||
if !errors.Is(err, filer_pb.ErrNotFound) && !errors.Is(err, ErrAttributeNotFound) {
|
||||
return err
|
||||
}
|
||||
if !h.authorizeAbsentTarget(r, &singleClient{client}, "ListViews", bucketName, namespaceName, "", "") {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
return filer_pb.ErrNotFound
|
||||
}
|
||||
if err := json.Unmarshal(data, &nsMeta); err != nil {
|
||||
return err
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
// - metadata/v*.metadata.json (table metadata)
|
||||
// - metadata/snap-*.avro (snapshot manifest lists)
|
||||
// - metadata/*.avro (manifest files)
|
||||
// - metadata/*.stats, metadata/*.puffin (statistics files)
|
||||
// - data/*.parquet, *.orc, *.avro, *.puffin (data files)
|
||||
|
||||
const uuidPattern = `[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}`
|
||||
@@ -68,6 +69,9 @@ var (
|
||||
// whose naming we can't anticipate across engines and versions.
|
||||
regexp.MustCompile(`^[A-Za-z0-9._-]+\.avro$`),
|
||||
regexp.MustCompile(`^[A-Za-z0-9._-]+\.metadata\.json$`),
|
||||
// Table and partition statistics files listed in the table metadata
|
||||
// are Puffin files; engines name them *.stats or *.puffin.
|
||||
regexp.MustCompile(`^[A-Za-z0-9._-]+\.puffin$`),
|
||||
}
|
||||
|
||||
// Patterns for valid data files
|
||||
|
||||
@@ -45,6 +45,10 @@ func TestIcebergLayoutValidator_AcceptsRealWorldManifestNames(t *testing.T) {
|
||||
"trino/iceberg stats file",
|
||||
"metadata/table.stats",
|
||||
},
|
||||
{
|
||||
"puffin statistics file",
|
||||
"metadata/20260928-1-0199a3c2-7d1e-7c55-9a0e-3b1f2c4d5e6f.puffin",
|
||||
},
|
||||
}
|
||||
|
||||
v := NewIcebergLayoutValidator()
|
||||
@@ -70,6 +74,8 @@ func TestIcebergLayoutValidator_RejectsClearlyBadMetadataNames(t *testing.T) {
|
||||
{"subdirectory under metadata is not allowed", "metadata/sub/file.avro"},
|
||||
{"top-level dir other than metadata or data", "garbage/file.avro"},
|
||||
{"catch-all anchors must reject trailing extension", "metadata/file.avro.txt"},
|
||||
{"puffin with a trailing extension", "metadata/stats.puffin.txt"},
|
||||
{"puffin in a subdirectory under metadata", "metadata/sub/stats.puffin"},
|
||||
}
|
||||
|
||||
v := NewIcebergLayoutValidator()
|
||||
@@ -137,3 +143,31 @@ func TestValidateFilePathAcceptsLanceLayout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Statistics files are referenced from the table metadata and kept by orphan
|
||||
// cleanup; the upload door has to admit them in the table's metadata/, and
|
||||
// only there.
|
||||
func TestTableBucketFileValidator_PuffinStatisticsOnlyInTableMetadata(t *testing.T) {
|
||||
v := NewTableBucketFileValidator()
|
||||
accepted := []string{
|
||||
TablesPath + "/cat/ns/t/metadata/stats-1.puffin",
|
||||
TablesPath + "/cat/ns/t/metadata/table.stats",
|
||||
TablesPath + "/cat/ns/t/data/0199a3c2-deletes.puffin",
|
||||
}
|
||||
for _, p := range accepted {
|
||||
if err := v.ValidateTableBucketUpload(p); err != nil {
|
||||
t.Errorf("ValidateTableBucketUpload(%q) = %v, want nil", p, err)
|
||||
}
|
||||
}
|
||||
rejected := []string{
|
||||
TablesPath + "/cat/ns/t/stats-1.puffin",
|
||||
TablesPath + "/cat/ns/t/other/stats-1.puffin",
|
||||
TablesPath + "/cat/ns/t/metadata/sub/stats-1.puffin",
|
||||
TablesPath + "/cat/ns/t/metadata/notes.txt",
|
||||
}
|
||||
for _, p := range rejected {
|
||||
if err := v.ValidateTableBucketUpload(p); err == nil {
|
||||
t.Errorf("ValidateTableBucketUpload(%q) = nil, want an error", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,22 @@ func (vs *VolumeServer) lookupRaftLeaderMaster(ctx context.Context) (pb.ServerAd
|
||||
return leader, nil
|
||||
}
|
||||
|
||||
// refreshLeaderFromPeers publishes the leader the configured masters report,
|
||||
// so lookups stop going to a master the heartbeat just lost while the loop
|
||||
// retries. Without an answer within timeout no leader is published: the lost
|
||||
// master is dead or no longer leader, and a follower answers lookups from its
|
||||
// own cache rather than refusing them, so failing fast is the safer answer.
|
||||
func (vs *VolumeServer) refreshLeaderFromPeers(timeout time.Duration) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
leader, err := vs.lookupRaftLeaderMaster(ctx)
|
||||
if err != nil {
|
||||
vs.setCurrentMaster("")
|
||||
return
|
||||
}
|
||||
vs.setCurrentMaster(leader)
|
||||
}
|
||||
|
||||
func isContextDoneErr(err error) bool {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
@@ -74,7 +90,10 @@ func (vs *VolumeServer) getCurrentMaster() pb.ServerAddress {
|
||||
}
|
||||
|
||||
// setCurrentMaster updates vs.currentMaster under a write lock. The
|
||||
// heartbeat goroutine calls this whenever it (re)connects to a master.
|
||||
// heartbeat goroutine calls this only with a master known to be the leader:
|
||||
// one that answered a heartbeat as leader, or the one a follower named, on
|
||||
// the heartbeat stream or, after the stream failed, in its configuration.
|
||||
// After a failed stream with no leader named, it is cleared to "".
|
||||
func (vs *VolumeServer) setCurrentMaster(master pb.ServerAddress) {
|
||||
vs.currentMasterLock.Lock()
|
||||
vs.currentMaster = master
|
||||
@@ -122,7 +141,6 @@ func (vs *VolumeServer) heartbeat() {
|
||||
time.Sleep(3 * time.Second)
|
||||
master = newLeader
|
||||
}
|
||||
vs.store.MasterAddress = master
|
||||
newLeader, err = vs.doHeartbeatWithRetry(master, grpcDialOption, vs.pulsePeriod, duplicateRetryCount)
|
||||
if err != nil {
|
||||
glog.V(0).Infof("heartbeat to %s error: %v", master, err)
|
||||
@@ -136,12 +154,15 @@ func (vs *VolumeServer) heartbeat() {
|
||||
} else {
|
||||
// Regular error, reset duplicate retry count
|
||||
duplicateRetryCount = 0
|
||||
time.Sleep(vs.pulsePeriod)
|
||||
// The lookup spends part of the retry delay rather than
|
||||
// adding to it.
|
||||
retryAt := time.Now().Add(vs.pulsePeriod)
|
||||
vs.refreshLeaderFromPeers(vs.pulsePeriod)
|
||||
time.Sleep(time.Until(retryAt))
|
||||
}
|
||||
|
||||
stats.VolumeServerMasterDisconnections.WithLabelValues(master.String()).Inc()
|
||||
newLeader = ""
|
||||
vs.store.MasterAddress = ""
|
||||
} else {
|
||||
// Successful connection, reset retry count
|
||||
duplicateRetryCount = 0
|
||||
@@ -180,17 +201,42 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp
|
||||
return "", err
|
||||
}
|
||||
glog.V(0).Infof("Heartbeat to: %v", masterAddress)
|
||||
vs.setCurrentMaster(masterAddress)
|
||||
|
||||
doneChan := make(chan error, 1)
|
||||
// The receiver asks for a fresh heartbeat here rather than sending it,
|
||||
// since a gRPC stream allows only one sender at a time.
|
||||
resendChan := make(chan struct{}, 1)
|
||||
|
||||
go func() {
|
||||
confirmedLeader := false
|
||||
for {
|
||||
in, err := stream.Recv()
|
||||
if err != nil {
|
||||
doneChan <- err
|
||||
return
|
||||
}
|
||||
// A reply names the leader or comes from it, so it settles where
|
||||
// lookups go before anything else on the reply is handled: a
|
||||
// follower reply carries no other fields, and a leader stays the
|
||||
// lookup target even when it rejects this registration.
|
||||
if in.GetLeader() != "" {
|
||||
// Only a follower names the leader. Publish the named leader right
|
||||
// away, so lookups stop landing on this follower while the loop
|
||||
// reconnects.
|
||||
leader := pb.ServerAddress(in.GetLeader())
|
||||
if !masterAddress.Equals(leader) {
|
||||
glog.V(0).Infof("Volume Server found a new master newLeader: %v instead of %v", leader, masterAddress)
|
||||
vs.setCurrentMaster(leader)
|
||||
newLeader = leader
|
||||
doneChan <- nil
|
||||
return
|
||||
}
|
||||
} else if !confirmedLeader {
|
||||
// The leader answers every new heartbeat stream without naming a
|
||||
// leader, so this peer is the one lookups should go to.
|
||||
confirmedLeader = true
|
||||
vs.setCurrentMaster(masterAddress)
|
||||
}
|
||||
if len(in.DuplicatedUuids) > 0 {
|
||||
var duplicateDir []string
|
||||
for _, loc := range vs.store.Locations {
|
||||
@@ -231,9 +277,9 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp
|
||||
}
|
||||
if volumeOptsChanged {
|
||||
if vs.store.MaybeAdjustVolumeMax() {
|
||||
if err = stream.Send(vs.store.CollectHeartbeat()); err != nil {
|
||||
glog.V(0).Infof("Volume Server Failed to talk with master %s: %v", vs.getCurrentMaster(), err)
|
||||
return
|
||||
select {
|
||||
case resendChan <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,15 +290,6 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp
|
||||
glog.V(0).Infof("master %s asked for the full volume list", masterAddress)
|
||||
vs.store.RequestFullVolumeList()
|
||||
}
|
||||
if in.GetLeader() != "" {
|
||||
current := vs.getCurrentMaster()
|
||||
if !current.Equals(pb.ServerAddress(in.GetLeader())) {
|
||||
glog.V(0).Infof("Volume Server found a new master newLeader: %v instead of %v", in.GetLeader(), current)
|
||||
newLeader = pb.ServerAddress(in.GetLeader())
|
||||
doneChan <- nil
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -359,6 +396,11 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp
|
||||
glog.V(0).Infof("Volume Server Failed to update to master %s: %v", masterAddress, err)
|
||||
return "", err
|
||||
}
|
||||
case <-resendChan:
|
||||
if err = stream.Send(vs.store.CollectHeartbeat()); err != nil {
|
||||
glog.V(0).Infof("Volume Server Failed to talk with master %s: %v", masterAddress, err)
|
||||
return "", err
|
||||
}
|
||||
case <-volumeTickChan.C:
|
||||
glog.V(4).Infof("volume server %s:%d heartbeat", vs.store.Ip, vs.store.Port)
|
||||
vs.store.MaybeAdjustVolumeMax()
|
||||
|
||||
@@ -4,11 +4,17 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/stats"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
@@ -60,3 +66,279 @@ func startFakeMasterServerForLeaderLookup(t *testing.T, srv master_pb.SeaweedSer
|
||||
_, port, _ := net.SplitHostPort(lis.Addr().String())
|
||||
return pb.ServerAddress(fmt.Sprintf("127.0.0.1:0.%s", port))
|
||||
}
|
||||
|
||||
// fakeHeartbeatMaster answers SendHeartbeat the way a master does: a follower
|
||||
// names the leader on every heartbeat, the leader answers the first one with
|
||||
// its volume options and never names itself.
|
||||
type fakeHeartbeatMaster struct {
|
||||
master_pb.UnimplementedSeaweedServer
|
||||
leader string // set on a follower
|
||||
release chan struct{} // a follower waits on it before answering
|
||||
heartbeats chan *master_pb.Heartbeat
|
||||
}
|
||||
|
||||
func (m *fakeHeartbeatMaster) SendHeartbeat(stream master_pb.Seaweed_SendHeartbeatServer) error {
|
||||
answered := false
|
||||
for {
|
||||
hb, err := stream.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.heartbeats != nil {
|
||||
m.heartbeats <- hb
|
||||
}
|
||||
if m.leader != "" {
|
||||
if m.release != nil {
|
||||
<-m.release
|
||||
}
|
||||
if err := stream.Send(&master_pb.HeartbeatResponse{Leader: m.leader}); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !answered {
|
||||
answered = true
|
||||
if err := stream.Send(&master_pb.HeartbeatResponse{VolumeSizeLimit: 1 << 30}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newHeartbeatTestVolumeServer(t *testing.T) *VolumeServer {
|
||||
t.Helper()
|
||||
store := storage.NewStore(nil, "127.0.0.1", 8080, 18080, "http://127.0.0.1:8080", "store-id",
|
||||
[]string{t.TempDir()},
|
||||
[]int32{0},
|
||||
[]util.MinFreeSpace{{}},
|
||||
"",
|
||||
storage.NeedleMapInMemory,
|
||||
[]types.DiskType{types.HardDriveType},
|
||||
nil,
|
||||
3,
|
||||
stats.DefaultDiskIOProbeConfig(),
|
||||
)
|
||||
t.Cleanup(store.Close)
|
||||
vs := &VolumeServer{
|
||||
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
store: store,
|
||||
stopChan: make(chan bool),
|
||||
isHeartbeating: true,
|
||||
}
|
||||
store.MasterFn = vs.getCurrentMaster
|
||||
return vs
|
||||
}
|
||||
|
||||
func waitForCurrentMaster(t *testing.T, vs *VolumeServer, want pb.ServerAddress) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !vs.getCurrentMaster().Equals(want) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("currentMaster = %q, want %q", vs.getCurrentMaster(), want)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// heartbeatRun is one doHeartbeatWithRetry call running in the background.
|
||||
type heartbeatRun struct {
|
||||
done chan heartbeatResult // receives the call's result once
|
||||
exited chan struct{}
|
||||
stop func()
|
||||
}
|
||||
|
||||
type heartbeatResult struct {
|
||||
newLeader pb.ServerAddress
|
||||
err error
|
||||
}
|
||||
|
||||
// startHeartbeat runs doHeartbeatWithRetry against master until it returns or
|
||||
// is stopped. Its cleanup stops the heartbeat and waits for it, and runs before
|
||||
// the cleanups registered earlier, so a failed assertion cannot leave the stream
|
||||
// open for the fake master's GracefulStop to wait on.
|
||||
func startHeartbeat(t *testing.T, vs *VolumeServer, master pb.ServerAddress) *heartbeatRun {
|
||||
t.Helper()
|
||||
var stopOnce sync.Once
|
||||
run := &heartbeatRun{
|
||||
done: make(chan heartbeatResult, 1),
|
||||
exited: make(chan struct{}),
|
||||
stop: func() { stopOnce.Do(func() { close(vs.stopChan) }) },
|
||||
}
|
||||
go func() {
|
||||
defer close(run.exited)
|
||||
newLeader, err := vs.doHeartbeatWithRetry(master, vs.grpcDialOption, time.Hour, 0)
|
||||
run.done <- heartbeatResult{newLeader, err}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
run.stop()
|
||||
select {
|
||||
case <-run.exited:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("heartbeat did not stop")
|
||||
}
|
||||
})
|
||||
return run
|
||||
}
|
||||
|
||||
// A follower must never become the master lookups go to: until it names the
|
||||
// leader the previous one stays, and then the named leader takes over before
|
||||
// the heartbeat loop has reconnected.
|
||||
func TestHeartbeatToFollowerPublishesNamedLeaderOnly(t *testing.T) {
|
||||
const leader = pb.ServerAddress("leader.example.com:9333.19333")
|
||||
follower := &fakeHeartbeatMaster{
|
||||
leader: string(leader),
|
||||
release: make(chan struct{}),
|
||||
heartbeats: make(chan *master_pb.Heartbeat, 8),
|
||||
}
|
||||
followerAddr := startFakeMasterServerForLeaderLookup(t, follower)
|
||||
// cleanups run last-in first-out, so a failed assertion still lets the
|
||||
// follower answer before its server stops gracefully
|
||||
var releaseOnce sync.Once
|
||||
release := func() { releaseOnce.Do(func() { close(follower.release) }) }
|
||||
t.Cleanup(release)
|
||||
|
||||
vs := newHeartbeatTestVolumeServer(t)
|
||||
previous := pb.ServerAddress("previous.example.com:9333.19333")
|
||||
vs.setCurrentMaster(previous)
|
||||
run := startHeartbeat(t, vs, followerAddr)
|
||||
|
||||
select {
|
||||
case <-follower.heartbeats:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("follower got no heartbeat")
|
||||
}
|
||||
if got := vs.getCurrentMaster(); got != previous {
|
||||
t.Fatalf("currentMaster = %q while connected to follower %q, want unchanged %q", string(got), string(followerAddr), string(previous))
|
||||
}
|
||||
if got := vs.store.MasterFn(); got != previous {
|
||||
t.Fatalf("store master = %q, want %q", got, previous)
|
||||
}
|
||||
release()
|
||||
|
||||
select {
|
||||
case r := <-run.done:
|
||||
if r.err != nil {
|
||||
t.Fatalf("heartbeat: %v", r.err)
|
||||
}
|
||||
if r.newLeader != leader {
|
||||
t.Fatalf("newLeader = %q, want %q", r.newLeader, leader)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("heartbeat did not return after the follower named the leader")
|
||||
}
|
||||
if got := vs.getCurrentMaster(); got != leader {
|
||||
t.Fatalf("currentMaster = %q, want named leader %q", got, leader)
|
||||
}
|
||||
}
|
||||
|
||||
// The leader's first answer confirms it, and the size limit that answer carries
|
||||
// makes the receiver ask for a fresh heartbeat, which the send loop delivers.
|
||||
func TestHeartbeatToLeaderPublishesItAndResendsFromSendLoop(t *testing.T) {
|
||||
leader := &fakeHeartbeatMaster{heartbeats: make(chan *master_pb.Heartbeat, 8)}
|
||||
leaderAddr := startFakeMasterServerForLeaderLookup(t, leader)
|
||||
|
||||
vs := newHeartbeatTestVolumeServer(t)
|
||||
run := startHeartbeat(t, vs, leaderAddr)
|
||||
|
||||
waitForCurrentMaster(t, vs, leaderAddr)
|
||||
if got := vs.store.MasterFn(); got != leaderAddr {
|
||||
t.Fatalf("store master = %q, want %q", got, leaderAddr)
|
||||
}
|
||||
|
||||
// the initial volume and ec heartbeats, then the resend
|
||||
for i := 0; i < 3; i++ {
|
||||
select {
|
||||
case <-leader.heartbeats:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("leader got %d heartbeats, want 3", i)
|
||||
}
|
||||
}
|
||||
|
||||
run.stop()
|
||||
select {
|
||||
case r := <-run.done:
|
||||
if r.err != nil {
|
||||
t.Fatalf("heartbeat: %v", r.err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("heartbeat did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
// duplicateUUIDMaster answers every heartbeat the way a leader holding a stale
|
||||
// registration does: a duplicate-uuid response and nothing else.
|
||||
type duplicateUUIDMaster struct {
|
||||
master_pb.UnimplementedSeaweedServer
|
||||
}
|
||||
|
||||
func (m *duplicateUUIDMaster) SendHeartbeat(stream master_pb.Seaweed_SendHeartbeatServer) error {
|
||||
for {
|
||||
if _, err := stream.Recv(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := stream.Send(&master_pb.HeartbeatResponse{DuplicatedUuids: []string{"uuid-1"}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A leader that rejects the registration over duplicate uuids still answers
|
||||
// lookups, so it is published before the retry error goes out.
|
||||
func TestHeartbeatPublishesLeaderBeforeDuplicateUuidError(t *testing.T) {
|
||||
masterAddr := startFakeMasterServerForLeaderLookup(t, &duplicateUUIDMaster{})
|
||||
vs := newHeartbeatTestVolumeServer(t)
|
||||
vs.setCurrentMaster("previous.example.com:9333.19333")
|
||||
|
||||
_, err := vs.doHeartbeatWithRetry(masterAddr, vs.grpcDialOption, time.Hour, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate UUIDs") {
|
||||
t.Fatalf("err = %v, want duplicate UUIDs", err)
|
||||
}
|
||||
if got := vs.getCurrentMaster(); got != masterAddr {
|
||||
t.Fatalf("currentMaster = %q, want the answering leader %q", got, masterAddr)
|
||||
}
|
||||
}
|
||||
|
||||
// After the heartbeat loses its master, lookups move to the leader the other
|
||||
// masters report instead of dialing the lost one until a new stream answers.
|
||||
func TestRefreshLeaderFromPeersPublishesReportedLeader(t *testing.T) {
|
||||
const leader = pb.ServerAddress("leader.example.com:9333.19333")
|
||||
peer := startFakeMasterServerForLeaderLookup(t, &fakeLeaderConfigServer{leader: string(leader)})
|
||||
vs := &VolumeServer{
|
||||
SeedMasterNodes: []pb.ServerAddress{peer},
|
||||
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
store: &storage.Store{Ip: "127.0.0.1", Port: 8080},
|
||||
}
|
||||
vs.store.MasterFn = vs.getCurrentMaster
|
||||
vs.setCurrentMaster("lost.example.com:9333.19333")
|
||||
|
||||
vs.refreshLeaderFromPeers(5 * time.Second)
|
||||
|
||||
if got := vs.getCurrentMaster(); got != leader {
|
||||
t.Fatalf("currentMaster = %q, want reported leader %q", string(got), string(leader))
|
||||
}
|
||||
if got := vs.store.MasterFn(); got != leader {
|
||||
t.Fatalf("store master = %q, want %q", string(got), string(leader))
|
||||
}
|
||||
}
|
||||
|
||||
// Mid-election no master names a leader. The lost master is dead or now a
|
||||
// follower that would answer lookups from its own cache, so none is published.
|
||||
func TestRefreshLeaderFromPeersClearsLeaderWithoutAnswer(t *testing.T) {
|
||||
peer := startFakeMasterServerForLeaderLookup(t, &fakeLeaderConfigServer{})
|
||||
vs := &VolumeServer{
|
||||
SeedMasterNodes: []pb.ServerAddress{peer},
|
||||
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
store: &storage.Store{Ip: "127.0.0.1", Port: 8080},
|
||||
}
|
||||
vs.store.MasterFn = vs.getCurrentMaster
|
||||
vs.setCurrentMaster("lost.example.com:9333.19333")
|
||||
|
||||
vs.refreshLeaderFromPeers(5 * time.Second)
|
||||
|
||||
if got := vs.getCurrentMaster(); got != "" {
|
||||
t.Fatalf("currentMaster = %q, want none", string(got))
|
||||
}
|
||||
if got := vs.store.MasterFn(); got != "" {
|
||||
t.Fatalf("store master = %q, want none", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ func NewVolumeServer(adminMux, publicMux *http.ServeMux, ip string,
|
||||
vs.checkWithMaster()
|
||||
|
||||
vs.store = storage.NewStore(vs.grpcDialOption, ip, port, grpcPort, publicUrl, id, folders, maxCounts, minFreeSpaces, idxFolder, vs.needleMapKind, diskTypes, diskTags, ldbTimeout, diskProbeConfig)
|
||||
vs.store.MasterFn = vs.getCurrentMaster
|
||||
vs.guard = security.NewGuard(whiteList, signingKey, expiresAfterSec, readSigningKey, readExpiresAfterSec)
|
||||
|
||||
handleStaticResources(adminMux)
|
||||
|
||||
+16
-5
@@ -61,14 +61,25 @@ type ReadOption struct {
|
||||
ReadBufferSize int
|
||||
}
|
||||
|
||||
// masterAddress returns the master to ask for volume and shard locations, or
|
||||
// "" while no leader is known.
|
||||
func (s *Store) masterAddress() pb.ServerAddress {
|
||||
if s.MasterFn == nil {
|
||||
return ""
|
||||
}
|
||||
return s.MasterFn()
|
||||
}
|
||||
|
||||
/*
|
||||
* A VolumeServer contains one Store
|
||||
*/
|
||||
type Store struct {
|
||||
MasterAddress pb.ServerAddress
|
||||
// MasterFn reports the master the volume server currently takes as leader.
|
||||
// The heartbeat loop owns that answer; the store only reads it.
|
||||
MasterFn func() pb.ServerAddress
|
||||
grpcDialOption grpc.DialOption
|
||||
volumeSizeLimit uint64 // read from the master
|
||||
preallocate atomic.Bool // read from the master
|
||||
volumeSizeLimit atomic.Uint64 // read from the master
|
||||
preallocate atomic.Bool // read from the master
|
||||
Ip string
|
||||
Port int
|
||||
GrpcPort int
|
||||
@@ -1302,11 +1313,11 @@ func (s *Store) ConfigureVolume(i needle.VolumeId, replication string) error {
|
||||
}
|
||||
|
||||
func (s *Store) SetVolumeSizeLimit(x uint64) {
|
||||
atomic.StoreUint64(&s.volumeSizeLimit, x)
|
||||
s.volumeSizeLimit.Store(x)
|
||||
}
|
||||
|
||||
func (s *Store) GetVolumeSizeLimit() uint64 {
|
||||
return atomic.LoadUint64(&s.volumeSizeLimit)
|
||||
return s.volumeSizeLimit.Load()
|
||||
}
|
||||
|
||||
func (s *Store) SetPreallocate(x bool) {
|
||||
|
||||
@@ -568,7 +568,7 @@ const ecIntervalReadConcurrency = 8
|
||||
|
||||
func (s *Store) readEcShardIntervals(needleId types.NeedleId, ecVolume *erasure_coding.EcVolume, intervals []erasure_coding.Interval) (data []byte, is_deleted bool, err error) {
|
||||
if err = s.cachedLookupEcShardLocations(ecVolume); err != nil {
|
||||
return nil, false, fmt.Errorf("failed to locate shard via master grpc %s: %v", s.MasterAddress, err)
|
||||
return nil, false, fmt.Errorf("failed to locate shard via master grpc %s: %v", s.masterAddress(), err)
|
||||
}
|
||||
|
||||
var totalSize int
|
||||
@@ -721,7 +721,7 @@ func (s *Store) cachedLookupEcShardLocations(ecVolume *erasure_coding.EcVolume)
|
||||
|
||||
glog.V(3).Infof("lookup and cache ec volume %d locations", ecVolume.VolumeId)
|
||||
|
||||
err = operation.WithMasterServerClient(context.Background(), false, s.MasterAddress, s.grpcDialOption, func(masterClient master_pb.SeaweedClient) error {
|
||||
err = operation.WithMasterServerClient(context.Background(), false, s.masterAddress(), s.grpcDialOption, func(masterClient master_pb.SeaweedClient) error {
|
||||
req := &master_pb.LookupEcVolumeRequest{
|
||||
VolumeId: uint32(ecVolume.VolumeId),
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func rewindShardLocationsRefresh(ecVolume *erasure_coding.EcVolume, d time.Durat
|
||||
func TestCachedLookupEcShardLocationsRefreshesAfterAFailedRead(t *testing.T) {
|
||||
master, masterAddr := startCountingMaster(t)
|
||||
store := &Store{
|
||||
MasterAddress: masterAddr,
|
||||
MasterFn: func() pb.ServerAddress { return masterAddr },
|
||||
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
}
|
||||
ecVolume := &erasure_coding.EcVolume{
|
||||
@@ -127,7 +127,7 @@ func TestCachedLookupEcShardLocationsRefreshesAfterAFailedRead(t *testing.T) {
|
||||
func TestCachedLookupEcShardLocationsSettlesAfterRefresh(t *testing.T) {
|
||||
master, masterAddr := startCountingMaster(t)
|
||||
store := &Store{
|
||||
MasterAddress: masterAddr,
|
||||
MasterFn: func() pb.ServerAddress { return masterAddr },
|
||||
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
}
|
||||
ecVolume := &erasure_coding.EcVolume{
|
||||
@@ -257,7 +257,7 @@ func TestReadOneEcShardIntervalMarksTheMapAfterADirectReadFails(t *testing.T) {
|
||||
func TestCachedLookupEcShardLocationsKeepsAMarkRaisedDuringTheLookup(t *testing.T) {
|
||||
master, masterAddr := startCountingMaster(t)
|
||||
store := &Store{
|
||||
MasterAddress: masterAddr,
|
||||
MasterFn: func() pb.ServerAddress { return masterAddr },
|
||||
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
}
|
||||
ecVolume := &erasure_coding.EcVolume{
|
||||
@@ -295,7 +295,7 @@ func TestCachedLookupEcShardLocationsKeepsAMarkRaisedDuringTheLookup(t *testing.
|
||||
func TestCachedLookupEcShardLocationsKeepsTheMarkWhenTheLookupFails(t *testing.T) {
|
||||
master, masterAddr := startCountingMaster(t)
|
||||
store := &Store{
|
||||
MasterAddress: masterAddr,
|
||||
MasterFn: func() pb.ServerAddress { return masterAddr },
|
||||
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
}
|
||||
ecVolume := &erasure_coding.EcVolume{
|
||||
|
||||
@@ -23,7 +23,7 @@ func (s *Store) ScrubEcVolume(vid needle.VolumeId, mode volume_server_pb.VolumeS
|
||||
return 0, nil, []error{fmt.Errorf("EC volume id %d not found", vid)}
|
||||
}
|
||||
if err := s.cachedLookupEcShardLocations(ecv); err != nil {
|
||||
return 0, nil, []error{fmt.Errorf("failed to locate shard via master grpc %s: %v", s.MasterAddress, err)}
|
||||
return 0, nil, []error{fmt.Errorf("failed to locate shard via master grpc %s: %v", s.masterAddress(), err)}
|
||||
}
|
||||
return s.scrubEcVolumeWalk(ecv, mode, forceDeletedNeedlesCheck)
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func (s *Store) ScrubEcVolumeMerged(merged *erasure_coding.MergedEcRuntimes, mod
|
||||
}
|
||||
}
|
||||
if err := s.cachedLookupEcShardLocations(ecv); err != nil {
|
||||
return 0, nil, []error{fmt.Errorf("failed to locate shard via master grpc %s: %v", s.MasterAddress, err)}
|
||||
return 0, nil, []error{fmt.Errorf("failed to locate shard via master grpc %s: %v", s.masterAddress(), err)}
|
||||
}
|
||||
|
||||
files, shardInfos, errs := s.scrubEcVolumeWalk(ecv, mode, forceDeletedNeedlesCheck)
|
||||
|
||||
Reference in New Issue
Block a user