mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-04 02:57:44 +01:00
* s3vectors: run an operation for trusted local tooling Like s3tables.Manager.Execute, Server.Execute runs one S3 Vectors operation through its handler as a given identity, without a signed request. The admin console can then manage vector buckets through the same validation, authorization and catalog steps as the API. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * admin: list, inspect, create and delete vector buckets over the API /api/s3vectors runs the S3 Vectors operations through Server.Execute as the admin account: list vector buckets with their encryption, read one bucket with its policy and each index's data type, dimension, distance metric and non-filterable keys, create and delete a bucket, and delete an index. Going through the operations keeps the API's rules, such as ICEBERG buckets not being vector buckets and the implicit index namespace not blocking a delete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3vectors: export the rule for which table buckets are vector buckets IsVectorBucketFormat names the check ListVectorBuckets and isVectorBucket already make, so the admin console can tell the same buckets apart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * admin: vector bucket pages Object Store > Vector Buckets lists the vector buckets with their ARN, encryption and creation time, creates and deletes them, and opens one bucket with its indexes (data type, dimension, distance metric, non-filterable keys) and its policy, where an index can be deleted. A LANCE row on the Table Buckets page links to the same bucket's vector page, since both APIs serve it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * admin: stop the vector bucket and index lists at the list limit A page cut short by filtered ICEBERG buckets let the next page carry the list past s3TablesAdminListLimit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: say when a vector bucket or index list is cut at the limit The lists stop at s3TablesAdminListLimit while more remain, so mark them truncated and point to an S3 Vectors client for the rest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: indent the vector bucket policy A policy stored as one line ran off the right edge of the details page. Policies that are not valid JSON are shown as stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: show a deeply nested vector bucket policy as stored Indenting multiplies the size of a deeply nested document, so a policy past 32 levels of nesting is shown without indenting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
7.4 KiB
Go
175 lines
7.4 KiB
Go
package dash
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3vectors"
|
|
)
|
|
|
|
func newS3VectorsTestServer(t *testing.T) (*AdminServer, *s3tables.ManagerClient) {
|
|
t.Helper()
|
|
fs := s3tablestest.Start(t)
|
|
client := s3tables.NewManagerClient(fs.Client)
|
|
server := s3vectors.NewServer(client, nil, nil)
|
|
server.SetRegion(s3tables.DefaultRegion)
|
|
server.SetAccountID(s3tables.DefaultAccountID)
|
|
return &AdminServer{s3VectorsServer: server}, client
|
|
}
|
|
|
|
// createS3VectorsIndexRecord writes the catalog record CreateIndex leaves
|
|
// behind; the admin server has no engine to run CreateIndex itself.
|
|
func createS3VectorsIndexRecord(t *testing.T, client *s3tables.ManagerClient, bucket, index string, metadata map[string]string) {
|
|
t.Helper()
|
|
m := s3tables.NewManager()
|
|
m.SetTrusted(true)
|
|
arn := "arn:aws:s3tables:us-east-1:000000000000:bucket/" + bucket
|
|
ctx := context.Background()
|
|
err := m.Execute(ctx, client, "CreateNamespace", &s3tables.CreateNamespaceRequest{TableBucketARN: arn, Namespace: []string{s3tables.VectorIndexNamespace}}, nil, s3_constants.AccountAdminId)
|
|
if err != nil && !strings.Contains(err.Error(), "already exists") {
|
|
t.Fatalf("create namespace: %v", err)
|
|
}
|
|
full, _ := json.Marshal(metadata)
|
|
if err := m.Execute(ctx, client, "CreateTable", &s3tables.CreateTableRequest{
|
|
TableBucketARN: arn, Namespace: []string{s3tables.VectorIndexNamespace}, Name: index,
|
|
Format: s3tables.FormatLance, Metadata: &s3tables.TableMetadata{FullMetadata: full},
|
|
}, nil, s3_constants.AccountAdminId); err != nil {
|
|
t.Fatalf("create index record: %v", err)
|
|
}
|
|
}
|
|
|
|
func s3VectorsRequest(t *testing.T, handler http.HandlerFunc, method, target, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
handler(w, httptest.NewRequest(method, target, strings.NewReader(body)))
|
|
return w
|
|
}
|
|
|
|
func TestS3VectorsBucketsListsOnlyVectorBuckets(t *testing.T) {
|
|
s, client := newS3VectorsTestServer(t)
|
|
ctx := context.Background()
|
|
if w := s3VectorsRequest(t, s.CreateS3VectorsBucket, http.MethodPost, "/api/s3vectors/buckets", `{"name":"media"}`); w.Code != http.StatusCreated {
|
|
t.Fatalf("create: %d %s", w.Code, w.Body)
|
|
}
|
|
if err := s.executeS3VectorsOperation(ctx, "CreateVectorBucket", &s3vectors.CreateVectorBucketRequest{
|
|
VectorBucketName: "sealed", EncryptionConfiguration: map[string]interface{}{"sseType": "AES256"},
|
|
}, nil); err != nil {
|
|
t.Fatalf("create sealed: %v", err)
|
|
}
|
|
if err := s3tables.NewManager().Execute(ctx, client, "CreateTableBucket", &s3tables.CreateTableBucketRequest{
|
|
Name: "warehouse", Format: s3tables.FormatIceberg,
|
|
}, nil, s3_constants.AccountAdminId); err != nil {
|
|
t.Fatalf("create iceberg bucket: %v", err)
|
|
}
|
|
|
|
data, err := s.GetS3VectorsBucketsData(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := map[string]string{}
|
|
for _, b := range data.Buckets {
|
|
got[b.Name] = b.Encryption
|
|
if want := "arn:aws:s3vectors:us-east-1:000000000000:bucket/" + b.Name; b.ARN != want || b.CreatedAt.IsZero() {
|
|
t.Errorf("%s: arn %q created %v, want %q and a time", b.Name, b.ARN, b.CreatedAt, want)
|
|
}
|
|
}
|
|
if len(got) != 2 || got["media"] != "" || got["sealed"] != "AES256" || data.TotalBuckets != 2 || data.Truncated {
|
|
t.Fatalf("buckets %v (total %d), want media and sealed (AES256) without the iceberg bucket", got, data.TotalBuckets)
|
|
}
|
|
}
|
|
|
|
func TestS3VectorsBucketShowsIndexesAndPolicy(t *testing.T) {
|
|
s, client := newS3VectorsTestServer(t)
|
|
ctx := context.Background()
|
|
if err := s.executeS3VectorsOperation(ctx, "CreateVectorBucket", &s3vectors.CreateVectorBucketRequest{VectorBucketName: "media"}, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
createS3VectorsIndexRecord(t, client, "media", "movies", map[string]string{
|
|
"dimension": "3", "distanceMetric": "cosine", "nonFilterableMetadataKeys": `["summary"]`,
|
|
})
|
|
policy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3vectors:QueryVectors","Resource":"*"}]}`
|
|
if err := s.executeS3VectorsOperation(ctx, "PutVectorBucketPolicy", &s3vectors.PutVectorBucketPolicyRequest{VectorBucketName: "media", Policy: policy}, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
w := s3VectorsRequest(t, s.GetS3VectorsBucketAPI, http.MethodGet, "/api/s3vectors/indexes?bucket=media", "")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("get: %d %s", w.Code, w.Body)
|
|
}
|
|
var data S3VectorsBucketData
|
|
if err := json.Unmarshal(w.Body.Bytes(), &data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if data.Policy != policy {
|
|
t.Errorf("policy %q, want %q", data.Policy, policy)
|
|
}
|
|
if data.TotalIndexes != 1 {
|
|
t.Fatalf("indexes %+v", data.Indexes)
|
|
}
|
|
idx := data.Indexes[0]
|
|
if idx.IndexName != "movies" || idx.Dimension != 3 || idx.DistanceMetric != "cosine" || idx.DataType != "float32" ||
|
|
idx.MetadataConfiguration == nil || len(idx.MetadataConfiguration.NonFilterableMetadataKeys) != 1 {
|
|
t.Fatalf("index %+v", idx)
|
|
}
|
|
|
|
if w := s3VectorsRequest(t, s.GetS3VectorsBucketAPI, http.MethodGet, "/api/s3vectors/indexes?bucket=missing", ""); w.Code != http.StatusNotFound {
|
|
t.Fatalf("missing bucket: %d, want 404", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestS3VectorsDeleteIndexThenBucket(t *testing.T) {
|
|
s, client := newS3VectorsTestServer(t)
|
|
ctx := context.Background()
|
|
if err := s.executeS3VectorsOperation(ctx, "CreateVectorBucket", &s3vectors.CreateVectorBucketRequest{VectorBucketName: "media"}, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
createS3VectorsIndexRecord(t, client, "media", "movies", map[string]string{"dimension": "3", "distanceMetric": "cosine"})
|
|
|
|
if w := s3VectorsRequest(t, s.DeleteS3VectorsBucket, http.MethodDelete, "/api/s3vectors/buckets?bucket=media", ""); w.Code != http.StatusConflict {
|
|
t.Fatalf("delete a bucket with an index: %d %s, want 409", w.Code, w.Body)
|
|
}
|
|
if w := s3VectorsRequest(t, s.DeleteS3VectorsIndex, http.MethodDelete, "/api/s3vectors/indexes?bucket=media&index=movies", ""); w.Code != http.StatusOK {
|
|
t.Fatalf("delete index: %d %s", w.Code, w.Body)
|
|
}
|
|
// The emptied index namespace does not keep the bucket alive.
|
|
if w := s3VectorsRequest(t, s.DeleteS3VectorsBucket, http.MethodDelete, "/api/s3vectors/buckets?bucket=media", ""); w.Code != http.StatusOK {
|
|
t.Fatalf("delete bucket: %d %s", w.Code, w.Body)
|
|
}
|
|
data, err := s.GetS3VectorsBucketsData(ctx)
|
|
if err != nil || data.TotalBuckets != 0 {
|
|
t.Fatalf("after delete: %+v, %v", data.Buckets, err)
|
|
}
|
|
}
|
|
|
|
func TestS3VectorsBucketsStopAtTheListLimit(t *testing.T) {
|
|
s, client := newS3VectorsTestServer(t)
|
|
ctx := context.Background()
|
|
// The ICEBERG bucket leaves the first page short, so the last page
|
|
// would carry the list past the limit.
|
|
if err := s3tables.NewManager().Execute(ctx, client, "CreateTableBucket", &s3tables.CreateTableBucketRequest{
|
|
Name: "iceberg", Format: s3tables.FormatIceberg,
|
|
}, nil, s3_constants.AccountAdminId); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := 0; i <= s3TablesAdminListLimit; i++ {
|
|
if err := s.executeS3VectorsOperation(ctx, "CreateVectorBucket", &s3vectors.CreateVectorBucketRequest{VectorBucketName: fmt.Sprintf("media-%04d", i)}, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
data, err := s.GetS3VectorsBucketsData(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if data.TotalBuckets != s3TablesAdminListLimit || !data.Truncated {
|
|
t.Fatalf("listed %d buckets (truncated %v), want the limit %d and truncated", data.TotalBuckets, data.Truncated, s3TablesAdminListLimit)
|
|
}
|
|
}
|