Files
seaweedfs/weed/s3api/s3api_object_handlers_readonly_test.go
T
Eliah RusinandClaude Opus 5.5 164c3db606 s3: return 403, not 500, when an over-quota bucket refuses a write (#11552)
* 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>
2026-10-01 22:21:03 +08:00

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)
}
}