mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-07 04:27:49 +01:00
fix(s3tables): route REST RenameTable and refuse unrouted S3 Tables requests (#12446)
* s3tables: route REST RenameTable and refuse unrouted S3 Tables requests
PUT /tables/<arn>/<ns>/<name>/rename had no route, so it fell through to the
/{bucket} routers and ran as a PutObject into a bucket named "tables",
answering 2xx while the table kept its name.
- Answer any s3tables-signed request no S3 Tables route takes with 404
UnknownOperationException, before the health, domain and bucket routers.
- Route the AWS RenameTable REST call (newNamespaceName, newName, both
optional; versionToken).
- Honor versionToken on RenameTable, checked against the snapshot the claim
asserts so a commit between the two reads cannot slip a stale token through.
* s3tables: match the credential scope's service, not a region named s3tables
The unrouted-request fallback widens what a false match costs: an S3
client whose region is named s3tables had every request answered as an
unknown S3 Tables operation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3tables: refuse an empty newName or newNamespaceName on REST rename
An empty field was read as omitted, so {"newNamespaceName":"other",
"newName":""} moved the table under its old name instead of failing
validation the way AWS does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3tables: check the rename version token after the snapshot comparison
Once the separately read metadata is known to equal the claimed snapshot,
the token compares against the parsed metadata; no second decode of the
snapshot bytes is needed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3tables test: cover policy and tag changes in the rename snapshot compare
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3tables test: give each REST rename subtest its own source table
The token subtests renamed the table the first subtest produced, so run
alone they failed on a missing table instead of testing the token.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
8 files changed
+1027
-6
No files matched your search
@@ -336,6 +336,44 @@ func (c *S3TablesClient) DeleteTable(bucketARN string, namespace []string, name
|
||||
return c.doRestRequestAndDecode("DeleteTable", http.MethodDelete, path, nil, nil)
|
||||
}
|
||||
|
||||
// RenameTable renames a table through the AWS REST shape: the path names the
|
||||
// source and the body the destination, where an empty newNamespace or newName
|
||||
// keeps the source's. Any 2xx is success, as for the SDK (AWS answers 204).
|
||||
func (c *S3TablesClient) RenameTable(bucketARN string, namespace []string, name, newNamespace, newName, versionToken string) error {
|
||||
nameSpace, err := getFirstNamespace(namespace)
|
||||
if err != nil {
|
||||
return fmt.Errorf("RenameTable requires namespace: %w", err)
|
||||
}
|
||||
body := map[string]string{}
|
||||
if newNamespace != "" {
|
||||
body["newNamespaceName"] = newNamespace
|
||||
}
|
||||
if newName != "" {
|
||||
body["newName"] = newName
|
||||
}
|
||||
if versionToken != "" {
|
||||
body["versionToken"] = versionToken
|
||||
}
|
||||
path := "/tables/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(nameSpace) + "/" + url.PathEscape(name) + "/rename"
|
||||
resp, err := c.doRestRequest(http.MethodPut, path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, readErr := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("RenameTable failed with status %d and could not read error response body: %v", resp.StatusCode, readErr)
|
||||
}
|
||||
var errResp s3tables.S3TablesError
|
||||
if err := json.Unmarshal(bodyBytes, &errResp); err != nil {
|
||||
return fmt.Errorf("RenameTable failed with status %d, could not decode error response: %v. Body: %s", resp.StatusCode, err, string(bodyBytes))
|
||||
}
|
||||
return fmt.Errorf("RenameTable failed: %s - %s", errResp.Type, errResp.Message)
|
||||
}
|
||||
|
||||
// Policy operations
|
||||
|
||||
func (c *S3TablesClient) PutTableBucketPolicy(bucketARN, policy string) error {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package s3tables
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
// emptyObjectBucket deletes every object in bucket.
|
||||
func emptyObjectBucket(t *testing.T, ctx context.Context, client *s3.Client, bucket string) {
|
||||
t.Helper()
|
||||
pages := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{Bucket: aws.String(bucket)})
|
||||
for pages.HasMorePages() {
|
||||
page, err := pages.NextPage(ctx)
|
||||
require.NoError(t, err)
|
||||
for _, o := range page.Contents {
|
||||
_, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: o.Key})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The AWS SDK sends RenameTable as PUT /tables/<arn>/<ns>/<name>/rename. With
|
||||
// no route for it the gateway ran it as a PutObject into an S3 bucket named
|
||||
// "tables" and answered 2xx, so the rename reported success while the table
|
||||
// kept its old name. The bucket is created up front so that failure shows as
|
||||
// an object landing in it.
|
||||
func TestRenameTableREST(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
ctx := context.Background()
|
||||
tables := NewS3TablesClient(sharedCluster.s3Endpoint, testRegion, testAccessKey, testSecretKey)
|
||||
objects := newObjectClient(t)
|
||||
|
||||
const decoyBucket = "tables"
|
||||
if _, err := objects.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(decoyBucket)}); err != nil {
|
||||
require.Equal(t, "BucketAlreadyOwnedByYou", apiErrorCode(err), "create the %q bucket: %v", decoyBucket, err)
|
||||
}
|
||||
emptyObjectBucket(t, ctx, objects, decoyBucket)
|
||||
defer func() {
|
||||
emptyObjectBucket(t, ctx, objects, decoyBucket)
|
||||
_, _ = objects.DeleteBucket(ctx, &s3.DeleteBucketInput{Bucket: aws.String(decoyBucket)})
|
||||
}()
|
||||
|
||||
created, err := tables.CreateTableBucket("rename-rest-"+randomString(8), nil)
|
||||
require.NoError(t, err)
|
||||
defer tables.DeleteTableBucket(created.ARN)
|
||||
bucketARN := created.ARN
|
||||
|
||||
const namespace, otherNamespace = "rename_ns", "rename_other"
|
||||
for _, ns := range []string{namespace, otherNamespace} {
|
||||
_, err := tables.CreateNamespace(bucketARN, []string{ns})
|
||||
require.NoError(t, err)
|
||||
defer tables.DeleteNamespace(bucketARN, []string{ns})
|
||||
}
|
||||
|
||||
// Each subtest owns its source table, so any one of them runs alone.
|
||||
createSource := func(t *testing.T, name string) string {
|
||||
resp, err := tables.CreateTable(bucketARN, []string{namespace}, name, "ICEBERG", nil, nil)
|
||||
require.NoError(t, err)
|
||||
return resp.VersionToken
|
||||
}
|
||||
|
||||
t.Run("newName only", func(t *testing.T) {
|
||||
createSource(t, "src")
|
||||
defer tables.DeleteTable(bucketARN, []string{namespace}, "src")
|
||||
defer tables.DeleteTable(bucketARN, []string{namespace}, "dst")
|
||||
require.NoError(t, tables.RenameTable(bucketARN, []string{namespace}, "src", "", "dst", ""))
|
||||
|
||||
got, err := tables.GetTable(bucketARN, []string{namespace}, "dst")
|
||||
require.NoError(t, err, "the renamed table must resolve under its new name")
|
||||
assert.Equal(t, "dst", got.Name)
|
||||
|
||||
_, err = tables.GetTable(bucketARN, []string{namespace}, "src")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), s3tables.ErrCodeNoSuchTable)
|
||||
|
||||
list, err := objects.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(decoyBucket)})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, list.Contents, "the rename must not store anything in the %q bucket", decoyBucket)
|
||||
})
|
||||
|
||||
t.Run("stale versionToken", func(t *testing.T) {
|
||||
token := createSource(t, "stale_src")
|
||||
defer tables.DeleteTable(bucketARN, []string{namespace}, "stale_src")
|
||||
defer tables.DeleteTable(bucketARN, []string{otherNamespace}, "stale_moved")
|
||||
err := tables.RenameTable(bucketARN, []string{namespace}, "stale_src", otherNamespace, "stale_moved", token+"-stale")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), s3tables.ErrCodeConflict)
|
||||
|
||||
_, err = tables.GetTable(bucketARN, []string{namespace}, "stale_src")
|
||||
require.NoError(t, err, "a refused rename must leave the source in place")
|
||||
})
|
||||
|
||||
t.Run("newNamespaceName and newName with the current versionToken", func(t *testing.T) {
|
||||
token := createSource(t, "tok_src")
|
||||
defer tables.DeleteTable(bucketARN, []string{namespace}, "tok_src")
|
||||
defer tables.DeleteTable(bucketARN, []string{otherNamespace}, "moved")
|
||||
require.NoError(t, tables.RenameTable(bucketARN, []string{namespace}, "tok_src", otherNamespace, "moved", token))
|
||||
|
||||
got, err := tables.GetTable(bucketARN, []string{otherNamespace}, "moved")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "moved", got.Name)
|
||||
|
||||
_, err = tables.GetTable(bucketARN, []string{namespace}, "tok_src")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), s3tables.ErrCodeNoSuchTable)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user