mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-06 14:31:57 +02:00
* s3: return 403, not 500, when an over-quota bucket refuses a write Filer AssignVolume flattened ErrReadOnly into the free-text AssignVolumeResponse.Error string, so S3 PutObject / PutObjectPart via UploadReaderInChunks could not match it with errors.Is and fell through to 500 InternalError: retryable, and it hides the quota. Add FilerError READ_ONLY and AssignVolumeResponse.error_code, set it alongside the unchanged error text, and rebuild the sentinel with filer_pb.AssignVolumeResponseError. weed_server.ErrReadOnly now aliases filer_pb.ErrReadOnly so errors.Is matches on both sides, and mapChunkedUploadErrorToS3Error maps it to ErrAccessDenied. There is no "read only" substring matching, so a volume server's "volume N is read only" stays retryable. Carrying the verdict as a response code rather than a gRPC status keeps clients from treating it as a transport failure: the S3 gateway does not fail over across filers and the Java client does not retry it. Wrap per-chunk copy errors with %w so CopyObject keeps the sentinel, and map UploadPartCopy chunk errors through mapCopyErrorToS3Error instead of always returning 500. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ci: re-run integration tests (PyPI download timeout) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
95 lines
3.4 KiB
Go
95 lines
3.4 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
weed_server "github.com/seaweedfs/seaweedfs/weed/server"
|
|
)
|
|
|
|
// readOnlyAssignFiler answers AssignVolume the way a filer does for a path
|
|
// under a read-only rule (e.g. an over-quota bucket), and counts the calls.
|
|
type readOnlyAssignFiler struct {
|
|
filer_pb.UnimplementedSeaweedFilerServer
|
|
calls int32
|
|
}
|
|
|
|
func (f *readOnlyAssignFiler) AssignVolume(context.Context, *filer_pb.AssignVolumeRequest) (*filer_pb.AssignVolumeResponse, error) {
|
|
atomic.AddInt32(&f.calls, 1)
|
|
return &filer_pb.AssignVolumeResponse{
|
|
Error: "assign volume: read only: /buckets/b (e.g. bucket over quota)",
|
|
ErrorCode: filer_pb.FilerError_READ_ONLY,
|
|
}, nil
|
|
}
|
|
|
|
// LookupDirectoryEntry finds nothing, so the bucket has no config to apply.
|
|
func (f *readOnlyAssignFiler) LookupDirectoryEntry(context.Context, *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
|
return &filer_pb.LookupDirectoryEntryResponse{}, nil
|
|
}
|
|
|
|
// startReadOnlyFilers starts a read-only filer followed by a spare one, so a
|
|
// test can assert the verdict was not treated as a reason to fail over.
|
|
func startReadOnlyFilers(t *testing.T) (*S3ApiServer, *readOnlyAssignFiler, *readOnlyAssignFiler) {
|
|
t.Helper()
|
|
first, spare := &readOnlyAssignFiler{}, &readOnlyAssignFiler{}
|
|
s3a := newPutTestServer(t, startFakeFiler(t, first), startFakeFiler(t, spare))
|
|
return s3a, first, spare
|
|
}
|
|
|
|
func assertNoFailover(t *testing.T, first, spare *readOnlyAssignFiler) {
|
|
t.Helper()
|
|
if calls := atomic.LoadInt32(&first.calls); calls == 0 {
|
|
t.Fatal("the first filer was never asked")
|
|
}
|
|
if calls := atomic.LoadInt32(&spare.calls); calls != 0 {
|
|
t.Fatalf("read-only verdict failed over to the next filer %d time(s)", calls)
|
|
}
|
|
}
|
|
|
|
// PutObject into an over-quota bucket is 403, not a retryable 500.
|
|
func TestPutToFilerReadOnlyBucketIsAccessDenied(t *testing.T) {
|
|
s3a, first, spare := startReadOnlyFilers(t)
|
|
|
|
if _, code := putTestObject(t, s3a); code != s3err.ErrAccessDenied {
|
|
t.Fatalf("putToFiler = %v, want %v", code, s3err.ErrAccessDenied)
|
|
}
|
|
assertNoFailover(t, first, spare)
|
|
}
|
|
|
|
func TestAssignNewVolumeReadOnlyKeepsSentinel(t *testing.T) {
|
|
s3a, first, spare := startReadOnlyFilers(t)
|
|
|
|
_, err := s3a.assignNewVolume("/buckets/b/o", 1)
|
|
if !errors.Is(err, weed_server.ErrReadOnly) {
|
|
t.Fatalf("assignNewVolume err = %v, want ErrReadOnly", err)
|
|
}
|
|
assertNoFailover(t, first, spare)
|
|
}
|
|
|
|
// The copy paths fan chunks out to workers; the sentinel must survive the
|
|
// per-chunk wrapping so CopyObject and UploadPartCopy report 403 too.
|
|
func TestCopyChunksReadOnlyIsAccessDenied(t *testing.T) {
|
|
s3a, _, _ := startReadOnlyFilers(t)
|
|
entry := &filer_pb.Entry{
|
|
Attributes: &filer_pb.FuseAttributes{FileSize: 8},
|
|
Chunks: []*filer_pb.FileChunk{
|
|
{FileId: "3,01637037d6", Offset: 0, Size: 4},
|
|
{FileId: "3,02637037d6", Offset: 4, Size: 4},
|
|
},
|
|
}
|
|
|
|
_, err := s3a.copyChunks(entry, "/buckets/b/dst")
|
|
if code := s3a.mapCopyErrorToS3Error(err); code != s3err.ErrAccessDenied {
|
|
t.Fatalf("copyChunks err %v maps to %v, want %v", err, code, s3err.ErrAccessDenied)
|
|
}
|
|
|
|
_, 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)
|
|
}
|
|
}
|