mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-07 20:47:46 +01: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>
87 lines
3.5 KiB
Go
87 lines
3.5 KiB
Go
package filer_pb
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
)
|
|
|
|
func TestAssignVolumeResponseError(t *testing.T) {
|
|
if err := AssignVolumeResponseError(&AssignVolumeResponse{FileId: "3,01"}); err != nil {
|
|
t.Fatalf("success response: err = %v, want nil", err)
|
|
}
|
|
|
|
// A filer that predates error_code: the text survives, no sentinel is implied.
|
|
err := AssignVolumeResponseError(&AssignVolumeResponse{Error: "assign volume: no free volumes"})
|
|
if err == nil || err.Error() != "assign volume: no free volumes" {
|
|
t.Fatalf("uncoded response: err = %v, want the filer's text", err)
|
|
}
|
|
if errors.Is(err, ErrReadOnly) {
|
|
t.Fatal("uncoded response must not match ErrReadOnly")
|
|
}
|
|
|
|
msg := "assign volume: read only: /buckets/b (e.g. bucket over quota)"
|
|
err = AssignVolumeResponseError(&AssignVolumeResponse{Error: msg, ErrorCode: FilerError_READ_ONLY})
|
|
if !errors.Is(err, ErrReadOnly) {
|
|
t.Fatalf("READ_ONLY response: err = %v, want ErrReadOnly", err)
|
|
}
|
|
if err.Error() != msg {
|
|
t.Fatalf("READ_ONLY response: message = %q, want %q", err.Error(), msg)
|
|
}
|
|
}
|
|
|
|
func TestIsDirectoryKeyObject(t *testing.T) {
|
|
chunk := []*FileChunk{{FileId: "1,01", Size: 75}}
|
|
|
|
cases := []struct {
|
|
name string
|
|
e *Entry
|
|
want bool
|
|
}{
|
|
{"plain directory", &Entry{IsDirectory: true, Attributes: &FuseAttributes{}}, false},
|
|
{"directory marker with mime", &Entry{IsDirectory: true, Attributes: &FuseAttributes{Mime: "application/octet-stream"}}, true},
|
|
{"directory promoted from file keeps chunks", &Entry{IsDirectory: true, Attributes: &FuseAttributes{}, Chunks: chunk}, true},
|
|
{"directory promoted from small file keeps content", &Entry{IsDirectory: true, Attributes: &FuseAttributes{}, Content: []byte("abc")}, true},
|
|
{"directory promoted from remote-tiered file", &Entry{IsDirectory: true, Attributes: &FuseAttributes{}, RemoteEntry: &RemoteEntry{RemoteSize: 100}}, true},
|
|
{"directory with chunks and nil attributes", &Entry{IsDirectory: true, Chunks: chunk}, true},
|
|
{"regular file with chunks", &Entry{IsDirectory: false, Attributes: &FuseAttributes{}, Chunks: chunk}, false},
|
|
{"remote mount directory has no remote size", &Entry{IsDirectory: true, Attributes: &FuseAttributes{}, RemoteEntry: &RemoteEntry{StorageName: "s3"}}, false},
|
|
{"empty prefix object has only the mark", &Entry{IsDirectory: true, Attributes: &FuseAttributes{}, Extended: map[string][]byte{s3_constants.SeaweedFSPrefixObject: []byte("true")}}, true},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := c.e.IsDirectoryKeyObject(); got != c.want {
|
|
t.Errorf("IsDirectoryKeyObject() = %v, want %v", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMarkPrefixObject(t *testing.T) {
|
|
entry := &Entry{Name: "foo", Attributes: &FuseAttributes{FileMode: 0644}}
|
|
|
|
if entry.IsPrefixObject() {
|
|
t.Fatal("a file is not a prefix object")
|
|
}
|
|
|
|
entry.MarkPrefixObject()
|
|
|
|
if !entry.IsDirectory || !entry.IsPrefixObject() {
|
|
t.Errorf("MarkPrefixObject() left %+v", entry)
|
|
}
|
|
// The filer derives the entry type from the mode, so the directory bit has to be
|
|
// on it for the store to keep this as a directory.
|
|
if mode := os.FileMode(entry.Attributes.FileMode); mode&os.ModeDir == 0 || mode.Perm()&0111 != 0111 {
|
|
t.Errorf("FileMode = %v, want a traversable directory", mode)
|
|
}
|
|
|
|
// A directory the mark was stripped from is a plain directory again.
|
|
delete(entry.Extended, s3_constants.SeaweedFSPrefixObject)
|
|
if entry.IsPrefixObject() || entry.IsDirectoryKeyObject() {
|
|
t.Error("a demoted directory names no key")
|
|
}
|
|
}
|