diff --git a/test/s3tables/client.go b/test/s3tables/client.go index cf3f689cc..3bde4eeb0 100644 --- a/test/s3tables/client.go +++ b/test/s3tables/client.go @@ -2,15 +2,58 @@ package s3tables import ( "bytes" + "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" "net/http" + "net/url" + "strconv" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" "github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" ) -func (c *S3TablesClient) doRequest(operation string, body interface{}) (*http.Response, error) { +func getFirstNamespace(namespace []string) (string, error) { + if len(namespace) == 0 { + return "", fmt.Errorf("namespace must not be empty") + } + return namespace[0], nil +} + +func (c *S3TablesClient) doRestRequest(method, path string, body interface{}) (*http.Response, error) { + var bodyBytes []byte + var err error + + if body != nil { + bodyBytes, err = json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + } + + req, err := http.NewRequest(method, c.endpoint+path, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + if body != nil { + req.Header.Set("Content-Type", "application/x-amz-json-1.1") + } + + if err := c.signRequest(req, bodyBytes); err != nil { + return nil, err + } + + return c.client.Do(req) +} + +func (c *S3TablesClient) doTargetRequest(operation string, body interface{}) (*http.Response, error) { var bodyBytes []byte var err error @@ -21,19 +64,65 @@ func (c *S3TablesClient) doRequest(operation string, body interface{}) (*http.Re } } - req, err := http.NewRequest(http.MethodPost, c.endpoint, bytes.NewReader(bodyBytes)) + req, err := http.NewRequest(http.MethodPost, c.endpoint+"/", bytes.NewReader(bodyBytes)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } + req.URL.RawPath = "/" req.Header.Set("Content-Type", "application/x-amz-json-1.1") req.Header.Set("X-Amz-Target", "S3Tables."+operation) + if err := c.signRequest(req, bodyBytes); err != nil { + return nil, err + } + return c.client.Do(req) } -func (c *S3TablesClient) doRequestAndDecode(operation string, reqBody interface{}, respBody interface{}) error { - resp, err := c.doRequest(operation, reqBody) +func (c *S3TablesClient) doTargetRequestAndDecode(operation string, reqBody interface{}, respBody interface{}) error { + resp, err := c.doTargetRequest(operation, reqBody) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("%s failed with status %d and could not read error response body: %v", operation, resp.StatusCode, readErr) + } + var errResp s3tables.S3TablesError + if err := json.Unmarshal(bodyBytes, &errResp); err != nil { + return fmt.Errorf("%s failed with status %d, could not decode error response: %v. Body: %s", operation, resp.StatusCode, err, string(bodyBytes)) + } + return fmt.Errorf("%s failed: %s - %s", operation, errResp.Type, errResp.Message) + } + + if respBody != nil { + if err := json.NewDecoder(resp.Body).Decode(respBody); err != nil { + return fmt.Errorf("failed to decode %s response: %w", operation, err) + } + } + + return nil +} + +func (c *S3TablesClient) signRequest(req *http.Request, body []byte) error { + creds := aws.Credentials{ + AccessKeyID: c.accessKey, + SecretAccessKey: c.secretKey, + } + if req.Host == "" { + req.Host = req.URL.Host + } + req.Header.Set("Host", req.URL.Host) + payloadHash := sha256.Sum256(body) + return v4.NewSigner().SignHTTP(context.Background(), creds, req, hex.EncodeToString(payloadHash[:]), "s3tables", c.region, time.Now()) +} + +func (c *S3TablesClient) doRestRequestAndDecode(operation, method, path string, reqBody interface{}, respBody interface{}) error { + resp, err := c.doRestRequest(method, path, reqBody) if err != nil { return err } @@ -68,233 +157,275 @@ func (c *S3TablesClient) CreateTableBucket(name string, tags map[string]string) Tags: tags, } var result s3tables.CreateTableBucketResponse - if err := c.doRequestAndDecode("CreateTableBucket", req, &result); err != nil { + if err := c.doRestRequestAndDecode("CreateTableBucket", http.MethodPut, "/buckets", req, &result); err != nil { return nil, err } return &result, nil } func (c *S3TablesClient) GetTableBucket(arn string) (*s3tables.GetTableBucketResponse, error) { - req := &s3tables.GetTableBucketRequest{ - TableBucketARN: arn, - } + path := "/buckets/" + url.PathEscape(arn) var result s3tables.GetTableBucketResponse - if err := c.doRequestAndDecode("GetTableBucket", req, &result); err != nil { + if err := c.doRestRequestAndDecode("GetTableBucket", http.MethodGet, path, nil, &result); err != nil { return nil, err } return &result, nil } func (c *S3TablesClient) ListTableBuckets(prefix, continuationToken string, maxBuckets int) (*s3tables.ListTableBucketsResponse, error) { - req := &s3tables.ListTableBucketsRequest{ - Prefix: prefix, - ContinuationToken: continuationToken, - MaxBuckets: maxBuckets, + query := url.Values{} + if prefix != "" { + query.Set("prefix", prefix) + } + if continuationToken != "" { + query.Set("continuationToken", continuationToken) + } + if maxBuckets > 0 { + query.Set("maxBuckets", strconv.Itoa(maxBuckets)) + } + path := "/buckets" + if encoded := query.Encode(); encoded != "" { + path = path + "?" + encoded } var result s3tables.ListTableBucketsResponse - if err := c.doRequestAndDecode("ListTableBuckets", req, &result); err != nil { + if err := c.doRestRequestAndDecode("ListTableBuckets", http.MethodGet, path, nil, &result); err != nil { return nil, err } return &result, nil } func (c *S3TablesClient) DeleteTableBucket(arn string) error { - req := &s3tables.DeleteTableBucketRequest{ - TableBucketARN: arn, - } - return c.doRequestAndDecode("DeleteTableBucket", req, nil) + path := "/buckets/" + url.PathEscape(arn) + return c.doRestRequestAndDecode("DeleteTableBucket", http.MethodDelete, path, nil, nil) } // Namespace operations func (c *S3TablesClient) CreateNamespace(bucketARN string, namespace []string) (*s3tables.CreateNamespaceResponse, error) { + if len(namespace) == 0 { + return nil, fmt.Errorf("CreateNamespace requires namespace") + } req := &s3tables.CreateNamespaceRequest{ - TableBucketARN: bucketARN, - Namespace: namespace, + Namespace: namespace, } + path := "/namespaces/" + url.PathEscape(bucketARN) var result s3tables.CreateNamespaceResponse - if err := c.doRequestAndDecode("CreateNamespace", req, &result); err != nil { + if err := c.doRestRequestAndDecode("CreateNamespace", http.MethodPut, path, req, &result); err != nil { return nil, err } return &result, nil } func (c *S3TablesClient) GetNamespace(bucketARN string, namespace []string) (*s3tables.GetNamespaceResponse, error) { - req := &s3tables.GetNamespaceRequest{ - TableBucketARN: bucketARN, - Namespace: namespace, + name, err := getFirstNamespace(namespace) + if err != nil { + return nil, fmt.Errorf("GetNamespace requires namespace: %w", err) } + path := "/namespaces/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(name) var result s3tables.GetNamespaceResponse - if err := c.doRequestAndDecode("GetNamespace", req, &result); err != nil { + if err := c.doRestRequestAndDecode("GetNamespace", http.MethodGet, path, nil, &result); err != nil { return nil, err } return &result, nil } func (c *S3TablesClient) ListNamespaces(bucketARN, prefix, continuationToken string, maxNamespaces int) (*s3tables.ListNamespacesResponse, error) { - req := &s3tables.ListNamespacesRequest{ - TableBucketARN: bucketARN, - Prefix: prefix, - ContinuationToken: continuationToken, - MaxNamespaces: maxNamespaces, + query := url.Values{} + if prefix != "" { + query.Set("prefix", prefix) + } + if continuationToken != "" { + query.Set("continuationToken", continuationToken) + } + if maxNamespaces > 0 { + query.Set("maxNamespaces", strconv.Itoa(maxNamespaces)) + } + path := "/namespaces/" + url.PathEscape(bucketARN) + if encoded := query.Encode(); encoded != "" { + path = path + "?" + encoded } var result s3tables.ListNamespacesResponse - if err := c.doRequestAndDecode("ListNamespaces", req, &result); err != nil { + if err := c.doRestRequestAndDecode("ListNamespaces", http.MethodGet, path, nil, &result); err != nil { return nil, err } return &result, nil } func (c *S3TablesClient) DeleteNamespace(bucketARN string, namespace []string) error { - req := &s3tables.DeleteNamespaceRequest{ - TableBucketARN: bucketARN, - Namespace: namespace, + name, err := getFirstNamespace(namespace) + if err != nil { + return fmt.Errorf("DeleteNamespace requires namespace: %w", err) } - return c.doRequestAndDecode("DeleteNamespace", req, nil) + path := "/namespaces/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(name) + return c.doRestRequestAndDecode("DeleteNamespace", http.MethodDelete, path, nil, nil) } // Table operations func (c *S3TablesClient) CreateTable(bucketARN string, namespace []string, name, format string, metadata *s3tables.TableMetadata, tags map[string]string) (*s3tables.CreateTableResponse, error) { + nameSpace, err := getFirstNamespace(namespace) + if err != nil { + return nil, fmt.Errorf("CreateTable requires namespace: %w", err) + } req := &s3tables.CreateTableRequest{ - TableBucketARN: bucketARN, - Namespace: namespace, - Name: name, - Format: format, - Metadata: metadata, - Tags: tags, + Name: name, + Format: format, + Metadata: metadata, + Tags: tags, } + path := "/tables/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(nameSpace) var result s3tables.CreateTableResponse - if err := c.doRequestAndDecode("CreateTable", req, &result); err != nil { + if err := c.doRestRequestAndDecode("CreateTable", http.MethodPut, path, req, &result); err != nil { return nil, err } return &result, nil } func (c *S3TablesClient) GetTable(bucketARN string, namespace []string, name string) (*s3tables.GetTableResponse, error) { - req := &s3tables.GetTableRequest{ - TableBucketARN: bucketARN, - Namespace: namespace, - Name: name, + nameSpace, err := getFirstNamespace(namespace) + if err != nil { + return nil, fmt.Errorf("GetTable requires namespace: %w", err) } + query := url.Values{} + query.Set("tableBucketARN", bucketARN) + query.Set("namespace", nameSpace) + query.Set("name", name) + path := "/get-table?" + query.Encode() var result s3tables.GetTableResponse - if err := c.doRequestAndDecode("GetTable", req, &result); err != nil { + if err := c.doRestRequestAndDecode("GetTable", http.MethodGet, path, nil, &result); err != nil { return nil, err } return &result, nil } func (c *S3TablesClient) ListTables(bucketARN string, namespace []string, prefix, continuationToken string, maxTables int) (*s3tables.ListTablesResponse, error) { - req := &s3tables.ListTablesRequest{ - TableBucketARN: bucketARN, - Namespace: namespace, - Prefix: prefix, - ContinuationToken: continuationToken, - MaxTables: maxTables, + query := url.Values{} + if len(namespace) > 0 { + nameSpace, err := getFirstNamespace(namespace) + if err != nil { + return nil, fmt.Errorf("ListTables requires namespace: %w", err) + } + query.Set("namespace", nameSpace) + } + if prefix != "" { + query.Set("prefix", prefix) + } + if continuationToken != "" { + query.Set("continuationToken", continuationToken) + } + if maxTables > 0 { + query.Set("maxTables", strconv.Itoa(maxTables)) + } + path := "/tables/" + url.PathEscape(bucketARN) + if encoded := query.Encode(); encoded != "" { + path = path + "?" + encoded } var result s3tables.ListTablesResponse - if err := c.doRequestAndDecode("ListTables", req, &result); err != nil { + if err := c.doRestRequestAndDecode("ListTables", http.MethodGet, path, nil, &result); err != nil { return nil, err } return &result, nil } func (c *S3TablesClient) DeleteTable(bucketARN string, namespace []string, name string) error { - req := &s3tables.DeleteTableRequest{ - TableBucketARN: bucketARN, - Namespace: namespace, - Name: name, + nameSpace, err := getFirstNamespace(namespace) + if err != nil { + return fmt.Errorf("DeleteTable requires namespace: %w", err) } - return c.doRequestAndDecode("DeleteTable", req, nil) + path := "/tables/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(nameSpace) + "/" + url.PathEscape(name) + return c.doRestRequestAndDecode("DeleteTable", http.MethodDelete, path, nil, nil) } // Policy operations func (c *S3TablesClient) PutTableBucketPolicy(bucketARN, policy string) error { req := &s3tables.PutTableBucketPolicyRequest{ - TableBucketARN: bucketARN, ResourcePolicy: policy, } - return c.doRequestAndDecode("PutTableBucketPolicy", req, nil) + path := "/buckets/" + url.PathEscape(bucketARN) + "/policy" + return c.doRestRequestAndDecode("PutTableBucketPolicy", http.MethodPut, path, req, nil) } func (c *S3TablesClient) GetTableBucketPolicy(bucketARN string) (*s3tables.GetTableBucketPolicyResponse, error) { - req := &s3tables.GetTableBucketPolicyRequest{ - TableBucketARN: bucketARN, - } + path := "/buckets/" + url.PathEscape(bucketARN) + "/policy" var result s3tables.GetTableBucketPolicyResponse - if err := c.doRequestAndDecode("GetTableBucketPolicy", req, &result); err != nil { + if err := c.doRestRequestAndDecode("GetTableBucketPolicy", http.MethodGet, path, nil, &result); err != nil { return nil, err } return &result, nil } func (c *S3TablesClient) DeleteTableBucketPolicy(bucketARN string) error { - req := &s3tables.DeleteTableBucketPolicyRequest{ - TableBucketARN: bucketARN, - } - return c.doRequestAndDecode("DeleteTableBucketPolicy", req, nil) + path := "/buckets/" + url.PathEscape(bucketARN) + "/policy" + return c.doRestRequestAndDecode("DeleteTableBucketPolicy", http.MethodDelete, path, nil, nil) } // Table Policy operations func (c *S3TablesClient) PutTablePolicy(bucketARN string, namespace []string, name, policy string) error { + nameSpace, err := getFirstNamespace(namespace) + if err != nil { + return fmt.Errorf("PutTablePolicy requires namespace: %w", err) + } req := &s3tables.PutTablePolicyRequest{ - TableBucketARN: bucketARN, - Namespace: namespace, - Name: name, ResourcePolicy: policy, } - return c.doRequestAndDecode("PutTablePolicy", req, nil) + path := "/tables/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(nameSpace) + "/" + url.PathEscape(name) + "/policy" + return c.doRestRequestAndDecode("PutTablePolicy", http.MethodPut, path, req, nil) } func (c *S3TablesClient) GetTablePolicy(bucketARN string, namespace []string, name string) (*s3tables.GetTablePolicyResponse, error) { - req := &s3tables.GetTablePolicyRequest{ - TableBucketARN: bucketARN, - Namespace: namespace, - Name: name, + nameSpace, err := getFirstNamespace(namespace) + if err != nil { + return nil, fmt.Errorf("GetTablePolicy requires namespace: %w", err) } + path := "/tables/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(nameSpace) + "/" + url.PathEscape(name) + "/policy" var result s3tables.GetTablePolicyResponse - if err := c.doRequestAndDecode("GetTablePolicy", req, &result); err != nil { + if err := c.doRestRequestAndDecode("GetTablePolicy", http.MethodGet, path, nil, &result); err != nil { return nil, err } return &result, nil } func (c *S3TablesClient) DeleteTablePolicy(bucketARN string, namespace []string, name string) error { - req := &s3tables.DeleteTablePolicyRequest{ - TableBucketARN: bucketARN, - Namespace: namespace, - Name: name, + nameSpace, err := getFirstNamespace(namespace) + if err != nil { + return fmt.Errorf("DeleteTablePolicy requires namespace: %w", err) } - return c.doRequestAndDecode("DeleteTablePolicy", req, nil) + path := "/tables/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(nameSpace) + "/" + url.PathEscape(name) + "/policy" + return c.doRestRequestAndDecode("DeleteTablePolicy", http.MethodDelete, path, nil, nil) } // Tagging operations func (c *S3TablesClient) TagResource(resourceARN string, tags map[string]string) error { req := &s3tables.TagResourceRequest{ - ResourceARN: resourceARN, - Tags: tags, + Tags: tags, } - return c.doRequestAndDecode("TagResource", req, nil) + path := "/tag/" + url.PathEscape(resourceARN) + return c.doRestRequestAndDecode("TagResource", http.MethodPost, path, req, nil) } func (c *S3TablesClient) ListTagsForResource(resourceARN string) (*s3tables.ListTagsForResourceResponse, error) { - req := &s3tables.ListTagsForResourceRequest{ - ResourceARN: resourceARN, - } + path := "/tag/" + url.PathEscape(resourceARN) var result s3tables.ListTagsForResourceResponse - if err := c.doRequestAndDecode("ListTagsForResource", req, &result); err != nil { + if err := c.doRestRequestAndDecode("ListTagsForResource", http.MethodGet, path, nil, &result); err != nil { return nil, err } return &result, nil } func (c *S3TablesClient) UntagResource(resourceARN string, tagKeys []string) error { - req := &s3tables.UntagResourceRequest{ - ResourceARN: resourceARN, - TagKeys: tagKeys, + if len(tagKeys) == 0 { + return fmt.Errorf("tagKeys cannot be empty") + } + query := url.Values{} + for _, key := range tagKeys { + query.Add("tagKeys", key) + } + path := "/tag/" + url.PathEscape(resourceARN) + if encoded := query.Encode(); encoded != "" { + path = path + "?" + encoded } - return c.doRequestAndDecode("UntagResource", req, nil) + return c.doRestRequestAndDecode("UntagResource", http.MethodDelete, path, nil, nil) } diff --git a/test/s3tables/s3tables_integration_test.go b/test/s3tables/s3tables_integration_test.go index 475182853..660957391 100644 --- a/test/s3tables/s3tables_integration_test.go +++ b/test/s3tables/s3tables_integration_test.go @@ -64,6 +64,10 @@ func TestS3TablesIntegration(t *testing.T) { t.Run("Tagging", func(t *testing.T) { testTagging(t, client) }) + + t.Run("TargetOperations", func(t *testing.T) { + testTargetOperations(t, client) + }) } func testTableBucketLifecycle(t *testing.T, client *S3TablesClient) { @@ -355,6 +359,125 @@ func testTagging(t *testing.T, client *S3TablesClient) { t.Logf("✓ Verified tag removal") } +func testTargetOperations(t *testing.T, client *S3TablesClient) { + bucketName := "test-target-bucket-" + randomString(8) + + var createResp s3tables.CreateTableBucketResponse + err := client.doTargetRequestAndDecode("CreateTableBucket", &s3tables.CreateTableBucketRequest{ + Name: bucketName, + }, &createResp) + require.NoError(t, err, "Failed to create table bucket via target") + defer client.doTargetRequestAndDecode("DeleteTableBucket", &s3tables.DeleteTableBucketRequest{ + TableBucketARN: createResp.ARN, + }, nil) + + var listResp s3tables.ListTableBucketsResponse + err = client.doTargetRequestAndDecode("ListTableBuckets", &s3tables.ListTableBucketsRequest{}, &listResp) + require.NoError(t, err, "Failed to list table buckets via target") + found := false + for _, b := range listResp.TableBuckets { + if b.Name == bucketName { + found = true + break + } + } + assert.True(t, found, "Created bucket should appear in target list") + + var getResp s3tables.GetTableBucketResponse + err = client.doTargetRequestAndDecode("GetTableBucket", &s3tables.GetTableBucketRequest{ + TableBucketARN: createResp.ARN, + }, &getResp) + require.NoError(t, err, "Failed to get table bucket via target") + assert.Equal(t, bucketName, getResp.Name) + + namespaceName := "target_ns" + var createNsResp s3tables.CreateNamespaceResponse + err = client.doTargetRequestAndDecode("CreateNamespace", &s3tables.CreateNamespaceRequest{ + TableBucketARN: createResp.ARN, + Namespace: []string{namespaceName}, + }, &createNsResp) + require.NoError(t, err, "Failed to create namespace via target") + defer client.doTargetRequestAndDecode("DeleteNamespace", &s3tables.DeleteNamespaceRequest{ + TableBucketARN: createResp.ARN, + Namespace: []string{namespaceName}, + }, nil) + + var listNsResp s3tables.ListNamespacesResponse + err = client.doTargetRequestAndDecode("ListNamespaces", &s3tables.ListNamespacesRequest{ + TableBucketARN: createResp.ARN, + }, &listNsResp) + require.NoError(t, err, "Failed to list namespaces via target") + + tableName := "target_table" + var createTableResp s3tables.CreateTableResponse + err = client.doTargetRequestAndDecode("CreateTable", &s3tables.CreateTableRequest{ + TableBucketARN: createResp.ARN, + Namespace: []string{namespaceName}, + Name: tableName, + Format: "ICEBERG", + }, &createTableResp) + require.NoError(t, err, "Failed to create table via target") + defer client.doTargetRequestAndDecode("DeleteTable", &s3tables.DeleteTableRequest{ + TableBucketARN: createResp.ARN, + Namespace: []string{namespaceName}, + Name: tableName, + }, nil) + + var listTablesResp s3tables.ListTablesResponse + err = client.doTargetRequestAndDecode("ListTables", &s3tables.ListTablesRequest{ + TableBucketARN: createResp.ARN, + Namespace: []string{namespaceName}, + }, &listTablesResp) + require.NoError(t, err, "Failed to list tables via target") + + var getTableResp s3tables.GetTableResponse + err = client.doTargetRequestAndDecode("GetTable", &s3tables.GetTableRequest{ + TableBucketARN: createResp.ARN, + Namespace: []string{namespaceName}, + Name: tableName, + }, &getTableResp) + require.NoError(t, err, "Failed to get table via target") + assert.Equal(t, tableName, getTableResp.Name) + + policy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3tables:*","Resource":"*"}]}` + err = client.doTargetRequestAndDecode("PutTableBucketPolicy", &s3tables.PutTableBucketPolicyRequest{ + TableBucketARN: createResp.ARN, + ResourcePolicy: policy, + }, nil) + require.NoError(t, err, "Failed to put bucket policy via target") + + var getPolicyResp s3tables.GetTableBucketPolicyResponse + err = client.doTargetRequestAndDecode("GetTableBucketPolicy", &s3tables.GetTableBucketPolicyRequest{ + TableBucketARN: createResp.ARN, + }, &getPolicyResp) + require.NoError(t, err, "Failed to get bucket policy via target") + assert.Equal(t, policy, getPolicyResp.ResourcePolicy) + + err = client.doTargetRequestAndDecode("DeleteTableBucketPolicy", &s3tables.DeleteTableBucketPolicyRequest{ + TableBucketARN: createResp.ARN, + }, nil) + require.NoError(t, err, "Failed to delete bucket policy via target") + + err = client.doTargetRequestAndDecode("TagResource", &s3tables.TagResourceRequest{ + ResourceARN: createResp.ARN, + Tags: map[string]string{"Environment": "test"}, + }, nil) + require.NoError(t, err, "Failed to tag resource via target") + + var listTagsResp s3tables.ListTagsForResourceResponse + err = client.doTargetRequestAndDecode("ListTagsForResource", &s3tables.ListTagsForResourceRequest{ + ResourceARN: createResp.ARN, + }, &listTagsResp) + require.NoError(t, err, "Failed to list tags via target") + assert.Equal(t, "test", listTagsResp.Tags["Environment"]) + + err = client.doTargetRequestAndDecode("UntagResource", &s3tables.UntagResourceRequest{ + ResourceARN: createResp.ARN, + TagKeys: []string{"Environment"}, + }, nil) + require.NoError(t, err, "Failed to untag resource via target") +} + // Helper functions // findAvailablePort finds an available port by binding to port 0 diff --git a/weed/s3api/auth_signature_v4.go b/weed/s3api/auth_signature_v4.go index 6c537dc70..bf07df8c1 100644 --- a/weed/s3api/auth_signature_v4.go +++ b/weed/s3api/auth_signature_v4.go @@ -26,6 +26,7 @@ import ( "io" "net" "net/http" + "net/url" "regexp" "sort" "strconv" @@ -284,8 +285,12 @@ func (iam *IdentityAccessManagement) verifyV4Signature(r *http.Request, shouldCh } // 8. Verify the signature, trying with X-Forwarded-Prefix first + pathForSignature := r.URL.EscapedPath() + if pathForSignature == "" { + pathForSignature = r.URL.Path + } if forwardedPrefix := r.Header.Get("X-Forwarded-Prefix"); forwardedPrefix != "" { - cleanedPath := buildPathWithForwardedPrefix(forwardedPrefix, r.URL.Path) + cleanedPath := buildPathWithForwardedPrefix(forwardedPrefix, pathForSignature) calculatedSignature, errCode = verify(cleanedPath) if errCode == s3err.ErrNone { return identity, cred, calculatedSignature, authInfo, s3err.ErrNone @@ -293,12 +298,20 @@ func (iam *IdentityAccessManagement) verifyV4Signature(r *http.Request, shouldCh } // 9. Verify with the original path - calculatedSignature, errCode = verify(r.URL.Path) - if errCode != s3err.ErrNone { - return nil, nil, "", nil, errCode + calculatedSignature, errCode = verify(pathForSignature) + if errCode == s3err.ErrNone { + return identity, cred, calculatedSignature, authInfo, s3err.ErrNone + } + + // 10. Retry with decoded path if signature used raw path encoding + if decodedPath, decodeErr := url.PathUnescape(pathForSignature); decodeErr == nil && decodedPath != pathForSignature { + calculatedSignature, errCode = verify(decodedPath) + if errCode == s3err.ErrNone { + return identity, cred, calculatedSignature, authInfo, s3err.ErrNone + } } - return identity, cred, calculatedSignature, authInfo, s3err.ErrNone + return nil, nil, "", nil, errCode } // validateSTSSessionToken validates an STS session token and extracts temporary credentials @@ -464,7 +477,7 @@ func extractV4AuthInfoFromHeader(r *http.Request) (*v4AuthInfo, s3err.ErrorCode) } hashedPayload := getContentSha256Cksum(r) - if signV4Values.Credential.scope.service != "s3" && hashedPayload == emptySHA256 && r.Body != nil { + if signV4Values.Credential.scope.service != "s3" && signV4Values.Credential.scope.service != "s3tables" && hashedPayload == emptySHA256 && r.Body != nil { var hashErr error hashedPayload, hashErr = streamHashRequestBody(r, iamRequestBodyLimit) if hashErr != nil { diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index 53b0ae13f..0431d5f09 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -428,6 +428,11 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) { // API Router apiRouter := router.PathPrefix("/").Subrouter() + // S3 Tables API endpoint + // POST / with X-Amz-Target: S3Tables. + // plus REST-style endpoints for AWS CLI + s3a.registerS3TablesRoutes(apiRouter) + // Readiness Probe apiRouter.Methods(http.MethodGet).Path("/status").HandlerFunc(s3a.StatusHandler) apiRouter.Methods(http.MethodGet).Path("/healthz").HandlerFunc(s3a.StatusHandler) @@ -658,10 +663,6 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) { } }) - // S3 Tables API endpoint - // POST / with X-Amz-Target: S3Tables. - s3a.registerS3TablesRoutes(apiRouter) - // STS API endpoint for AssumeRoleWithWebIdentity // POST /?Action=AssumeRoleWithWebIdentity&WebIdentityToken=... if s3a.stsHandlers != nil { diff --git a/weed/s3api/s3api_tables.go b/weed/s3api/s3api_tables.go index 298e3012f..ffd3a9fe1 100644 --- a/weed/s3api/s3api_tables.go +++ b/weed/s3api/s3api_tables.go @@ -1,7 +1,13 @@ package s3api import ( + "bytes" + "encoding/json" + "fmt" + "io" "net/http" + "net/url" + "strconv" "strings" "github.com/gorilla/mux" @@ -13,31 +19,6 @@ import ( "github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" ) -// s3TablesActionsMap contains all valid S3 Tables operations for O(1) lookup -var s3TablesActionsMap = map[string]struct{}{ - "CreateTableBucket": {}, - "GetTableBucket": {}, - "ListTableBuckets": {}, - "DeleteTableBucket": {}, - "PutTableBucketPolicy": {}, - "GetTableBucketPolicy": {}, - "DeleteTableBucketPolicy": {}, - "CreateNamespace": {}, - "GetNamespace": {}, - "ListNamespaces": {}, - "DeleteNamespace": {}, - "CreateTable": {}, - "GetTable": {}, - "ListTables": {}, - "DeleteTable": {}, - "PutTablePolicy": {}, - "GetTablePolicy": {}, - "DeleteTablePolicy": {}, - "TagResource": {}, - "ListTagsForResource": {}, - "UntagResource": {}, -} - // S3TablesApiServer wraps the S3 Tables handler with S3ApiServer's filer access type S3TablesApiServer struct { s3a *S3ApiServer @@ -77,41 +58,570 @@ func (s3a *S3ApiServer) registerS3TablesRoutes(router *mux.Router) { // Create S3 Tables handler s3TablesApi := NewS3TablesApiServer(s3a) - // S3 Tables API uses POST with x-amz-target header - // The AWS CLI sends requests with: - // - Content-Type: application/x-amz-json-1.1 - // - X-Amz-Target: S3Tables. - - // Matcher function to identify S3 Tables requests - s3TablesMatcher := func(r *http.Request, rm *mux.RouteMatch) bool { - // Check for X-Amz-Target header with S3Tables prefix - target := r.Header.Get("X-Amz-Target") - if target != "" && strings.HasPrefix(target, "S3Tables.") { - return true + // REST-style S3 Tables API routes (used by AWS CLI) + targetMatcher := func(r *http.Request, rm *mux.RouteMatch) bool { + return strings.HasPrefix(r.Header.Get("X-Amz-Target"), "S3Tables.") + } + router.Methods(http.MethodPost).Path("/").MatcherFunc(targetMatcher). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.S3TablesHandler), "S3Tables-Target")) + router.Methods(http.MethodPut).Path("/buckets"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("CreateTableBucket", buildCreateTableBucketRequest)), "S3Tables-CreateTableBucket")) + router.Methods(http.MethodGet).Path("/buckets"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("ListTableBuckets", buildListTableBucketsRequest)), "S3Tables-ListTableBuckets")) + router.Methods(http.MethodGet).Path("/buckets/{tableBucketARN:arn:aws:s3tables:[^/]+:[^/]+:bucket/[^/]+}"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("GetTableBucket", buildTableBucketArnRequest)), "S3Tables-GetTableBucket")) + router.Methods(http.MethodDelete).Path("/buckets/{tableBucketARN:arn:aws:s3tables:[^/]+:[^/]+:bucket/[^/]+}"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("DeleteTableBucket", buildDeleteTableBucketRequest)), "S3Tables-DeleteTableBucket")) + router.Methods(http.MethodPut).Path("/buckets/{tableBucketARN:arn:aws:s3tables:[^/]+:[^/]+:bucket/[^/]+}/policy"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("PutTableBucketPolicy", buildPutTableBucketPolicyRequest)), "S3Tables-PutTableBucketPolicy")) + router.Methods(http.MethodGet).Path("/buckets/{tableBucketARN:arn:aws:s3tables:[^/]+:[^/]+:bucket/[^/]+}/policy"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("GetTableBucketPolicy", buildGetTableBucketPolicyRequest)), "S3Tables-GetTableBucketPolicy")) + router.Methods(http.MethodDelete).Path("/buckets/{tableBucketARN:arn:aws:s3tables:[^/]+:[^/]+:bucket/[^/]+}/policy"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("DeleteTableBucketPolicy", buildDeleteTableBucketPolicyRequest)), "S3Tables-DeleteTableBucketPolicy")) + + router.Methods(http.MethodPut).Path("/namespaces/{tableBucketARN:arn:aws:s3tables:[^/]+:[^/]+:bucket/[^/]+}"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("CreateNamespace", buildCreateNamespaceRequest)), "S3Tables-CreateNamespace")) + router.Methods(http.MethodGet).Path("/namespaces/{tableBucketARN:arn:aws:s3tables:[^/]+:[^/]+:bucket/[^/]+}"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("ListNamespaces", buildListNamespacesRequest)), "S3Tables-ListNamespaces")) + router.Methods(http.MethodGet).Path("/namespaces/{tableBucketARN:arn:aws:s3tables:[^/]+:[^/]+:bucket/[^/]+}/{namespace}"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("GetNamespace", buildGetNamespaceRequest)), "S3Tables-GetNamespace")) + router.Methods(http.MethodDelete).Path("/namespaces/{tableBucketARN:arn:aws:s3tables:[^/]+:[^/]+:bucket/[^/]+}/{namespace}"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("DeleteNamespace", buildDeleteNamespaceRequest)), "S3Tables-DeleteNamespace")) + + router.Methods(http.MethodPut).Path("/tables/{tableBucketARN:arn:aws:s3tables:[^/]+:[^/]+:bucket/[^/]+}/{namespace}"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("CreateTable", buildCreateTableRequest)), "S3Tables-CreateTable")) + router.Methods(http.MethodGet).Path("/tables/{tableBucketARN:arn:aws:s3tables:[^/]+:[^/]+:bucket/[^/]+}"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("ListTables", buildListTablesRequest)), "S3Tables-ListTables")) + router.Methods(http.MethodDelete).Path("/tables/{tableBucketARN:arn:aws:s3tables:[^/]+:[^/]+:bucket/[^/]+}/{namespace}/{name}"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("DeleteTable", buildDeleteTableRequest)), "S3Tables-DeleteTable")) + + router.Methods(http.MethodPut).Path("/tables/{tableBucketARN:arn:aws:s3tables:[^/]+:[^/]+:bucket/[^/]+}/{namespace}/{name}/policy"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("PutTablePolicy", buildPutTablePolicyRequest)), "S3Tables-PutTablePolicy")) + router.Methods(http.MethodGet).Path("/tables/{tableBucketARN:arn:aws:s3tables:[^/]+:[^/]+:bucket/[^/]+}/{namespace}/{name}/policy"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("GetTablePolicy", buildGetTablePolicyRequest)), "S3Tables-GetTablePolicy")) + router.Methods(http.MethodDelete).Path("/tables/{tableBucketARN:arn:aws:s3tables:[^/]+:[^/]+:bucket/[^/]+}/{namespace}/{name}/policy"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("DeleteTablePolicy", buildDeleteTablePolicyRequest)), "S3Tables-DeleteTablePolicy")) + + router.Methods(http.MethodPost).Path("/tag/{resourceArn:arn:aws:s3tables:.*}"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("TagResource", buildTagResourceRequest)), "S3Tables-TagResource")) + router.Methods(http.MethodGet).Path("/tag/{resourceArn:arn:aws:s3tables:.*}"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("ListTagsForResource", buildListTagsForResourceRequest)), "S3Tables-ListTagsForResource")) + router.Methods(http.MethodDelete).Path("/tag/{resourceArn:arn:aws:s3tables:.*}"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("UntagResource", buildUntagResourceRequest)), "S3Tables-UntagResource")) + + router.Methods(http.MethodGet).Path("/get-table"). + HandlerFunc(track(s3a.authenticateS3Tables(s3TablesApi.handleRestOperation("GetTable", buildGetTableRequest)), "S3Tables-GetTable")) + + glog.V(1).Infof("S3 Tables API enabled") +} + +type s3tablesRequestBuilder func(r *http.Request) (interface{}, error) + +func (st *S3TablesApiServer) handleRestOperation(operation string, builder s3tablesRequestBuilder) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + payload, err := builder(r) + if err != nil { + writeS3TablesError(w, http.StatusBadRequest, s3tables.ErrCodeInvalidRequest, err.Error()) + return } + if err := setS3TablesRequestBody(r, payload); err != nil { + writeS3TablesError(w, http.StatusInternalServerError, s3tables.ErrCodeInternalError, err.Error()) + return + } + r.Header.Set("X-Amz-Target", "S3Tables."+operation) + st.S3TablesHandler(w, r) + } +} + +func setS3TablesRequestBody(r *http.Request, payload interface{}) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + r.Body = io.NopCloser(bytes.NewReader(body)) + r.ContentLength = int64(len(body)) + r.Header.Set("Content-Type", "application/x-amz-json-1.1") + return nil +} + +func readS3TablesJSONBody(r *http.Request, v interface{}) error { + if r.Body == nil { + return nil + } + defer r.Body.Close() + const maxRequestBodySize = 10 * 1024 * 1024 + if r.ContentLength > maxRequestBodySize { + return fmt.Errorf("request body too large: exceeds maximum size of %d bytes", maxRequestBodySize) + } + limitedReader := io.LimitReader(r.Body, maxRequestBodySize+1) + body, err := io.ReadAll(limitedReader) + if err != nil { + return err + } + if len(body) > maxRequestBodySize { + return fmt.Errorf("request body too large: exceeds maximum size of %d bytes", maxRequestBodySize) + } + if len(bytes.TrimSpace(body)) == 0 { + return nil + } + return json.Unmarshal(body, v) +} + +func writeS3TablesError(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/x-amz-json-1.1") + w.WriteHeader(status) + errorResponse := map[string]interface{}{ + "__type": code, + "message": message, + } + if err := json.NewEncoder(w).Encode(errorResponse); err != nil { + glog.Errorf("failed to encode S3Tables error response (status=%d, code=%s, message=%q): %v", status, code, message, err) + } +} + +func getDecodedPathParam(r *http.Request, name string) (string, error) { + value := mux.Vars(r)[name] + if value == "" { + return "", nil + } + decoded, err := url.PathUnescape(value) + if err != nil { + return "", err + } + if decoded == ".." || strings.Contains(decoded, "../") || strings.Contains(decoded, `..\`) || strings.Contains(decoded, "\x00") { + return "", fmt.Errorf("invalid path parameter %s", name) + } + return decoded, nil +} + +func buildTableBucketRequestWithARN(r *http.Request, constructor func(string) interface{}) (interface{}, error) { + arn, err := getDecodedPathParam(r, "tableBucketARN") + if err != nil { + return nil, err + } + if arn == "" { + return nil, fmt.Errorf("tableBucketARN is required") + } + if _, err := s3tables.ParseBucketNameFromARN(arn); err != nil { + return nil, err + } + return constructor(arn), nil +} + +func parseOptionalIntParam(r *http.Request, name string) (int, error) { + value := r.URL.Query().Get(name) + if value == "" { + return 0, nil + } + parsed, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("%s must be an integer", name) + } + if parsed <= 0 { + return 0, fmt.Errorf("%s must be a positive integer", name) + } + return parsed, nil +} + +func parseOptionalNamespace(r *http.Request, name string) []string { + value := r.URL.Query().Get(name) + if value == "" { + return nil + } + if _, err := s3tables.ValidateNamespace([]string{value}); err != nil { + glog.V(1).Infof("invalid namespace value for %s: %q: %v", name, value, err) + return nil + } + return []string{value} +} - // Also check for specific S3 Tables actions in query string (CLI fallback) - action := r.URL.Query().Get("Action") - if isS3TablesAction(action) { - return true +// parseTagKeys handles tag key parsing from query parameters. +// If a single value contains commas, it is split into multiple keys (e.g., "key1,key2,key3"). +// Otherwise, multiple query values are returned as-is. +func parseTagKeys(values []string) []string { + if len(values) == 0 { + return nil + } + out := make([]string, 0, len(values)) + for _, value := range values { + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } } + } + if len(out) == 0 { + return nil + } + return out +} - return false +func buildCreateTableBucketRequest(r *http.Request) (interface{}, error) { + var req s3tables.CreateTableBucketRequest + if err := readS3TablesJSONBody(r, &req); err != nil { + return nil, err } + return &req, nil +} - // Register the S3 Tables handler wrapped with IAM authentication - router.Methods(http.MethodPost).Path("/").MatcherFunc(s3TablesMatcher). - HandlerFunc(track(s3a.authenticateS3Tables(func(w http.ResponseWriter, r *http.Request) { - s3TablesApi.S3TablesHandler(w, r) - }), "S3Tables")) +func buildListTableBucketsRequest(r *http.Request) (interface{}, error) { + maxBuckets, err := parseOptionalIntParam(r, "maxBuckets") + if err != nil { + return nil, err + } + return &s3tables.ListTableBucketsRequest{ + Prefix: r.URL.Query().Get("prefix"), + ContinuationToken: r.URL.Query().Get("continuationToken"), + MaxBuckets: maxBuckets, + }, nil +} - glog.V(1).Infof("S3 Tables API enabled") +func buildTableBucketArnRequest(r *http.Request) (interface{}, error) { + return buildTableBucketRequestWithARN(r, func(arn string) interface{} { + return &s3tables.GetTableBucketRequest{TableBucketARN: arn} + }) +} + +func buildDeleteTableBucketRequest(r *http.Request) (interface{}, error) { + return buildTableBucketRequestWithARN(r, func(arn string) interface{} { + return &s3tables.DeleteTableBucketRequest{TableBucketARN: arn} + }) +} + +func buildPutTableBucketPolicyRequest(r *http.Request) (interface{}, error) { + var req s3tables.PutTableBucketPolicyRequest + if err := readS3TablesJSONBody(r, &req); err != nil { + return nil, err + } + tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN") + if err != nil { + return nil, err + } + req.TableBucketARN = tableBucketARN + return &req, nil +} + +func buildGetTableBucketPolicyRequest(r *http.Request) (interface{}, error) { + return buildTableBucketRequestWithARN(r, func(arn string) interface{} { + return &s3tables.GetTableBucketPolicyRequest{TableBucketARN: arn} + }) +} + +func buildDeleteTableBucketPolicyRequest(r *http.Request) (interface{}, error) { + return buildTableBucketRequestWithARN(r, func(arn string) interface{} { + return &s3tables.DeleteTableBucketPolicyRequest{TableBucketARN: arn} + }) +} + +func buildCreateNamespaceRequest(r *http.Request) (interface{}, error) { + var req s3tables.CreateNamespaceRequest + if err := readS3TablesJSONBody(r, &req); err != nil { + return nil, err + } + tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN") + if err != nil { + return nil, err + } + req.TableBucketARN = tableBucketARN + return &req, nil +} + +func buildListNamespacesRequest(r *http.Request) (interface{}, error) { + tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN") + if err != nil { + return nil, err + } + maxNamespaces, err := parseOptionalIntParam(r, "maxNamespaces") + if err != nil { + return nil, err + } + return &s3tables.ListNamespacesRequest{ + TableBucketARN: tableBucketARN, + Prefix: r.URL.Query().Get("prefix"), + ContinuationToken: r.URL.Query().Get("continuationToken"), + MaxNamespaces: maxNamespaces, + }, nil } -// isS3TablesAction checks if the action is an S3 Tables operation using O(1) map lookup -func isS3TablesAction(action string) bool { - _, ok := s3TablesActionsMap[action] - return ok +func buildGetNamespaceRequest(r *http.Request) (interface{}, error) { + tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN") + if err != nil { + return nil, err + } + namespace, err := getDecodedPathParam(r, "namespace") + if err != nil { + return nil, err + } + if namespace == "" { + return nil, fmt.Errorf("namespace is required") + } + if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil { + return nil, err + } + return &s3tables.GetNamespaceRequest{ + TableBucketARN: tableBucketARN, + Namespace: []string{namespace}, + }, nil +} + +func buildDeleteNamespaceRequest(r *http.Request) (interface{}, error) { + tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN") + if err != nil { + return nil, err + } + namespace, err := getDecodedPathParam(r, "namespace") + if err != nil { + return nil, err + } + if namespace == "" { + return nil, fmt.Errorf("namespace is required") + } + if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil { + return nil, err + } + return &s3tables.DeleteNamespaceRequest{ + TableBucketARN: tableBucketARN, + Namespace: []string{namespace}, + }, nil +} + +func buildCreateTableRequest(r *http.Request) (interface{}, error) { + var req s3tables.CreateTableRequest + if err := readS3TablesJSONBody(r, &req); err != nil { + return nil, err + } + tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN") + if err != nil { + return nil, err + } + namespace, err := getDecodedPathParam(r, "namespace") + if err != nil { + return nil, err + } + if namespace == "" { + return nil, fmt.Errorf("namespace is required") + } + if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil { + return nil, err + } + req.TableBucketARN = tableBucketARN + req.Namespace = []string{namespace} + return &req, nil +} + +func buildListTablesRequest(r *http.Request) (interface{}, error) { + tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN") + if err != nil { + return nil, err + } + maxTables, err := parseOptionalIntParam(r, "maxTables") + if err != nil { + return nil, err + } + return &s3tables.ListTablesRequest{ + TableBucketARN: tableBucketARN, + Namespace: parseOptionalNamespace(r, "namespace"), + Prefix: r.URL.Query().Get("prefix"), + ContinuationToken: r.URL.Query().Get("continuationToken"), + MaxTables: maxTables, + }, nil +} + +func buildGetTableRequest(r *http.Request) (interface{}, error) { + query := r.URL.Query() + tableARN := query.Get("tableArn") + req := &s3tables.GetTableRequest{ + TableARN: tableARN, + } + if tableARN == "" { + req.TableBucketARN = query.Get("tableBucketARN") + req.Namespace = parseOptionalNamespace(r, "namespace") + req.Name = query.Get("name") + if req.TableBucketARN == "" || len(req.Namespace) == 0 || req.Name == "" { + return nil, fmt.Errorf("either tableArn or (tableBucketARN, namespace, name) must be provided") + } + } + return req, nil +} + +func buildDeleteTableRequest(r *http.Request) (interface{}, error) { + tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN") + if err != nil { + return nil, err + } + namespace, err := getDecodedPathParam(r, "namespace") + if err != nil { + return nil, err + } + if namespace == "" { + return nil, fmt.Errorf("namespace is required") + } + if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil { + return nil, err + } + name, err := getDecodedPathParam(r, "name") + if err != nil { + return nil, err + } + if name == "" { + return nil, fmt.Errorf("name is required") + } + if _, err := s3tables.ValidateTableName(name); err != nil { + return nil, err + } + return &s3tables.DeleteTableRequest{ + TableBucketARN: tableBucketARN, + Namespace: []string{namespace}, + Name: name, + VersionToken: r.URL.Query().Get("versionToken"), + }, nil +} + +func buildPutTablePolicyRequest(r *http.Request) (interface{}, error) { + var req s3tables.PutTablePolicyRequest + if err := readS3TablesJSONBody(r, &req); err != nil { + return nil, err + } + tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN") + if err != nil { + return nil, err + } + namespace, err := getDecodedPathParam(r, "namespace") + if err != nil { + return nil, err + } + if namespace == "" { + return nil, fmt.Errorf("namespace is required") + } + if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil { + return nil, err + } + name, err := getDecodedPathParam(r, "name") + if err != nil { + return nil, err + } + if name == "" { + return nil, fmt.Errorf("name is required") + } + if _, err := s3tables.ValidateTableName(name); err != nil { + return nil, err + } + req.TableBucketARN = tableBucketARN + req.Namespace = []string{namespace} + req.Name = name + return &req, nil +} + +func buildGetTablePolicyRequest(r *http.Request) (interface{}, error) { + tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN") + if err != nil { + return nil, err + } + namespace, err := getDecodedPathParam(r, "namespace") + if err != nil { + return nil, err + } + if namespace == "" { + return nil, fmt.Errorf("namespace is required") + } + if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil { + return nil, err + } + name, err := getDecodedPathParam(r, "name") + if err != nil { + return nil, err + } + if name == "" { + return nil, fmt.Errorf("name is required") + } + if _, err := s3tables.ValidateTableName(name); err != nil { + return nil, err + } + return &s3tables.GetTablePolicyRequest{ + TableBucketARN: tableBucketARN, + Namespace: []string{namespace}, + Name: name, + }, nil +} + +func buildDeleteTablePolicyRequest(r *http.Request) (interface{}, error) { + tableBucketARN, err := getDecodedPathParam(r, "tableBucketARN") + if err != nil { + return nil, err + } + namespace, err := getDecodedPathParam(r, "namespace") + if err != nil { + return nil, err + } + if namespace == "" { + return nil, fmt.Errorf("namespace is required") + } + if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil { + return nil, err + } + name, err := getDecodedPathParam(r, "name") + if err != nil { + return nil, err + } + if name == "" { + return nil, fmt.Errorf("name is required") + } + if _, err := s3tables.ValidateTableName(name); err != nil { + return nil, err + } + return &s3tables.DeleteTablePolicyRequest{ + TableBucketARN: tableBucketARN, + Namespace: []string{namespace}, + Name: name, + }, nil +} + +func buildTagResourceRequest(r *http.Request) (interface{}, error) { + var req s3tables.TagResourceRequest + if err := readS3TablesJSONBody(r, &req); err != nil { + return nil, err + } + resourceARN, err := getDecodedPathParam(r, "resourceArn") + if err != nil { + return nil, err + } + if resourceARN == "" { + return nil, fmt.Errorf("resourceArn is required") + } + req.ResourceARN = resourceARN + return &req, nil +} + +func buildListTagsForResourceRequest(r *http.Request) (interface{}, error) { + resourceARN, err := getDecodedPathParam(r, "resourceArn") + if err != nil { + return nil, err + } + if resourceARN == "" { + return nil, fmt.Errorf("resourceArn is required") + } + return &s3tables.ListTagsForResourceRequest{ + ResourceARN: resourceARN, + }, nil +} + +func buildUntagResourceRequest(r *http.Request) (interface{}, error) { + resourceARN, err := getDecodedPathParam(r, "resourceArn") + if err != nil { + return nil, err + } + if resourceARN == "" { + return nil, fmt.Errorf("resourceArn is required") + } + tagKeys := parseTagKeys(r.URL.Query()["tagKeys"]) + if len(tagKeys) == 0 { + return nil, fmt.Errorf("tagKeys is required for %s", resourceARN) + } + return &s3tables.UntagResourceRequest{ + ResourceARN: resourceARN, + TagKeys: tagKeys, + }, nil } // authenticateS3Tables wraps the handler with IAM authentication using AuthSignatureOnly diff --git a/weed/s3api/s3tables/handler.go b/weed/s3api/s3tables/handler.go index 3b5dd79a9..016e2a783 100644 --- a/weed/s3api/s3tables/handler.go +++ b/weed/s3api/s3tables/handler.go @@ -74,17 +74,16 @@ type FilerClient interface { // HandleRequest is the main entry point for S3 Tables API requests func (h *S3TablesHandler) HandleRequest(w http.ResponseWriter, r *http.Request, filerClient FilerClient) { - // S3 Tables API uses x-amz-target header to specify the operation - target := r.Header.Get("X-Amz-Target") - if target == "" { - // Try to get from query parameter for CLI compatibility - target = r.URL.Query().Get("Action") + operation := r.Header.Get("X-Amz-Target") + if operation != "" { + if idx := strings.LastIndex(operation, "."); idx != -1 { + operation = operation[idx+1:] + } } - - // Extract operation name (e.g., "S3Tables.CreateTableBucket" -> "CreateTableBucket") - operation := target - if idx := strings.LastIndex(target, "."); idx != -1 { - operation = target[idx+1:] + if operation == "" { + glog.V(1).Infof("S3Tables: missing X-Amz-Target header") + h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "Missing X-Amz-Target header") + return } glog.V(3).Infof("S3Tables: handling operation %s", operation) diff --git a/weed/s3api/s3tables/utils.go b/weed/s3api/s3tables/utils.go index fa60a9421..953bea34b 100644 --- a/weed/s3api/s3tables/utils.go +++ b/weed/s3api/s3tables/utils.go @@ -38,6 +38,11 @@ func parseBucketNameFromARN(arn string) (string, error) { return bucketName, nil } +// ParseBucketNameFromARN is a wrapper to validate bucket ARN for other packages. +func ParseBucketNameFromARN(arn string) (string, error) { + return parseBucketNameFromARN(arn) +} + // parseTableFromARN extracts bucket name, namespace, and table name from ARN // ARN format: arn:aws:s3tables:{region}:{account}:bucket/{bucket-name}/table/{namespace}/{table-name} func parseTableFromARN(arn string) (bucketName, namespace, tableName string, err error) { @@ -240,6 +245,11 @@ func validateNamespace(namespace []string) (string, error) { return name, nil } +// ValidateNamespace is a wrapper to validate namespace for other packages. +func ValidateNamespace(namespace []string) (string, error) { + return validateNamespace(namespace) +} + // validateTableName validates a table name func validateTableName(name string) (string, error) { if len(name) < 1 || len(name) > 255 { @@ -265,6 +275,11 @@ func validateTableName(name string) (string, error) { return name, nil } +// ValidateTableName is a wrapper to validate table name for other packages. +func ValidateTableName(name string) (string, error) { + return validateTableName(name) +} + // flattenNamespace joins namespace elements into a single string (using dots as per AWS S3 Tables) func flattenNamespace(namespace []string) string { if len(namespace) == 0 {