Files
seaweedfs/weed/admin/dash/s3vectors_management.go
T
Chris LuandClaude Opus 5.5 a074538c8f admin: manage S3 vector buckets (#12334)
* 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>
2026-11-02 11:25:24 -08:00

255 lines
8.5 KiB
Go

package dash
import (
"context"
"errors"
"net/http"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3vectors"
)
type S3VectorsBucketsData struct {
Username string `json:"username"`
Buckets []S3VectorsBucketSummary `json:"buckets"`
TotalBuckets int `json:"total_buckets"`
Truncated bool `json:"truncated,omitempty"`
LastUpdated time.Time `json:"last_updated"`
}
type S3VectorsBucketSummary struct {
Name string `json:"name"`
ARN string `json:"arn"`
CreatedAt time.Time `json:"createdAt"`
// Encryption is the bucket's sseType, or empty when it declares none.
Encryption string `json:"encryption,omitempty"`
}
type S3VectorsBucketData struct {
Username string `json:"username"`
Bucket S3VectorsBucketSummary `json:"bucket"`
Policy string `json:"policy,omitempty"`
Indexes []*s3vectors.Index `json:"indexes"`
TotalIndexes int `json:"total_indexes"`
Truncated bool `json:"truncated,omitempty"`
LastUpdated time.Time `json:"last_updated"`
}
// The S3 Vectors operations reach the filer through the admin server's own
// filer connection.
type s3VectorsFilerClient struct{ s *AdminServer }
func (c s3VectorsFilerClient) WithFilerClient(_ bool, fn func(client filer_pb.SeaweedFilerClient) error) error {
return c.s.WithFilerClient(fn)
}
// newS3VectorsServer serves the catalog side of S3 Vectors for the admin
// console. It has no vector engine, so it never runs an operation that needs
// one, such as CreateIndex.
func newS3VectorsServer(s *AdminServer) *s3vectors.Server {
server := s3vectors.NewServer(s3VectorsFilerClient{s}, nil, nil)
server.SetRegion(s3tables.DefaultRegion)
server.SetAccountID(s3tables.DefaultAccountID)
return server
}
func (s *AdminServer) executeS3VectorsOperation(ctx context.Context, operation string, req, resp interface{}) error {
return s.s3VectorsServer.Execute(ctx, operation, req, resp, s3_constants.AccountAdminId)
}
const s3VectorsAdminPage = 500
func (s *AdminServer) s3VectorsBucketSummary(ctx context.Context, name string) (S3VectorsBucketSummary, error) {
var resp s3vectors.GetVectorBucketResponse
if err := s.executeS3VectorsOperation(ctx, "GetVectorBucket", &s3vectors.GetVectorBucketRequest{VectorBucketName: name}, &resp); err != nil {
return S3VectorsBucketSummary{}, err
}
b := resp.VectorBucket
summary := S3VectorsBucketSummary{Name: b.VectorBucketName, ARN: b.VectorBucketArn, CreatedAt: b.CreationTime}
if sse, ok := b.EncryptionConfiguration["sseType"].(string); ok {
summary.Encryption = sse
}
return summary, nil
}
func (s *AdminServer) GetS3VectorsBucketsData(ctx context.Context) (S3VectorsBucketsData, error) {
data := S3VectorsBucketsData{Buckets: []S3VectorsBucketSummary{}}
page := s3VectorsAdminPage
req := &s3vectors.ListVectorBucketsRequest{MaxResults: &page}
for {
var resp s3vectors.ListVectorBucketsResponse
if err := s.executeS3VectorsOperation(ctx, "ListVectorBuckets", req, &resp); err != nil {
return S3VectorsBucketsData{}, err
}
for _, b := range resp.VectorBuckets {
if len(data.Buckets) == s3TablesAdminListLimit {
data.Truncated = true
break
}
summary, err := s.s3VectorsBucketSummary(ctx, b.VectorBucketName)
if isS3VectorsNotFound(err) {
continue
}
if err != nil {
return S3VectorsBucketsData{}, err
}
data.Buckets = append(data.Buckets, summary)
}
if data.Truncated || resp.NextToken == "" {
break
}
req.NextToken = resp.NextToken
}
data.TotalBuckets = len(data.Buckets)
data.LastUpdated = time.Now()
return data, nil
}
func (s *AdminServer) GetS3VectorsBucketData(ctx context.Context, bucket string) (S3VectorsBucketData, error) {
summary, err := s.s3VectorsBucketSummary(ctx, bucket)
if err != nil {
return S3VectorsBucketData{}, err
}
data := S3VectorsBucketData{Bucket: summary, Indexes: []*s3vectors.Index{}, LastUpdated: time.Now()}
var policy s3vectors.GetVectorBucketPolicyResponse
switch err := s.executeS3VectorsOperation(ctx, "GetVectorBucketPolicy", &s3vectors.GetVectorBucketPolicyRequest{VectorBucketName: bucket}, &policy); {
case err == nil:
data.Policy = policy.Policy
case !isS3VectorsNotFound(err):
return S3VectorsBucketData{}, err
}
page := s3VectorsAdminPage
req := &s3vectors.ListIndexesRequest{VectorBucketName: bucket, MaxResults: &page}
for {
var resp s3vectors.ListIndexesResponse
if err := s.executeS3VectorsOperation(ctx, "ListIndexes", req, &resp); err != nil {
return S3VectorsBucketData{}, err
}
for _, summary := range resp.Indexes {
if len(data.Indexes) == s3TablesAdminListLimit {
data.Truncated = true
break
}
var index s3vectors.GetIndexResponse
err := s.executeS3VectorsOperation(ctx, "GetIndex", &s3vectors.GetIndexRequest{VectorBucketName: bucket, IndexName: summary.IndexName}, &index)
if isS3VectorsNotFound(err) {
continue
}
if err != nil {
return S3VectorsBucketData{}, err
}
data.Indexes = append(data.Indexes, index.Index)
}
if data.Truncated || resp.NextToken == "" {
break
}
req.NextToken = resp.NextToken
}
data.TotalIndexes = len(data.Indexes)
return data, nil
}
func (s *AdminServer) ListS3VectorsBucketsAPI(w http.ResponseWriter, r *http.Request) {
data, err := s.GetS3VectorsBucketsData(r.Context())
if err != nil {
writeS3VectorsError(w, err)
return
}
writeJSON(w, http.StatusOK, data)
}
func (s *AdminServer) CreateS3VectorsBucket(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
var req struct {
Name string `json:"name"`
}
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
var resp s3vectors.CreateVectorBucketResponse
if err := s.executeS3VectorsOperation(r.Context(), "CreateVectorBucket", &s3vectors.CreateVectorBucketRequest{VectorBucketName: req.Name}, &resp); err != nil {
writeS3VectorsError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{"arn": resp.VectorBucketArn})
}
func (s *AdminServer) DeleteS3VectorsBucket(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
bucket := r.URL.Query().Get("bucket")
if bucket == "" {
writeJSONError(w, http.StatusBadRequest, "bucket query parameter is required")
return
}
if err := s.executeS3VectorsOperation(r.Context(), "DeleteVectorBucket", &s3vectors.DeleteVectorBucketRequest{VectorBucketName: bucket}, nil); err != nil {
writeS3VectorsError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Vector bucket deleted"})
}
func (s *AdminServer) GetS3VectorsBucketAPI(w http.ResponseWriter, r *http.Request) {
bucket := r.URL.Query().Get("bucket")
if bucket == "" {
writeJSONError(w, http.StatusBadRequest, "bucket query parameter is required")
return
}
data, err := s.GetS3VectorsBucketData(r.Context(), bucket)
if err != nil {
writeS3VectorsError(w, err)
return
}
writeJSON(w, http.StatusOK, data)
}
func (s *AdminServer) DeleteS3VectorsIndex(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
bucket, index := r.URL.Query().Get("bucket"), r.URL.Query().Get("index")
if bucket == "" || index == "" {
writeJSONError(w, http.StatusBadRequest, "bucket and index query parameters are required")
return
}
if err := s.executeS3VectorsOperation(r.Context(), "DeleteIndex", &s3vectors.DeleteIndexRequest{VectorBucketName: bucket, IndexName: index}, nil); err != nil {
writeS3VectorsError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Index deleted"})
}
func isS3VectorsNotFound(err error) bool {
var e *s3vectors.Error
return errors.As(err, &e) && e.Code == "NotFoundException"
}
func writeS3VectorsError(w http.ResponseWriter, err error) {
var e *s3vectors.Error
if !errors.As(err, &e) {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
status := http.StatusInternalServerError
switch e.Code {
case "ValidationException":
status = http.StatusBadRequest
case "AccessDeniedException":
status = http.StatusForbidden
case "NotFoundException":
status = http.StatusNotFound
case "ConflictException":
status = http.StatusConflict
case "ServiceUnavailableException":
status = http.StatusServiceUnavailable
}
writeJSONError(w, status, e.Message)
}