mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-01 09:50:39 +01:00
s3tables: keep the fence on a declare of an existing index Deciding the existing-index exception before the handler ran left a window: an index deleted between that lookup and the handler's own check would be recreated, unfenced, by a caller without s3vectors:CreateIndex. Drop the up-front exception, so every CreateTable runs fenced and a fresh create is refused at its first write. The engine's declare still passes because the idempotent path writes nothing; for a build whose idempotent path re-applies state already in place, the fence answers an UpdateEntry that leaves the entry exactly as stored without writing. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
291 lines
14 KiB
Go
291 lines
14 KiB
Go
package s3tables
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const vectorTestBucket = "vecns"
|
|
|
|
func vectorTestARN(t *testing.T) string {
|
|
t.Helper()
|
|
arn, err := BuildBucketARN(DefaultRegion, DefaultAccountID, vectorTestBucket)
|
|
require.NoError(t, err)
|
|
return arn
|
|
}
|
|
|
|
// seedVectorBucket lays down, through a trusted Manager, a bucket of the given
|
|
// format holding vector/idx (with a policy and a tag) and other/t, the shape
|
|
// the S3 Vectors gateway leaves behind.
|
|
func seedVectorBucket(t *testing.T, fs *s3tablestest.MemFiler, format string) *Manager {
|
|
t.Helper()
|
|
trusted := NewManager()
|
|
trusted.SetTrusted(true)
|
|
run := func(operation string, req interface{}) {
|
|
t.Helper()
|
|
require.NoError(t, trusted.Execute(context.Background(), NewManagerClient(fs.Client), operation, req, nil, ""))
|
|
}
|
|
arn := vectorTestARN(t)
|
|
run("CreateTableBucket", &CreateTableBucketRequest{Name: vectorTestBucket, Format: format})
|
|
for _, ns := range []string{VectorIndexNamespace, "other"} {
|
|
run("CreateNamespace", &CreateNamespaceRequest{TableBucketARN: arn, Namespace: []string{ns}})
|
|
}
|
|
run("CreateTable", &CreateTableRequest{TableBucketARN: arn, Namespace: []string{VectorIndexNamespace}, Name: "idx", Format: format})
|
|
run("CreateTable", &CreateTableRequest{TableBucketARN: arn, Namespace: []string{"other"}, Name: "t", Format: format})
|
|
run("PutTablePolicy", &PutTablePolicyRequest{TableBucketARN: arn, Namespace: []string{VectorIndexNamespace}, Name: "idx",
|
|
ResourcePolicy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3tables:GetTable","Resource":"*"}]}`})
|
|
idx, err := getTestTable(t, trusted, fs, VectorIndexNamespace, "idx")
|
|
require.NoError(t, err)
|
|
run("TagResource", &TagResourceRequest{ResourceARN: idx.TableARN, Tags: map[string]string{"k": "v"}})
|
|
return trusted
|
|
}
|
|
|
|
func getTestTable(t *testing.T, m *Manager, fs *s3tablestest.MemFiler, namespace, name string) (*GetTableResponse, error) {
|
|
t.Helper()
|
|
var resp GetTableResponse
|
|
err := m.Execute(context.Background(), NewManagerClient(fs.Client), "GetTable", &GetTableRequest{
|
|
TableBucketARN: vectorTestARN(t), Namespace: []string{namespace}, Name: name}, &resp, "")
|
|
return &resp, err
|
|
}
|
|
|
|
// vectorWrite labels a case "Operation detail" when an operation appears twice.
|
|
type vectorWrite struct {
|
|
operation string
|
|
request interface{}
|
|
}
|
|
|
|
func (w vectorWrite) op() string {
|
|
op, _, _ := strings.Cut(w.operation, " ")
|
|
return op
|
|
}
|
|
|
|
// vectorNamespaceWrites lists the catalog writes that reach vector/idx or move
|
|
// a table across the namespace boundary.
|
|
func vectorNamespaceWrites(t *testing.T, indexARN, versionToken string) []vectorWrite {
|
|
arn := vectorTestARN(t)
|
|
vector := []string{VectorIndexNamespace}
|
|
return []vectorWrite{
|
|
{"UpdateNamespace", &UpdateNamespaceRequest{TableBucketARN: arn, Namespace: vector, Properties: map[string]string{"a": "b"}}},
|
|
{"CreateTable", &CreateTableRequest{TableBucketARN: arn, Namespace: vector, Name: "fresh", Format: FormatLance}},
|
|
{"RegisterTable", &RegisterTableRequest{TableBucketARN: arn, Namespace: vector, Name: "registered",
|
|
MetadataLocation: "s3://" + vectorTestBucket + "/vector/registered/metadata/v1.metadata.json"}},
|
|
{"UpdateTable", &UpdateTableRequest{TableBucketARN: arn, Namespace: vector, Name: "idx", VersionToken: versionToken,
|
|
MetadataLocation: "s3://" + vectorTestBucket + "/vector/elsewhere"}},
|
|
{"DeleteTable", &DeleteTableRequest{TableBucketARN: arn, Namespace: vector, Name: "idx"}},
|
|
{"RenameTable out", &RenameTableRequest{TableBucketARN: arn, SourceNamespace: vector, SourceName: "idx",
|
|
DestNamespace: []string{"other"}, DestName: "moved"}},
|
|
{"RenameTable in", &RenameTableRequest{TableBucketARN: arn, SourceNamespace: []string{"other"}, SourceName: "t",
|
|
DestNamespace: vector, DestName: "t"}},
|
|
{"PutTablePolicy", &PutTablePolicyRequest{TableBucketARN: arn, Namespace: vector, Name: "idx",
|
|
ResourcePolicy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3tables:*","Resource":"*"}]}`}},
|
|
{"DeleteTablePolicy", &DeleteTablePolicyRequest{TableBucketARN: arn, Namespace: vector, Name: "idx"}},
|
|
{"TagResource", &TagResourceRequest{ResourceARN: indexARN, Tags: map[string]string{"x": "y"}}},
|
|
{"UntagResource", &UntagResourceRequest{ResourceARN: indexARN, TagKeys: []string{"k"}}},
|
|
{"PutTableMaintenanceConfiguration", &PutTableMaintenanceConfigurationRequest{TableBucketARN: arn, Namespace: vector, Name: "idx",
|
|
Type: MaintenanceTypeIcebergCompaction, Value: &MaintenanceConfigurationValue{Status: "enabled"}}},
|
|
}
|
|
}
|
|
|
|
func untrustedManager() *Manager {
|
|
m := NewManager()
|
|
m.SetDefaultAllow(false)
|
|
return m
|
|
}
|
|
|
|
// A caller holding s3tables:* still cannot change the index namespace of a
|
|
// vector bucket, and nothing it tried is left behind.
|
|
func TestVectorNamespaceRefusesUntrustedWrites(t *testing.T) {
|
|
fs := s3tablestest.Start(t)
|
|
trusted := seedVectorBucket(t, fs, FormatLance)
|
|
before, err := getTestTable(t, trusted, fs, VectorIndexNamespace, "idx")
|
|
require.NoError(t, err)
|
|
m := untrustedManager()
|
|
granted := []string{"s3tables:*"}
|
|
|
|
for _, tc := range vectorNamespaceWrites(t, before.TableARN, before.VersionToken) {
|
|
t.Run(tc.operation, func(t *testing.T) {
|
|
got := runRequestAs(t, m, fs, tc.op(), tc.request, granted)
|
|
assert.Equal(t, 403, got.status)
|
|
assert.Equal(t, ErrCodeAccessDenied, got.body.Type)
|
|
assert.Equal(t, vectorNamespaceReadOnly, got.body.Message)
|
|
})
|
|
}
|
|
|
|
after, err := getTestTable(t, trusted, fs, VectorIndexNamespace, "idx")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, before.VersionToken, after.VersionToken)
|
|
assert.Equal(t, before.MetadataLocation, after.MetadataLocation)
|
|
_, err = getTestTable(t, trusted, fs, "other", "t")
|
|
assert.NoError(t, err)
|
|
for _, absent := range [][2]string{{VectorIndexNamespace, "fresh"}, {VectorIndexNamespace, "registered"}, {VectorIndexNamespace, "t"}, {"other", "moved"}} {
|
|
_, err := getTestTable(t, trusted, fs, absent[0], absent[1])
|
|
assert.Error(t, err, "%s/%s should not exist", absent[0], absent[1])
|
|
}
|
|
var tags ListTagsForResourceResponse
|
|
require.NoError(t, trusted.Execute(context.Background(), NewManagerClient(fs.Client), "ListTagsForResource",
|
|
&ListTagsForResourceRequest{ResourceARN: before.TableARN}, &tags, ""))
|
|
assert.Equal(t, map[string]string{"k": "v"}, tags.Tags)
|
|
}
|
|
|
|
func TestVectorNamespaceCannotBeCreatedOrDeleted(t *testing.T) {
|
|
fs := s3tablestest.Start(t)
|
|
trusted := NewManager()
|
|
trusted.SetTrusted(true)
|
|
m := untrustedManager()
|
|
granted := []string{"s3tables:*"}
|
|
arn := vectorTestARN(t)
|
|
ctx := context.Background()
|
|
client := NewManagerClient(fs.Client)
|
|
require.NoError(t, trusted.Execute(ctx, client, "CreateTableBucket", &CreateTableBucketRequest{Name: vectorTestBucket, Format: FormatLance}, nil, ""))
|
|
|
|
create := &CreateNamespaceRequest{TableBucketARN: arn, Namespace: []string{VectorIndexNamespace}}
|
|
got := runRequestAs(t, m, fs, "CreateNamespace", create, granted)
|
|
assert.Equal(t, 403, got.status)
|
|
assert.Equal(t, vectorNamespaceReadOnly, got.body.Message)
|
|
|
|
require.NoError(t, trusted.Execute(ctx, client, "CreateNamespace", create, nil, ""))
|
|
got = runRequestAs(t, m, fs, "DeleteNamespace", &DeleteNamespaceRequest{TableBucketARN: arn, Namespace: []string{VectorIndexNamespace}}, granted)
|
|
assert.Equal(t, 403, got.status)
|
|
assert.Equal(t, vectorNamespaceReadOnly, got.body.Message)
|
|
require.NoError(t, trusted.Execute(ctx, client, "GetNamespace", &GetNamespaceRequest{TableBucketARN: arn, Namespace: []string{VectorIndexNamespace}}, nil, ""))
|
|
|
|
other := &CreateNamespaceRequest{TableBucketARN: arn, Namespace: []string{"other"}}
|
|
assert.Equal(t, 200, runRequestAs(t, m, fs, "CreateNamespace", other, granted).status)
|
|
}
|
|
|
|
// The vector engine declares an index the gateway already created; that
|
|
// CreateTable writes nothing and keeps succeeding, as do reads.
|
|
func TestVectorNamespaceAllowsReadsAndExistingCreate(t *testing.T) {
|
|
fs := s3tablestest.Start(t)
|
|
trusted := seedVectorBucket(t, fs, FormatLance)
|
|
before, err := getTestTable(t, trusted, fs, VectorIndexNamespace, "idx")
|
|
require.NoError(t, err)
|
|
m := untrustedManager()
|
|
granted := []string{"s3tables:*"}
|
|
arn := vectorTestARN(t)
|
|
vector := []string{VectorIndexNamespace}
|
|
|
|
for _, tc := range []vectorWrite{
|
|
{"CreateTable", &CreateTableRequest{TableBucketARN: arn, Namespace: vector, Name: "idx", Format: FormatLance}},
|
|
{"GetTable", &GetTableRequest{TableBucketARN: arn, Namespace: vector, Name: "idx"}},
|
|
{"ListTables", &ListTablesRequest{TableBucketARN: arn, Namespace: vector}},
|
|
{"GetNamespace", &GetNamespaceRequest{TableBucketARN: arn, Namespace: vector}},
|
|
{"GetTablePolicy", &GetTablePolicyRequest{TableBucketARN: arn, Namespace: vector, Name: "idx"}},
|
|
{"ListTagsForResource", &ListTagsForResourceRequest{ResourceARN: before.TableARN}},
|
|
} {
|
|
t.Run(tc.operation, func(t *testing.T) {
|
|
assert.Equal(t, 200, runRequestAs(t, m, fs, tc.op(), tc.request, granted).status)
|
|
})
|
|
}
|
|
after, err := getTestTable(t, trusted, fs, VectorIndexNamespace, "idx")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, before.VersionToken, after.VersionToken)
|
|
|
|
require.NoError(t, trusted.Execute(context.Background(), NewManagerClient(fs.Client), "DeleteTable",
|
|
&DeleteTableRequest{TableBucketARN: arn, Namespace: vector, Name: "idx"}, nil, ""), "the trusted path stays writable")
|
|
}
|
|
|
|
// An Iceberg bucket's namespace named vector is an ordinary namespace.
|
|
func TestVectorNamespaceOfIcebergBucketIsWritable(t *testing.T) {
|
|
fs := s3tablestest.Start(t)
|
|
trusted := seedVectorBucket(t, fs, FormatIceberg)
|
|
before, err := getTestTable(t, trusted, fs, VectorIndexNamespace, "idx")
|
|
require.NoError(t, err)
|
|
m := untrustedManager()
|
|
granted := []string{"s3tables:*"}
|
|
|
|
for _, tc := range vectorNamespaceWrites(t, before.TableARN, before.VersionToken) {
|
|
switch tc.operation {
|
|
case "CreateTable", "UpdateTable", "DeleteTable", "RenameTable out":
|
|
continue // covered below: they need an Iceberg request or would consume idx
|
|
}
|
|
t.Run(tc.operation, func(t *testing.T) {
|
|
got := runRequestAs(t, m, fs, tc.op(), tc.request, granted)
|
|
assert.Equal(t, 200, got.status, "%+v", got.body)
|
|
})
|
|
}
|
|
arn := vectorTestARN(t)
|
|
vector := []string{VectorIndexNamespace}
|
|
assert.Equal(t, 200, runRequestAs(t, m, fs, "CreateTable",
|
|
&CreateTableRequest{TableBucketARN: arn, Namespace: vector, Name: "fresh", Format: FormatIceberg}, granted).status)
|
|
assert.Equal(t, 200, runRequestAs(t, m, fs, "DeleteTable",
|
|
&DeleteTableRequest{TableBucketARN: arn, Namespace: vector, Name: "idx"}, granted).status)
|
|
}
|
|
|
|
// A caller without a grant gets the answer an Iceberg bucket of the same name
|
|
// and shape gives: the read-only rule never tells it the bucket exists or
|
|
// holds vectors.
|
|
func TestVectorNamespaceDenialHidesBucketFormat(t *testing.T) {
|
|
lanceFS := s3tablestest.Start(t)
|
|
lance := seedVectorBucket(t, lanceFS, FormatLance)
|
|
icebergFS := s3tablestest.Start(t)
|
|
seedVectorBucket(t, icebergFS, FormatIceberg)
|
|
idx, err := getTestTable(t, lance, lanceFS, VectorIndexNamespace, "idx")
|
|
require.NoError(t, err)
|
|
m := untrustedManager()
|
|
|
|
arn := vectorTestARN(t)
|
|
writes := append(vectorNamespaceWrites(t, idx.TableARN, idx.VersionToken),
|
|
vectorWrite{"CreateNamespace", &CreateNamespaceRequest{TableBucketARN: arn, Namespace: []string{VectorIndexNamespace}}},
|
|
vectorWrite{"DeleteNamespace", &DeleteNamespaceRequest{TableBucketARN: arn, Namespace: []string{VectorIndexNamespace}}})
|
|
for _, tc := range writes {
|
|
t.Run(tc.operation, func(t *testing.T) {
|
|
got := runUnauthorizedRequest(t, m, lanceFS, tc.op(), tc.request)
|
|
want := runUnauthorizedRequest(t, m, icebergFS, tc.op(), tc.request)
|
|
assert.Equal(t, want, got)
|
|
assert.NotEqual(t, vectorNamespaceReadOnly, got.body.Message)
|
|
})
|
|
}
|
|
}
|
|
|
|
// A deferred create writes into the bucket before it registers anything, so
|
|
// its gate refuses the vector namespace as the create itself would.
|
|
func TestAuthorizeCreateTableRefusesVectorNamespace(t *testing.T) {
|
|
identity := &testIdentity{Name: "reader", Account: &testIdentityAccount{Id: "reader"}, Actions: []string{"s3tables:*"}}
|
|
ctx := s3_constants.SetIdentityInContext(context.Background(), identity)
|
|
req := &CreateTableRequest{TableBucketARN: vectorTestARN(t), Namespace: []string{VectorIndexNamespace}, Name: "fresh"}
|
|
|
|
lanceFS := s3tablestest.Start(t)
|
|
seedVectorBucket(t, lanceFS, FormatLance)
|
|
err := untrustedManager().AuthorizeCreateTable(ctx, NewManagerClient(lanceFS.Client), req, "reader")
|
|
var apiErr *S3TablesError
|
|
require.ErrorAs(t, err, &apiErr)
|
|
assert.Equal(t, ErrCodeAccessDenied, apiErr.Type)
|
|
assert.Equal(t, vectorNamespaceReadOnly, apiErr.Message)
|
|
|
|
icebergFS := s3tablestest.Start(t)
|
|
seedVectorBucket(t, icebergFS, FormatIceberg)
|
|
assert.NoError(t, untrustedManager().AuthorizeCreateTable(ctx, NewManagerClient(icebergFS.Client), req, "reader"))
|
|
}
|
|
|
|
// Behind the fence a rewrite that leaves the entry as stored, as an
|
|
// idempotent repair does, passes without writing; any real change is refused.
|
|
func TestVectorNamespaceFencePassesOnlyNoOpUpdates(t *testing.T) {
|
|
fs := s3tablestest.Start(t)
|
|
seedVectorBucket(t, fs, FormatLance)
|
|
h := untrustedManager().handler
|
|
fence := &vectorNamespaceFence{h: h, filer: NewManagerClient(fs.Client)}
|
|
idxPath := GetTablePath(vectorTestBucket, VectorIndexNamespace, "idx")
|
|
stored := fs.Get(GetNamespacePath(vectorTestBucket, VectorIndexNamespace), "idx").Extended[ExtendedKeyMetadata]
|
|
|
|
err := fence.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
return h.setExtendedAttribute(context.Background(), client, idxPath, ExtendedKeyMetadata, stored)
|
|
})
|
|
require.NoError(t, err)
|
|
assert.False(t, fence.tripped.Load())
|
|
|
|
err = fence.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
return h.setExtendedAttribute(context.Background(), client, idxPath, ExtendedKeyMetadata, []byte(`{}`))
|
|
})
|
|
assert.ErrorIs(t, err, errVectorNamespaceReadOnly)
|
|
assert.True(t, fence.tripped.Load())
|
|
assert.Equal(t, stored, fs.Get(GetNamespacePath(vectorTestBucket, VectorIndexNamespace), "idx").Extended[ExtendedKeyMetadata])
|
|
}
|