Browse Source
Add s3tables shell and admin UI (#8172)
Add s3tables shell and admin UI (#8172)
* Add shared s3tables manager * Add s3tables shell commands * Add s3tables admin API * Add s3tables admin UI * Fix admin s3tables namespace create * Rename table buckets menu * Centralize s3tables tag validation * Reuse s3tables manager in admin * Extract s3tables list limit * Add s3tables bucket ARN helper * Remove write middleware from s3tables APIs * Fix bucket link and policy hint * Fix table tag parsing and nav link * Disable namespace table link on invalid ARN * Improve s3tables error decode * Return flag parse errors for s3tables tag * Accept query params for namespace create * Bind namespace create form data * Read s3tables JS data from DOM * s3tables: allow empty region ARN * shell: pass s3tables account id * shell: require account for table buckets * shell: use bucket name for namespaces * shell: use bucket name for tables * shell: use bucket name for tags * admin: add table buckets links in file browser * s3api: reuse s3tables tag validation * admin: harden s3tables UI handlers * fix admin list table buckets * allow admin s3tables access * validate s3tables bucket tags * log s3tables bucket metadata errors * rollback table bucket on owner failure * show s3tables bucket owner * add s3tables iam conditions * Add s3tables user permissions UI * Authorize s3tables using identity actions * Add s3tables permissions to user modal * Disambiguate bucket scope in user permissions * Block table bucket names that match S3 buckets * Pretty-print IAM identity JSON * Include tags in s3tables permission context * admin: refactor S3 Tables inline JavaScript into a separate file * s3tables: extend IAM policy condition operators support * shell: use LookupEntry wrapper for s3tables bucket conflict check * admin: handle buildBucketPermissions validation in create/update flowspull/8173/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 4933 additions and 404 deletions
-
16weed/admin/README.md
-
4weed/admin/dash/admin_server.go
-
45weed/admin/dash/file_browser_data.go
-
24weed/admin/dash/file_browser_data_test.go
-
605weed/admin/dash/s3tables_management.go
-
139weed/admin/handlers/admin_handlers.go
-
479weed/admin/static/js/s3tables.js
-
15weed/admin/view/app/file_browser.templ
-
408weed/admin/view/app/file_browser_templ.go
-
197weed/admin/view/app/object_store_users.templ
-
2weed/admin/view/app/object_store_users_templ.go
-
275weed/admin/view/app/s3tables_buckets.templ
-
222weed/admin/view/app/s3tables_buckets_templ.go
-
242weed/admin/view/app/s3tables_namespaces.templ
-
198weed/admin/view/app/s3tables_namespaces_templ.go
-
294weed/admin/view/app/s3tables_tables.templ
-
317weed/admin/view/app/s3tables_tables_templ.go
-
8weed/admin/view/layout/layout.templ
-
24weed/admin/view/layout/layout_templ.go
-
2weed/credential/filer_etc/filer_etc_identity.go
-
2weed/credential/filer_etc/filer_etc_service_account.go
-
66weed/s3api/s3tables/handler.go
-
36weed/s3api/s3tables/handler_bucket_create.go
-
42weed/s3api/s3tables/handler_bucket_get_list_delete.go
-
58weed/s3api/s3tables/handler_namespace.go
-
146weed/s3api/s3tables/handler_policy.go
-
163weed/s3api/s3tables/handler_table.go
-
98weed/s3api/s3tables/manager.go
-
181weed/s3api/s3tables/permissions.go
-
120weed/s3api/s3tables/permissions_test.go
-
75weed/s3api/s3tables/utils.go
-
24weed/s3api/tags.go
-
254weed/shell/command_s3tables_bucket.go
-
131weed/shell/command_s3tables_namespace.go
-
205weed/shell/command_s3tables_table.go
-
131weed/shell/command_s3tables_tag.go
-
89weed/shell/s3tables_helpers.go
@ -0,0 +1,605 @@ |
|||
package dash |
|||
|
|||
import ( |
|||
"context" |
|||
"encoding/json" |
|||
"errors" |
|||
"fmt" |
|||
"io" |
|||
"net/http" |
|||
"strings" |
|||
"time" |
|||
|
|||
"github.com/gin-gonic/gin" |
|||
"github.com/seaweedfs/seaweedfs/weed/glog" |
|||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" |
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" |
|||
) |
|||
|
|||
// S3Tables data structures for admin UI
|
|||
|
|||
type S3TablesBucketsData struct { |
|||
Username string `json:"username"` |
|||
Buckets []S3TablesBucketSummary `json:"buckets"` |
|||
TotalBuckets int `json:"total_buckets"` |
|||
LastUpdated time.Time `json:"last_updated"` |
|||
} |
|||
|
|||
type S3TablesBucketSummary struct { |
|||
ARN string `json:"arn"` |
|||
Name string `json:"name"` |
|||
OwnerAccountID string `json:"ownerAccountId"` |
|||
CreatedAt time.Time `json:"createdAt"` |
|||
} |
|||
|
|||
type S3TablesNamespacesData struct { |
|||
Username string `json:"username"` |
|||
BucketARN string `json:"bucket_arn"` |
|||
Namespaces []s3tables.NamespaceSummary `json:"namespaces"` |
|||
TotalNamespaces int `json:"total_namespaces"` |
|||
LastUpdated time.Time `json:"last_updated"` |
|||
} |
|||
|
|||
type S3TablesTablesData struct { |
|||
Username string `json:"username"` |
|||
BucketARN string `json:"bucket_arn"` |
|||
Namespace string `json:"namespace"` |
|||
Tables []s3tables.TableSummary `json:"tables"` |
|||
TotalTables int `json:"total_tables"` |
|||
LastUpdated time.Time `json:"last_updated"` |
|||
} |
|||
|
|||
type tableBucketMetadata struct { |
|||
Name string `json:"name"` |
|||
CreatedAt time.Time `json:"createdAt"` |
|||
OwnerAccountID string `json:"ownerAccountId"` |
|||
} |
|||
|
|||
// S3Tables manager helpers
|
|||
|
|||
const s3TablesAdminListLimit = 1000 |
|||
|
|||
func newS3TablesManager() *s3tables.Manager { |
|||
manager := s3tables.NewManager() |
|||
manager.SetAccountID(s3_constants.AccountAdminId) |
|||
return manager |
|||
} |
|||
|
|||
func (s *AdminServer) executeS3TablesOperation(ctx context.Context, operation string, req interface{}, resp interface{}) error { |
|||
return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { |
|||
mgrClient := s3tables.NewManagerClient(client) |
|||
return s.s3TablesManager.Execute(ctx, mgrClient, operation, req, resp, s3_constants.AccountAdminId) |
|||
}) |
|||
} |
|||
|
|||
// S3Tables data retrieval for pages
|
|||
|
|||
func (s *AdminServer) GetS3TablesBucketsData(ctx context.Context) (S3TablesBucketsData, error) { |
|||
var buckets []S3TablesBucketSummary |
|||
err := s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { |
|||
resp, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{ |
|||
Directory: s3tables.TablesPath, |
|||
Limit: uint32(s3TablesAdminListLimit * 2), |
|||
InclusiveStartFrom: true, |
|||
}) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
for len(buckets) < s3TablesAdminListLimit { |
|||
entry, recvErr := resp.Recv() |
|||
if recvErr != nil { |
|||
if recvErr == io.EOF { |
|||
break |
|||
} |
|||
return recvErr |
|||
} |
|||
if entry.Entry == nil || !entry.Entry.IsDirectory { |
|||
continue |
|||
} |
|||
if strings.HasPrefix(entry.Entry.Name, ".") { |
|||
continue |
|||
} |
|||
metaBytes, ok := entry.Entry.Extended[s3tables.ExtendedKeyMetadata] |
|||
if !ok { |
|||
continue |
|||
} |
|||
var metadata tableBucketMetadata |
|||
if err := json.Unmarshal(metaBytes, &metadata); err != nil { |
|||
glog.V(1).Infof("S3Tables: failed to decode table bucket metadata for %s: %v", entry.Entry.Name, err) |
|||
continue |
|||
} |
|||
arn, err := s3tables.BuildBucketARN(s3tables.DefaultRegion, metadata.OwnerAccountID, entry.Entry.Name) |
|||
if err != nil { |
|||
glog.V(1).Infof("S3Tables: failed to build table bucket ARN for %s: %v", entry.Entry.Name, err) |
|||
continue |
|||
} |
|||
buckets = append(buckets, S3TablesBucketSummary{ |
|||
ARN: arn, |
|||
Name: entry.Entry.Name, |
|||
OwnerAccountID: metadata.OwnerAccountID, |
|||
CreatedAt: metadata.CreatedAt, |
|||
}) |
|||
} |
|||
return nil |
|||
}) |
|||
if err != nil { |
|||
return S3TablesBucketsData{}, err |
|||
} |
|||
return S3TablesBucketsData{ |
|||
Buckets: buckets, |
|||
TotalBuckets: len(buckets), |
|||
LastUpdated: time.Now(), |
|||
}, nil |
|||
} |
|||
|
|||
func (s *AdminServer) GetS3TablesNamespacesData(ctx context.Context, bucketArn string) (S3TablesNamespacesData, error) { |
|||
var resp s3tables.ListNamespacesResponse |
|||
req := &s3tables.ListNamespacesRequest{TableBucketARN: bucketArn, MaxNamespaces: s3TablesAdminListLimit} |
|||
if err := s.executeS3TablesOperation(ctx, "ListNamespaces", req, &resp); err != nil { |
|||
return S3TablesNamespacesData{}, err |
|||
} |
|||
return S3TablesNamespacesData{ |
|||
BucketARN: bucketArn, |
|||
Namespaces: resp.Namespaces, |
|||
TotalNamespaces: len(resp.Namespaces), |
|||
LastUpdated: time.Now(), |
|||
}, nil |
|||
} |
|||
|
|||
func (s *AdminServer) GetS3TablesTablesData(ctx context.Context, bucketArn, namespace string) (S3TablesTablesData, error) { |
|||
var resp s3tables.ListTablesResponse |
|||
var ns []string |
|||
if namespace != "" { |
|||
ns = []string{namespace} |
|||
} |
|||
req := &s3tables.ListTablesRequest{TableBucketARN: bucketArn, Namespace: ns, MaxTables: s3TablesAdminListLimit} |
|||
if err := s.executeS3TablesOperation(ctx, "ListTables", req, &resp); err != nil { |
|||
return S3TablesTablesData{}, err |
|||
} |
|||
return S3TablesTablesData{ |
|||
BucketARN: bucketArn, |
|||
Namespace: namespace, |
|||
Tables: resp.Tables, |
|||
TotalTables: len(resp.Tables), |
|||
LastUpdated: time.Now(), |
|||
}, nil |
|||
} |
|||
|
|||
// API handlers
|
|||
|
|||
func (s *AdminServer) ListS3TablesBucketsAPI(c *gin.Context) { |
|||
data, err := s.GetS3TablesBucketsData(c.Request.Context()) |
|||
if err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(200, data) |
|||
} |
|||
|
|||
func (s *AdminServer) CreateS3TablesBucket(c *gin.Context) { |
|||
var req struct { |
|||
Name string `json:"name"` |
|||
Tags map[string]string `json:"tags"` |
|||
Owner string `json:"owner"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) |
|||
return |
|||
} |
|||
if req.Name == "" { |
|||
c.JSON(400, gin.H{"error": "Bucket name is required"}) |
|||
return |
|||
} |
|||
owner := strings.TrimSpace(req.Owner) |
|||
if len(owner) > MaxOwnerNameLength { |
|||
c.JSON(400, gin.H{"error": fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)}) |
|||
return |
|||
} |
|||
if len(req.Tags) > 0 { |
|||
if err := s3tables.ValidateTags(req.Tags); err != nil { |
|||
c.JSON(400, gin.H{"error": "Invalid tags: " + err.Error()}) |
|||
return |
|||
} |
|||
} |
|||
createReq := &s3tables.CreateTableBucketRequest{Name: req.Name, Tags: req.Tags} |
|||
var resp s3tables.CreateTableBucketResponse |
|||
if err := s.executeS3TablesOperation(c.Request.Context(), "CreateTableBucket", createReq, &resp); err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
if owner != "" { |
|||
if err := s.SetTableBucketOwner(c.Request.Context(), req.Name, owner); err != nil { |
|||
deleteReq := &s3tables.DeleteTableBucketRequest{TableBucketARN: resp.ARN} |
|||
if deleteErr := s.executeS3TablesOperation(c.Request.Context(), "DeleteTableBucket", deleteReq, nil); deleteErr != nil { |
|||
c.JSON(500, gin.H{"error": fmt.Sprintf("Failed to set table bucket owner: %v; rollback delete failed: %v", err, deleteErr)}) |
|||
return |
|||
} |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
} |
|||
c.JSON(201, gin.H{"arn": resp.ARN}) |
|||
} |
|||
|
|||
func (s *AdminServer) SetTableBucketOwner(ctx context.Context, bucketName, owner string) error { |
|||
return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { |
|||
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{ |
|||
Directory: s3tables.TablesPath, |
|||
Name: bucketName, |
|||
}) |
|||
if err != nil { |
|||
return fmt.Errorf("lookup table bucket %s: %w", bucketName, err) |
|||
} |
|||
if resp.Entry == nil { |
|||
return fmt.Errorf("table bucket %s not found", bucketName) |
|||
} |
|||
entry := resp.Entry |
|||
if entry.Extended == nil { |
|||
return fmt.Errorf("table bucket %s metadata missing", bucketName) |
|||
} |
|||
metaBytes, ok := entry.Extended[s3tables.ExtendedKeyMetadata] |
|||
if !ok { |
|||
return fmt.Errorf("table bucket %s metadata missing", bucketName) |
|||
} |
|||
var metadata tableBucketMetadata |
|||
if err := json.Unmarshal(metaBytes, &metadata); err != nil { |
|||
return fmt.Errorf("failed to parse table bucket metadata: %w", err) |
|||
} |
|||
metadata.OwnerAccountID = owner |
|||
updated, err := json.Marshal(&metadata) |
|||
if err != nil { |
|||
return fmt.Errorf("failed to marshal table bucket metadata: %w", err) |
|||
} |
|||
entry.Extended[s3tables.ExtendedKeyMetadata] = updated |
|||
if _, err := client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{ |
|||
Directory: s3tables.TablesPath, |
|||
Entry: entry, |
|||
}); err != nil { |
|||
return fmt.Errorf("failed to update table bucket owner: %w", err) |
|||
} |
|||
return nil |
|||
}) |
|||
} |
|||
|
|||
func (s *AdminServer) DeleteS3TablesBucket(c *gin.Context) { |
|||
bucketArn := c.Query("bucket") |
|||
if bucketArn == "" { |
|||
c.JSON(400, gin.H{"error": "Bucket ARN is required"}) |
|||
return |
|||
} |
|||
req := &s3tables.DeleteTableBucketRequest{TableBucketARN: bucketArn} |
|||
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTableBucket", req, nil); err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(200, gin.H{"message": "Bucket deleted"}) |
|||
} |
|||
|
|||
func (s *AdminServer) ListS3TablesNamespacesAPI(c *gin.Context) { |
|||
bucketArn := c.Query("bucket") |
|||
if bucketArn == "" { |
|||
c.JSON(400, gin.H{"error": "bucket query parameter is required"}) |
|||
return |
|||
} |
|||
data, err := s.GetS3TablesNamespacesData(c.Request.Context(), bucketArn) |
|||
if err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(200, data) |
|||
} |
|||
|
|||
func (s *AdminServer) CreateS3TablesNamespace(c *gin.Context) { |
|||
var req struct { |
|||
BucketARN string `json:"bucket_arn"` |
|||
Name string `json:"name"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) |
|||
return |
|||
} |
|||
if req.BucketARN == "" || req.Name == "" { |
|||
c.JSON(400, gin.H{"error": "bucket_arn and name are required"}) |
|||
return |
|||
} |
|||
createReq := &s3tables.CreateNamespaceRequest{TableBucketARN: req.BucketARN, Namespace: []string{req.Name}} |
|||
var resp s3tables.CreateNamespaceResponse |
|||
if err := s.executeS3TablesOperation(c.Request.Context(), "CreateNamespace", createReq, &resp); err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(201, gin.H{"namespace": resp.Namespace}) |
|||
} |
|||
|
|||
func (s *AdminServer) DeleteS3TablesNamespace(c *gin.Context) { |
|||
bucketArn := c.Query("bucket") |
|||
namespace := c.Query("name") |
|||
if bucketArn == "" || namespace == "" { |
|||
c.JSON(400, gin.H{"error": "bucket and name query parameters are required"}) |
|||
return |
|||
} |
|||
req := &s3tables.DeleteNamespaceRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}} |
|||
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteNamespace", req, nil); err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(200, gin.H{"message": "Namespace deleted"}) |
|||
} |
|||
|
|||
func (s *AdminServer) ListS3TablesTablesAPI(c *gin.Context) { |
|||
bucketArn := c.Query("bucket") |
|||
if bucketArn == "" { |
|||
c.JSON(400, gin.H{"error": "bucket query parameter is required"}) |
|||
return |
|||
} |
|||
namespace := c.Query("namespace") |
|||
data, err := s.GetS3TablesTablesData(c.Request.Context(), bucketArn, namespace) |
|||
if err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(200, data) |
|||
} |
|||
|
|||
func (s *AdminServer) CreateS3TablesTable(c *gin.Context) { |
|||
var req struct { |
|||
BucketARN string `json:"bucket_arn"` |
|||
Namespace string `json:"namespace"` |
|||
Name string `json:"name"` |
|||
Format string `json:"format"` |
|||
Tags map[string]string `json:"tags"` |
|||
Metadata *s3tables.TableMetadata `json:"metadata"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) |
|||
return |
|||
} |
|||
if req.BucketARN == "" || req.Namespace == "" || req.Name == "" { |
|||
c.JSON(400, gin.H{"error": "bucket_arn, namespace, and name are required"}) |
|||
return |
|||
} |
|||
format := req.Format |
|||
if format == "" { |
|||
format = "ICEBERG" |
|||
} |
|||
if len(req.Tags) > 0 { |
|||
if err := s3tables.ValidateTags(req.Tags); err != nil { |
|||
c.JSON(400, gin.H{"error": "Invalid tags: " + err.Error()}) |
|||
return |
|||
} |
|||
} |
|||
createReq := &s3tables.CreateTableRequest{ |
|||
TableBucketARN: req.BucketARN, |
|||
Namespace: []string{req.Namespace}, |
|||
Name: req.Name, |
|||
Format: format, |
|||
Tags: req.Tags, |
|||
Metadata: req.Metadata, |
|||
} |
|||
var resp s3tables.CreateTableResponse |
|||
if err := s.executeS3TablesOperation(c.Request.Context(), "CreateTable", createReq, &resp); err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(201, gin.H{"table_arn": resp.TableARN, "version_token": resp.VersionToken}) |
|||
} |
|||
|
|||
func (s *AdminServer) DeleteS3TablesTable(c *gin.Context) { |
|||
bucketArn := c.Query("bucket") |
|||
namespace := c.Query("namespace") |
|||
name := c.Query("name") |
|||
version := c.Query("version") |
|||
if bucketArn == "" || namespace == "" || name == "" { |
|||
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"}) |
|||
return |
|||
} |
|||
req := &s3tables.DeleteTableRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name, VersionToken: version} |
|||
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTable", req, nil); err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(200, gin.H{"message": "Table deleted"}) |
|||
} |
|||
|
|||
func (s *AdminServer) PutS3TablesBucketPolicy(c *gin.Context) { |
|||
var req struct { |
|||
BucketARN string `json:"bucket_arn"` |
|||
Policy string `json:"policy"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) |
|||
return |
|||
} |
|||
if req.BucketARN == "" || req.Policy == "" { |
|||
c.JSON(400, gin.H{"error": "bucket_arn and policy are required"}) |
|||
return |
|||
} |
|||
putReq := &s3tables.PutTableBucketPolicyRequest{TableBucketARN: req.BucketARN, ResourcePolicy: req.Policy} |
|||
if err := s.executeS3TablesOperation(c.Request.Context(), "PutTableBucketPolicy", putReq, nil); err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(200, gin.H{"message": "Policy updated"}) |
|||
} |
|||
|
|||
func (s *AdminServer) GetS3TablesBucketPolicy(c *gin.Context) { |
|||
bucketArn := c.Query("bucket") |
|||
if bucketArn == "" { |
|||
c.JSON(400, gin.H{"error": "bucket query parameter is required"}) |
|||
return |
|||
} |
|||
getReq := &s3tables.GetTableBucketPolicyRequest{TableBucketARN: bucketArn} |
|||
var resp s3tables.GetTableBucketPolicyResponse |
|||
if err := s.executeS3TablesOperation(c.Request.Context(), "GetTableBucketPolicy", getReq, &resp); err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(200, gin.H{"policy": resp.ResourcePolicy}) |
|||
} |
|||
|
|||
func (s *AdminServer) DeleteS3TablesBucketPolicy(c *gin.Context) { |
|||
bucketArn := c.Query("bucket") |
|||
if bucketArn == "" { |
|||
c.JSON(400, gin.H{"error": "bucket query parameter is required"}) |
|||
return |
|||
} |
|||
deleteReq := &s3tables.DeleteTableBucketPolicyRequest{TableBucketARN: bucketArn} |
|||
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTableBucketPolicy", deleteReq, nil); err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(200, gin.H{"message": "Policy deleted"}) |
|||
} |
|||
|
|||
func (s *AdminServer) PutS3TablesTablePolicy(c *gin.Context) { |
|||
var req struct { |
|||
BucketARN string `json:"bucket_arn"` |
|||
Namespace string `json:"namespace"` |
|||
Name string `json:"name"` |
|||
Policy string `json:"policy"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) |
|||
return |
|||
} |
|||
if req.BucketARN == "" || req.Namespace == "" || req.Name == "" || req.Policy == "" { |
|||
c.JSON(400, gin.H{"error": "bucket_arn, namespace, name, and policy are required"}) |
|||
return |
|||
} |
|||
putReq := &s3tables.PutTablePolicyRequest{TableBucketARN: req.BucketARN, Namespace: []string{req.Namespace}, Name: req.Name, ResourcePolicy: req.Policy} |
|||
if err := s.executeS3TablesOperation(c.Request.Context(), "PutTablePolicy", putReq, nil); err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(200, gin.H{"message": "Policy updated"}) |
|||
} |
|||
|
|||
func (s *AdminServer) GetS3TablesTablePolicy(c *gin.Context) { |
|||
bucketArn := c.Query("bucket") |
|||
namespace := c.Query("namespace") |
|||
name := c.Query("name") |
|||
if bucketArn == "" || namespace == "" || name == "" { |
|||
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"}) |
|||
return |
|||
} |
|||
getReq := &s3tables.GetTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name} |
|||
var resp s3tables.GetTablePolicyResponse |
|||
if err := s.executeS3TablesOperation(c.Request.Context(), "GetTablePolicy", getReq, &resp); err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(200, gin.H{"policy": resp.ResourcePolicy}) |
|||
} |
|||
|
|||
func (s *AdminServer) DeleteS3TablesTablePolicy(c *gin.Context) { |
|||
bucketArn := c.Query("bucket") |
|||
namespace := c.Query("namespace") |
|||
name := c.Query("name") |
|||
if bucketArn == "" || namespace == "" || name == "" { |
|||
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"}) |
|||
return |
|||
} |
|||
deleteReq := &s3tables.DeleteTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name} |
|||
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTablePolicy", deleteReq, nil); err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(200, gin.H{"message": "Policy deleted"}) |
|||
} |
|||
|
|||
func (s *AdminServer) TagS3TablesResource(c *gin.Context) { |
|||
var req struct { |
|||
ResourceARN string `json:"resource_arn"` |
|||
Tags map[string]string `json:"tags"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) |
|||
return |
|||
} |
|||
if req.ResourceARN == "" || len(req.Tags) == 0 { |
|||
c.JSON(400, gin.H{"error": "resource_arn and tags are required"}) |
|||
return |
|||
} |
|||
if err := s3tables.ValidateTags(req.Tags); err != nil { |
|||
c.JSON(400, gin.H{"error": "Invalid tags: " + err.Error()}) |
|||
return |
|||
} |
|||
tagReq := &s3tables.TagResourceRequest{ResourceARN: req.ResourceARN, Tags: req.Tags} |
|||
if err := s.executeS3TablesOperation(c.Request.Context(), "TagResource", tagReq, nil); err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(200, gin.H{"message": "Tags updated"}) |
|||
} |
|||
|
|||
func (s *AdminServer) ListS3TablesTags(c *gin.Context) { |
|||
resourceArn := c.Query("arn") |
|||
if resourceArn == "" { |
|||
c.JSON(400, gin.H{"error": "arn query parameter is required"}) |
|||
return |
|||
} |
|||
listReq := &s3tables.ListTagsForResourceRequest{ResourceARN: resourceArn} |
|||
var resp s3tables.ListTagsForResourceResponse |
|||
if err := s.executeS3TablesOperation(c.Request.Context(), "ListTagsForResource", listReq, &resp); err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(200, resp) |
|||
} |
|||
|
|||
func (s *AdminServer) UntagS3TablesResource(c *gin.Context) { |
|||
var req struct { |
|||
ResourceARN string `json:"resource_arn"` |
|||
TagKeys []string `json:"tag_keys"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) |
|||
return |
|||
} |
|||
if req.ResourceARN == "" || len(req.TagKeys) == 0 { |
|||
c.JSON(400, gin.H{"error": "resource_arn and tag_keys are required"}) |
|||
return |
|||
} |
|||
untagReq := &s3tables.UntagResourceRequest{ResourceARN: req.ResourceARN, TagKeys: req.TagKeys} |
|||
if err := s.executeS3TablesOperation(c.Request.Context(), "UntagResource", untagReq, nil); err != nil { |
|||
writeS3TablesError(c, err) |
|||
return |
|||
} |
|||
c.JSON(200, gin.H{"message": "Tags removed"}) |
|||
} |
|||
|
|||
func parseS3TablesErrorMessage(err error) string { |
|||
if err == nil { |
|||
return "" |
|||
} |
|||
var s3Err *s3tables.S3TablesError |
|||
if errors.As(err, &s3Err) { |
|||
if s3Err.Message != "" { |
|||
return fmt.Sprintf("%s: %s", s3Err.Type, s3Err.Message) |
|||
} |
|||
return s3Err.Type |
|||
} |
|||
return err.Error() |
|||
} |
|||
|
|||
func writeS3TablesError(c *gin.Context, err error) { |
|||
c.JSON(s3TablesErrorStatus(err), gin.H{"error": parseS3TablesErrorMessage(err)}) |
|||
} |
|||
|
|||
func s3TablesErrorStatus(err error) int { |
|||
var s3Err *s3tables.S3TablesError |
|||
if errors.As(err, &s3Err) { |
|||
switch s3Err.Type { |
|||
case s3tables.ErrCodeInvalidRequest: |
|||
return http.StatusBadRequest |
|||
case s3tables.ErrCodeNoSuchBucket, s3tables.ErrCodeNoSuchNamespace, s3tables.ErrCodeNoSuchTable, s3tables.ErrCodeNoSuchPolicy: |
|||
return http.StatusNotFound |
|||
case s3tables.ErrCodeAccessDenied: |
|||
return http.StatusForbidden |
|||
case s3tables.ErrCodeBucketAlreadyExists, s3tables.ErrCodeNamespaceAlreadyExists, s3tables.ErrCodeTableAlreadyExists, s3tables.ErrCodeConflict: |
|||
return http.StatusConflict |
|||
} |
|||
} |
|||
return http.StatusInternalServerError |
|||
} |
|||
@ -0,0 +1,479 @@ |
|||
/** |
|||
* Shared S3 Tables functionality for the SeaweedFS Admin Dashboard. |
|||
*/ |
|||
|
|||
// Shared Modals
|
|||
let s3tablesBucketDeleteModal = null; |
|||
let s3tablesBucketPolicyModal = null; |
|||
let s3tablesNamespaceDeleteModal = null; |
|||
let s3tablesTableDeleteModal = null; |
|||
let s3tablesTablePolicyModal = null; |
|||
let s3tablesTagsModal = null; |
|||
|
|||
/** |
|||
* Initialize S3 Tables Buckets Page |
|||
*/ |
|||
function initS3TablesBuckets() { |
|||
s3tablesBucketDeleteModal = new bootstrap.Modal(document.getElementById('deleteS3TablesBucketModal')); |
|||
s3tablesBucketPolicyModal = new bootstrap.Modal(document.getElementById('s3tablesBucketPolicyModal')); |
|||
s3tablesTagsModal = new bootstrap.Modal(document.getElementById('s3tablesTagsModal')); |
|||
|
|||
const ownerSelect = document.getElementById('s3tablesBucketOwner'); |
|||
if (ownerSelect) { |
|||
document.getElementById('createS3TablesBucketModal').addEventListener('show.bs.modal', async function () { |
|||
if (ownerSelect.options.length <= 1) { |
|||
try { |
|||
const response = await fetch('/api/users'); |
|||
const data = await response.json(); |
|||
const users = data.users || []; |
|||
users.forEach(user => { |
|||
const option = document.createElement('option'); |
|||
option.value = user.username; |
|||
option.textContent = user.username; |
|||
ownerSelect.appendChild(option); |
|||
}); |
|||
} catch (error) { |
|||
console.error('Error fetching users for owner dropdown:', error); |
|||
ownerSelect.innerHTML = '<option value="">No owner (admin-only access)</option>'; |
|||
ownerSelect.selectedIndex = 0; |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
|
|||
document.querySelectorAll('.s3tables-delete-bucket-btn').forEach(button => { |
|||
button.addEventListener('click', function () { |
|||
document.getElementById('deleteS3TablesBucketName').textContent = this.dataset.bucketName || ''; |
|||
document.getElementById('deleteS3TablesBucketModal').dataset.bucketArn = this.dataset.bucketArn || ''; |
|||
s3tablesBucketDeleteModal.show(); |
|||
}); |
|||
}); |
|||
|
|||
document.querySelectorAll('.s3tables-bucket-policy-btn').forEach(button => { |
|||
button.addEventListener('click', function () { |
|||
const bucketArn = this.dataset.bucketArn || ''; |
|||
document.getElementById('s3tablesBucketPolicyArn').value = bucketArn; |
|||
loadS3TablesBucketPolicy(bucketArn); |
|||
s3tablesBucketPolicyModal.show(); |
|||
}); |
|||
}); |
|||
|
|||
document.querySelectorAll('.s3tables-tags-btn').forEach(button => { |
|||
button.addEventListener('click', function () { |
|||
const resourceArn = this.dataset.resourceArn || ''; |
|||
openS3TablesTags(resourceArn); |
|||
}); |
|||
}); |
|||
|
|||
const createForm = document.getElementById('createS3TablesBucketForm'); |
|||
if (createForm) { |
|||
createForm.addEventListener('submit', async function (e) { |
|||
e.preventDefault(); |
|||
const name = document.getElementById('s3tablesBucketName').value.trim(); |
|||
const owner = ownerSelect.value; |
|||
const tagsInput = document.getElementById('s3tablesBucketTags').value.trim(); |
|||
const tags = parseTagsInput(tagsInput); |
|||
if (tags === null) return; |
|||
const payload = { name: name, tags: tags, owner: owner }; |
|||
|
|||
try { |
|||
const response = await fetch('/api/s3tables/buckets', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify(payload) |
|||
}); |
|||
const data = await response.json(); |
|||
if (!response.ok) { |
|||
alert(data.error || 'Failed to create bucket'); |
|||
return; |
|||
} |
|||
alert('Bucket created successfully'); |
|||
location.reload(); |
|||
} catch (error) { |
|||
alert('Failed to create bucket: ' + error.message); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
const policyForm = document.getElementById('s3tablesBucketPolicyForm'); |
|||
if (policyForm) { |
|||
policyForm.addEventListener('submit', async function (e) { |
|||
e.preventDefault(); |
|||
const bucketArn = document.getElementById('s3tablesBucketPolicyArn').value; |
|||
const policy = document.getElementById('s3tablesBucketPolicyText').value.trim(); |
|||
if (!policy) { |
|||
alert('Policy JSON is required'); |
|||
return; |
|||
} |
|||
try { |
|||
const response = await fetch('/api/s3tables/bucket-policy', { |
|||
method: 'PUT', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ bucket_arn: bucketArn, policy: policy }) |
|||
}); |
|||
const data = await response.json(); |
|||
if (!response.ok) { |
|||
alert(data.error || 'Failed to update policy'); |
|||
return; |
|||
} |
|||
alert('Policy updated'); |
|||
s3tablesBucketPolicyModal.hide(); |
|||
} catch (error) { |
|||
alert('Failed to update policy: ' + error.message); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
const tagsForm = document.getElementById('s3tablesTagsForm'); |
|||
if (tagsForm) { |
|||
tagsForm.addEventListener('submit', async function (e) { |
|||
e.preventDefault(); |
|||
const resourceArn = document.getElementById('s3tablesTagsResourceArn').value; |
|||
const tags = parseTagsInput(document.getElementById('s3tablesTagsInput').value.trim()); |
|||
if (tags === null || Object.keys(tags).length === 0) { |
|||
alert('Please provide tags to update'); |
|||
return; |
|||
} |
|||
await updateS3TablesTags(resourceArn, tags); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Initialize S3 Tables Tables Page |
|||
*/ |
|||
function initS3TablesTables() { |
|||
s3tablesTableDeleteModal = new bootstrap.Modal(document.getElementById('deleteS3TablesTableModal')); |
|||
s3tablesTablePolicyModal = new bootstrap.Modal(document.getElementById('s3tablesTablePolicyModal')); |
|||
s3tablesTagsModal = new bootstrap.Modal(document.getElementById('s3tablesTagsModal')); |
|||
|
|||
const dataContainer = document.getElementById('s3tables-tables-content'); |
|||
const dataBucketArn = dataContainer.dataset.bucketArn || ''; |
|||
const dataNamespace = dataContainer.dataset.namespace || ''; |
|||
|
|||
document.querySelectorAll('.s3tables-delete-table-btn').forEach(button => { |
|||
button.addEventListener('click', function () { |
|||
document.getElementById('deleteS3TablesTableName').textContent = this.dataset.tableName || ''; |
|||
document.getElementById('deleteS3TablesTableModal').dataset.tableName = this.dataset.tableName || ''; |
|||
s3tablesTableDeleteModal.show(); |
|||
}); |
|||
}); |
|||
|
|||
document.querySelectorAll('.s3tables-table-policy-btn').forEach(button => { |
|||
button.addEventListener('click', function () { |
|||
document.getElementById('s3tablesTablePolicyBucketArn').value = dataBucketArn; |
|||
document.getElementById('s3tablesTablePolicyNamespace').value = dataNamespace; |
|||
document.getElementById('s3tablesTablePolicyName').value = this.dataset.tableName || ''; |
|||
loadS3TablesTablePolicy(dataBucketArn, dataNamespace, this.dataset.tableName || ''); |
|||
s3tablesTablePolicyModal.show(); |
|||
}); |
|||
}); |
|||
|
|||
document.querySelectorAll('.s3tables-tags-btn').forEach(button => { |
|||
button.addEventListener('click', function () { |
|||
const resourceArn = this.dataset.resourceArn || ''; |
|||
openS3TablesTags(resourceArn); |
|||
}); |
|||
}); |
|||
|
|||
const createForm = document.getElementById('createS3TablesTableForm'); |
|||
if (createForm) { |
|||
createForm.addEventListener('submit', async function (e) { |
|||
e.preventDefault(); |
|||
const name = document.getElementById('s3tablesTableName').value.trim(); |
|||
const format = document.getElementById('s3tablesTableFormat').value; |
|||
const metadataText = document.getElementById('s3tablesTableMetadata').value.trim(); |
|||
const tagsInput = document.getElementById('s3tablesTableTags').value.trim(); |
|||
const tags = parseTagsInput(tagsInput); |
|||
if (tags === null) return; |
|||
let metadata = null; |
|||
if (metadataText) { |
|||
try { |
|||
metadata = JSON.parse(metadataText); |
|||
} catch (error) { |
|||
alert('Invalid metadata JSON'); |
|||
return; |
|||
} |
|||
} |
|||
const payload = { bucket_arn: dataBucketArn, namespace: dataNamespace, name: name, format: format, tags: tags }; |
|||
if (metadata) { |
|||
payload.metadata = metadata; |
|||
} |
|||
try { |
|||
const response = await fetch('/api/s3tables/tables', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify(payload) |
|||
}); |
|||
const data = await response.json(); |
|||
if (!response.ok) { |
|||
alert(data.error || 'Failed to create table'); |
|||
return; |
|||
} |
|||
alert('Table created'); |
|||
location.reload(); |
|||
} catch (error) { |
|||
alert('Failed to create table: ' + error.message); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
const policyForm = document.getElementById('s3tablesTablePolicyForm'); |
|||
if (policyForm) { |
|||
policyForm.addEventListener('submit', async function (e) { |
|||
e.preventDefault(); |
|||
const policy = document.getElementById('s3tablesTablePolicyText').value.trim(); |
|||
if (!policy) { |
|||
alert('Policy JSON is required'); |
|||
return; |
|||
} |
|||
try { |
|||
const response = await fetch('/api/s3tables/table-policy', { |
|||
method: 'PUT', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ bucket_arn: dataBucketArn, namespace: dataNamespace, name: document.getElementById('s3tablesTablePolicyName').value, policy: policy }) |
|||
}); |
|||
const data = await response.json(); |
|||
if (!response.ok) { |
|||
alert(data.error || 'Failed to update policy'); |
|||
return; |
|||
} |
|||
alert('Policy updated'); |
|||
s3tablesTablePolicyModal.hide(); |
|||
} catch (error) { |
|||
alert('Failed to update policy: ' + error.message); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
const tagsForm = document.getElementById('s3tablesTagsForm'); |
|||
if (tagsForm) { |
|||
tagsForm.addEventListener('submit', async function (e) { |
|||
e.preventDefault(); |
|||
const resourceArn = document.getElementById('s3tablesTagsResourceArn').value; |
|||
const tags = parseTagsInput(document.getElementById('s3tablesTagsInput').value.trim()); |
|||
if (tags === null || Object.keys(tags).length === 0) { |
|||
alert('Please provide tags to update'); |
|||
return; |
|||
} |
|||
await updateS3TablesTags(resourceArn, tags); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
// Global scope functions used by onclick handlers
|
|||
|
|||
async function deleteS3TablesBucket() { |
|||
const bucketArn = document.getElementById('deleteS3TablesBucketModal').dataset.bucketArn; |
|||
if (!bucketArn) return; |
|||
try { |
|||
const response = await fetch(`/api/s3tables/buckets?bucket=${encodeURIComponent(bucketArn)}`, { method: 'DELETE' }); |
|||
const data = await response.json(); |
|||
if (!response.ok) { |
|||
alert(data.error || 'Failed to delete bucket'); |
|||
return; |
|||
} |
|||
alert('Bucket deleted'); |
|||
location.reload(); |
|||
} catch (error) { |
|||
alert('Failed to delete bucket: ' + error.message); |
|||
} |
|||
} |
|||
|
|||
async function loadS3TablesBucketPolicy(bucketArn) { |
|||
document.getElementById('s3tablesBucketPolicyText').value = ''; |
|||
if (!bucketArn) return; |
|||
try { |
|||
const response = await fetch(`/api/s3tables/bucket-policy?bucket=${encodeURIComponent(bucketArn)}`); |
|||
const data = await response.json(); |
|||
if (response.ok && data.policy) { |
|||
document.getElementById('s3tablesBucketPolicyText').value = data.policy; |
|||
} |
|||
} catch (error) { |
|||
console.error('Failed to load bucket policy', error); |
|||
} |
|||
} |
|||
|
|||
async function deleteS3TablesBucketPolicy() { |
|||
const bucketArn = document.getElementById('s3tablesBucketPolicyArn').value; |
|||
if (!bucketArn) return; |
|||
try { |
|||
const response = await fetch(`/api/s3tables/bucket-policy?bucket=${encodeURIComponent(bucketArn)}`, { method: 'DELETE' }); |
|||
const data = await response.json(); |
|||
if (!response.ok) { |
|||
alert(data.error || 'Failed to delete policy'); |
|||
return; |
|||
} |
|||
alert('Policy deleted'); |
|||
document.getElementById('s3tablesBucketPolicyText').value = ''; |
|||
} catch (error) { |
|||
alert('Failed to delete policy: ' + error.message); |
|||
} |
|||
} |
|||
|
|||
async function deleteS3TablesTable() { |
|||
const dataContainer = document.getElementById('s3tables-tables-content'); |
|||
const dataBucketArn = dataContainer.dataset.bucketArn || ''; |
|||
const dataNamespace = dataContainer.dataset.namespace || ''; |
|||
const tableName = document.getElementById('deleteS3TablesTableModal').dataset.tableName; |
|||
const versionToken = document.getElementById('deleteS3TablesTableVersion').value.trim(); |
|||
if (!tableName) return; |
|||
const query = new URLSearchParams({ |
|||
bucket: dataBucketArn, |
|||
namespace: dataNamespace, |
|||
name: tableName |
|||
}); |
|||
if (versionToken) { |
|||
query.set('version', versionToken); |
|||
} |
|||
try { |
|||
const response = await fetch(`/api/s3tables/tables?${query.toString()}`, { method: 'DELETE' }); |
|||
const data = await response.json(); |
|||
if (!response.ok) { |
|||
alert(data.error || 'Failed to delete table'); |
|||
return; |
|||
} |
|||
alert('Table deleted'); |
|||
location.reload(); |
|||
} catch (error) { |
|||
alert('Failed to delete table: ' + error.message); |
|||
} |
|||
} |
|||
|
|||
async function loadS3TablesTablePolicy(bucketArn, namespace, name) { |
|||
document.getElementById('s3tablesTablePolicyText').value = ''; |
|||
if (!bucketArn || !namespace || !name) return; |
|||
const query = new URLSearchParams({ bucket: bucketArn, namespace: namespace, name: name }); |
|||
try { |
|||
const response = await fetch(`/api/s3tables/table-policy?${query.toString()}`); |
|||
const data = await response.json(); |
|||
if (response.ok && data.policy) { |
|||
document.getElementById('s3tablesTablePolicyText').value = data.policy; |
|||
} |
|||
} catch (error) { |
|||
console.error('Failed to load table policy', error); |
|||
} |
|||
} |
|||
|
|||
async function deleteS3TablesTablePolicy() { |
|||
const dataContainer = document.getElementById('s3tables-tables-content'); |
|||
const dataBucketArn = dataContainer.dataset.bucketArn || ''; |
|||
const dataNamespace = dataContainer.dataset.namespace || ''; |
|||
const query = new URLSearchParams({ bucket: dataBucketArn, namespace: dataNamespace, name: document.getElementById('s3tablesTablePolicyName').value }); |
|||
try { |
|||
const response = await fetch(`/api/s3tables/table-policy?${query.toString()}`, { method: 'DELETE' }); |
|||
const data = await response.json(); |
|||
if (!response.ok) { |
|||
alert(data.error || 'Failed to delete policy'); |
|||
return; |
|||
} |
|||
alert('Policy deleted'); |
|||
document.getElementById('s3tablesTablePolicyText').value = ''; |
|||
} catch (error) { |
|||
alert('Failed to delete policy: ' + error.message); |
|||
} |
|||
} |
|||
|
|||
function parseTagsInput(input) { |
|||
if (!input) return {}; |
|||
const tags = {}; |
|||
const maxTags = 10; |
|||
const maxKeyLength = 128; |
|||
const maxValueLength = 256; |
|||
const parts = input.split(','); |
|||
for (const part of parts) { |
|||
const trimmedPart = part.trim(); |
|||
if (!trimmedPart) continue; |
|||
const idx = trimmedPart.indexOf('='); |
|||
if (idx <= 0) { |
|||
alert('Invalid tag format. Use key=value, and key cannot be empty.'); |
|||
return null; |
|||
} |
|||
const key = trimmedPart.slice(0, idx).trim(); |
|||
const value = trimmedPart.slice(idx + 1).trim(); |
|||
if (!key) { |
|||
alert('Invalid tag format. Use key=value, and key cannot be empty.'); |
|||
return null; |
|||
} |
|||
if (key.length > maxKeyLength) { |
|||
alert(`Tag key length must be <= ${maxKeyLength}`); |
|||
return null; |
|||
} |
|||
if (value.length > maxValueLength) { |
|||
alert(`Tag value length must be <= ${maxValueLength}`); |
|||
return null; |
|||
} |
|||
tags[key] = value; |
|||
if (Object.keys(tags).length > maxTags) { |
|||
alert(`Too many tags. Max ${maxTags} tags allowed.`); |
|||
return null; |
|||
} |
|||
} |
|||
return tags; |
|||
} |
|||
|
|||
async function openS3TablesTags(resourceArn) { |
|||
if (!resourceArn) return; |
|||
document.getElementById('s3tablesTagsResourceArn').value = resourceArn; |
|||
document.getElementById('s3tablesTagsInput').value = ''; |
|||
document.getElementById('s3tablesTagsDeleteInput').value = ''; |
|||
document.getElementById('s3tablesTagsList').textContent = 'Loading...'; |
|||
s3tablesTagsModal.show(); |
|||
try { |
|||
const response = await fetch(`/api/s3tables/tags?arn=${encodeURIComponent(resourceArn)}`); |
|||
const data = await response.json(); |
|||
if (response.ok) { |
|||
document.getElementById('s3tablesTagsList').textContent = JSON.stringify(data.tags || {}, null, 2); |
|||
} else { |
|||
document.getElementById('s3tablesTagsList').textContent = data.error || 'Failed to load tags'; |
|||
} |
|||
} catch (error) { |
|||
document.getElementById('s3tablesTagsList').textContent = error.message; |
|||
} |
|||
} |
|||
|
|||
async function updateS3TablesTags(resourceArn, tags) { |
|||
try { |
|||
const response = await fetch('/api/s3tables/tags', { |
|||
method: 'PUT', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ resource_arn: resourceArn, tags: tags }) |
|||
}); |
|||
const data = await response.json(); |
|||
if (!response.ok) { |
|||
alert(data.error || 'Failed to update tags'); |
|||
return; |
|||
} |
|||
alert('Tags updated'); |
|||
openS3TablesTags(resourceArn); |
|||
} catch (error) { |
|||
alert('Failed to update tags: ' + error.message); |
|||
} |
|||
} |
|||
|
|||
async function deleteS3TablesTags() { |
|||
const resourceArn = document.getElementById('s3tablesTagsResourceArn').value; |
|||
const keysInput = document.getElementById('s3tablesTagsDeleteInput').value.trim(); |
|||
if (!resourceArn) return; |
|||
const tagKeys = keysInput.split(',').map(k => k.trim()).filter(k => k); |
|||
if (tagKeys.length === 0) { |
|||
alert('Provide tag keys to remove'); |
|||
return; |
|||
} |
|||
try { |
|||
const response = await fetch('/api/s3tables/tags', { |
|||
method: 'DELETE', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ resource_arn: resourceArn, tag_keys: tagKeys }) |
|||
}); |
|||
const data = await response.json(); |
|||
if (!response.ok) { |
|||
alert(data.error || 'Failed to remove tags'); |
|||
return; |
|||
} |
|||
alert('Tags removed'); |
|||
openS3TablesTags(resourceArn); |
|||
} catch (error) { |
|||
alert('Failed to remove tags: ' + error.message); |
|||
} |
|||
} |
|||
408
weed/admin/view/app/file_browser_templ.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
weed/admin/view/app/object_store_users_templ.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,275 @@ |
|||
package app |
|||
|
|||
import ( |
|||
"fmt" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/admin/dash" |
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" |
|||
) |
|||
|
|||
templ S3TablesBuckets(data dash.S3TablesBucketsData) { |
|||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> |
|||
<h1 class="h2"> |
|||
<i class="fas fa-table me-2"></i>S3 Tables Buckets |
|||
</h1> |
|||
<div class="btn-toolbar mb-2 mb-md-0"> |
|||
<div class="btn-group me-2"> |
|||
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#createS3TablesBucketModal"> |
|||
<i class="fas fa-plus me-1"></i>Create Bucket |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div id="s3tables-buckets-content"> |
|||
<div class="row mb-4"> |
|||
<div class="col-xl-4 col-md-6 mb-4"> |
|||
<div class="card border-left-primary shadow h-100 py-2"> |
|||
<div class="card-body"> |
|||
<div class="row no-gutters align-items-center"> |
|||
<div class="col mr-2"> |
|||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1"> |
|||
Total Buckets |
|||
</div> |
|||
<div class="h5 mb-0 font-weight-bold text-gray-800"> |
|||
{ fmt.Sprintf("%d", data.TotalBuckets) } |
|||
</div> |
|||
</div> |
|||
<div class="col-auto"> |
|||
<i class="fas fa-table fa-2x text-gray-300"></i> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="col-xl-4 col-md-6 mb-4"> |
|||
<div class="card border-left-info shadow h-100 py-2"> |
|||
<div class="card-body"> |
|||
<div class="row no-gutters align-items-center"> |
|||
<div class="col mr-2"> |
|||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1"> |
|||
Last Updated |
|||
</div> |
|||
<div class="h6 mb-0 font-weight-bold text-gray-800"> |
|||
{ data.LastUpdated.Format("15:04") } |
|||
</div> |
|||
</div> |
|||
<div class="col-auto"> |
|||
<i class="fas fa-clock fa-2x text-gray-300"></i> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="row"> |
|||
<div class="col-12"> |
|||
<div class="card shadow mb-4"> |
|||
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between"> |
|||
<h6 class="m-0 font-weight-bold text-primary"> |
|||
<i class="fas fa-table me-2"></i>Table Buckets |
|||
</h6> |
|||
</div> |
|||
<div class="card-body"> |
|||
<div class="table-responsive"> |
|||
<table class="table table-hover" width="100%" cellspacing="0" id="s3tablesBucketsTable"> |
|||
<thead> |
|||
<tr> |
|||
<th>Name</th> |
|||
<th>Owner</th> |
|||
<th>ARN</th> |
|||
<th>Created</th> |
|||
<th>Actions</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
for _, bucket := range data.Buckets { |
|||
<tr> |
|||
<td>{ bucket.Name }</td> |
|||
<td>{ bucket.OwnerAccountID }</td> |
|||
<td class="text-muted small">{ bucket.ARN }</td> |
|||
<td>{ bucket.CreatedAt.Format("2006-01-02 15:04") }</td> |
|||
<td> |
|||
<div class="btn-group btn-group-sm" role="group"> |
|||
{{ bucketName, parseErr := s3tables.ParseBucketNameFromARN(bucket.ARN) }} |
|||
if parseErr == nil { |
|||
<a class="btn btn-outline-primary btn-sm" href={ templ.SafeURL(fmt.Sprintf("/object-store/s3tables/buckets/%s/namespaces", bucketName)) }> |
|||
<i class="fas fa-folder-open"></i> |
|||
</a> |
|||
} else { |
|||
<button type="button" class="btn btn-outline-primary btn-sm" disabled title="Invalid bucket ARN"> |
|||
<i class="fas fa-folder-open"></i> |
|||
</button> |
|||
} |
|||
<button type="button" class="btn btn-outline-success btn-sm s3tables-tags-btn" data-resource-arn={ bucket.ARN } title="Tags"> |
|||
<i class="fas fa-tags"></i> |
|||
</button> |
|||
<button type="button" class="btn btn-outline-info btn-sm s3tables-bucket-policy-btn" data-bucket-arn={ bucket.ARN } title="Bucket Policy"> |
|||
<i class="fas fa-shield-alt"></i> |
|||
</button> |
|||
<button type="button" class="btn btn-outline-danger btn-sm s3tables-delete-bucket-btn" data-bucket-arn={ bucket.ARN } data-bucket-name={ bucket.Name } title="Delete"> |
|||
<i class="fas fa-trash"></i> |
|||
</button> |
|||
</div> |
|||
</td> |
|||
</tr> |
|||
} |
|||
if len(data.Buckets) == 0 { |
|||
<tr> |
|||
<td colspan="5" class="text-center text-muted py-4"> |
|||
<i class="fas fa-table fa-3x mb-3 text-muted"></i> |
|||
<div> |
|||
<h5>No table buckets found</h5> |
|||
<p>Create your first S3 Tables bucket to get started.</p> |
|||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createS3TablesBucketModal"> |
|||
<i class="fas fa-plus me-1"></i>Create Bucket |
|||
</button> |
|||
</div> |
|||
</td> |
|||
</tr> |
|||
} |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="modal fade" id="createS3TablesBucketModal" tabindex="-1" aria-labelledby="createS3TablesBucketModalLabel" aria-hidden="true"> |
|||
<div class="modal-dialog"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<h5 class="modal-title" id="createS3TablesBucketModalLabel"> |
|||
<i class="fas fa-plus me-2"></i>Create Table Bucket |
|||
</h5> |
|||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |
|||
</div> |
|||
<form id="createS3TablesBucketForm"> |
|||
<div class="modal-body"> |
|||
<div class="mb-3"> |
|||
<label for="s3tablesBucketName" class="form-label">Bucket Name</label> |
|||
<input type="text" class="form-control" id="s3tablesBucketName" name="name" placeholder="table-bucket-name" required/> |
|||
</div> |
|||
<div class="mb-3"> |
|||
<label for="s3tablesBucketOwner" class="form-label">Owner (Optional)</label> |
|||
<select class="form-select" id="s3tablesBucketOwner" name="owner"> |
|||
<option value="">No owner (admin-only access)</option> |
|||
</select> |
|||
<div class="form-text"> |
|||
The S3 identity that owns this table bucket. Non-admin users can only access table buckets they own. |
|||
</div> |
|||
</div> |
|||
<div class="mb-3"> |
|||
<label for="s3tablesBucketTags" class="form-label">Tags</label> |
|||
<input type="text" class="form-control" id="s3tablesBucketTags" name="tags" placeholder="key1=value1,key2=value2"/> |
|||
<div class="form-text">Optional tags in key=value format.</div> |
|||
</div> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> |
|||
<button type="submit" class="btn btn-primary"> |
|||
<i class="fas fa-plus me-1"></i>Create |
|||
</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="modal fade" id="deleteS3TablesBucketModal" tabindex="-1" aria-labelledby="deleteS3TablesBucketModalLabel" aria-hidden="true"> |
|||
<div class="modal-dialog"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<h5 class="modal-title" id="deleteS3TablesBucketModalLabel"> |
|||
<i class="fas fa-exclamation-triangle me-2 text-warning"></i>Delete Table Bucket |
|||
</h5> |
|||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |
|||
</div> |
|||
<div class="modal-body"> |
|||
<p>Are you sure you want to delete the table bucket <strong id="deleteS3TablesBucketName"></strong>?</p> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> |
|||
<button type="button" class="btn btn-danger" onclick="deleteS3TablesBucket()"> |
|||
<i class="fas fa-trash me-1"></i>Delete |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="modal fade" id="s3tablesBucketPolicyModal" tabindex="-1" aria-labelledby="s3tablesBucketPolicyModalLabel" aria-hidden="true"> |
|||
<div class="modal-dialog modal-lg"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<h5 class="modal-title" id="s3tablesBucketPolicyModalLabel"> |
|||
<i class="fas fa-shield-alt me-2"></i>Table Bucket Policy |
|||
</h5> |
|||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |
|||
</div> |
|||
<form id="s3tablesBucketPolicyForm"> |
|||
<div class="modal-body"> |
|||
<input type="hidden" id="s3tablesBucketPolicyArn" name="bucket_arn"/> |
|||
<div class="mb-3"> |
|||
<label for="s3tablesBucketPolicyText" class="form-label">Policy JSON</label> |
|||
<textarea class="form-control" id="s3tablesBucketPolicyText" name="policy" rows="12" placeholder="{ }"></textarea> |
|||
</div> |
|||
<div class="form-text"> |
|||
Provide a policy JSON; use Delete Policy to remove the policy. |
|||
</div> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> |
|||
<button type="button" class="btn btn-outline-danger" onclick="deleteS3TablesBucketPolicy()"> |
|||
<i class="fas fa-trash me-1"></i>Delete Policy |
|||
</button> |
|||
<button type="submit" class="btn btn-primary"> |
|||
<i class="fas fa-save me-1"></i>Save Policy |
|||
</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="modal fade" id="s3tablesTagsModal" tabindex="-1" aria-labelledby="s3tablesTagsModalLabel" aria-hidden="true"> |
|||
<div class="modal-dialog modal-lg"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<h5 class="modal-title" id="s3tablesTagsModalLabel"> |
|||
<i class="fas fa-tags me-2"></i>Resource Tags |
|||
</h5> |
|||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |
|||
</div> |
|||
<form id="s3tablesTagsForm"> |
|||
<div class="modal-body"> |
|||
<input type="hidden" id="s3tablesTagsResourceArn" name="resource_arn"/> |
|||
<div class="mb-3"> |
|||
<label class="form-label">Existing Tags</label> |
|||
<pre class="bg-light p-3 border rounded" id="s3tablesTagsList">Loading...</pre> |
|||
</div> |
|||
<div class="mb-3"> |
|||
<label for="s3tablesTagsInput" class="form-label">Add or Update Tags</label> |
|||
<input type="text" class="form-control" id="s3tablesTagsInput" placeholder="key1=value1,key2=value2"/> |
|||
</div> |
|||
<div class="mb-3"> |
|||
<label for="s3tablesTagsDeleteInput" class="form-label">Remove Tag Keys</label> |
|||
<input type="text" class="form-control" id="s3tablesTagsDeleteInput" placeholder="key1,key2"/> |
|||
</div> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> |
|||
<button type="button" class="btn btn-outline-danger" onclick="deleteS3TablesTags()"> |
|||
<i class="fas fa-trash me-1"></i>Remove Tags |
|||
</button> |
|||
<button type="submit" class="btn btn-primary"> |
|||
<i class="fas fa-save me-1"></i>Update Tags |
|||
</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<script> |
|||
document.addEventListener('DOMContentLoaded', function() { |
|||
initS3TablesBuckets(); |
|||
}); |
|||
</script> |
|||
} |
|||
222
weed/admin/view/app/s3tables_buckets_templ.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,242 @@ |
|||
package app |
|||
|
|||
import ( |
|||
"fmt" |
|||
"strings" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/admin/dash" |
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" |
|||
) |
|||
|
|||
templ S3TablesNamespaces(data dash.S3TablesNamespacesData) { |
|||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> |
|||
<h1 class="h2"> |
|||
<i class="fas fa-layer-group me-2"></i>S3 Tables Namespaces |
|||
</h1> |
|||
<div class="btn-toolbar mb-2 mb-md-0"> |
|||
<div class="btn-group me-2"> |
|||
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#createS3TablesNamespaceModal"> |
|||
<i class="fas fa-plus me-1"></i>Create Namespace |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="mb-3"> |
|||
<a href="/object-store/s3tables/buckets" class="btn btn-sm btn-outline-secondary"> |
|||
<i class="fas fa-arrow-left me-1"></i>Back to Buckets |
|||
</a> |
|||
<span class="text-muted ms-2">Bucket ARN: { data.BucketARN }</span> |
|||
</div> |
|||
<div id="s3tables-namespaces-content" data-bucket-arn={ data.BucketARN }> |
|||
<div class="row mb-4"> |
|||
<div class="col-xl-4 col-md-6 mb-4"> |
|||
<div class="card border-left-primary shadow h-100 py-2"> |
|||
<div class="card-body"> |
|||
<div class="row no-gutters align-items-center"> |
|||
<div class="col mr-2"> |
|||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1"> |
|||
Total Namespaces |
|||
</div> |
|||
<div class="h5 mb-0 font-weight-bold text-gray-800"> |
|||
{ fmt.Sprintf("%d", data.TotalNamespaces) } |
|||
</div> |
|||
</div> |
|||
<div class="col-auto"> |
|||
<i class="fas fa-layer-group fa-2x text-gray-300"></i> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="col-xl-4 col-md-6 mb-4"> |
|||
<div class="card border-left-info shadow h-100 py-2"> |
|||
<div class="card-body"> |
|||
<div class="row no-gutters align-items-center"> |
|||
<div class="col mr-2"> |
|||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1"> |
|||
Last Updated |
|||
</div> |
|||
<div class="h6 mb-0 font-weight-bold text-gray-800"> |
|||
{ data.LastUpdated.Format("15:04") } |
|||
</div> |
|||
</div> |
|||
<div class="col-auto"> |
|||
<i class="fas fa-clock fa-2x text-gray-300"></i> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="row"> |
|||
<div class="col-12"> |
|||
<div class="card shadow mb-4"> |
|||
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between"> |
|||
<h6 class="m-0 font-weight-bold text-primary"> |
|||
<i class="fas fa-layer-group me-2"></i>Namespaces |
|||
</h6> |
|||
</div> |
|||
<div class="card-body"> |
|||
<div class="table-responsive"> |
|||
<table class="table table-hover" width="100%" cellspacing="0" id="s3tablesNamespacesTable"> |
|||
<thead> |
|||
<tr> |
|||
<th>Namespace</th> |
|||
<th>Created</th> |
|||
<th>Actions</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
for _, namespace := range data.Namespaces { |
|||
<tr> |
|||
<td>{ strings.Join(namespace.Namespace, ".") }</td> |
|||
<td>{ namespace.CreatedAt.Format("2006-01-02 15:04") }</td> |
|||
<td> |
|||
<div class="btn-group btn-group-sm" role="group"> |
|||
{{ bucketName, parseErr := s3tables.ParseBucketNameFromARN(data.BucketARN) }} |
|||
{{ namespaceName := strings.Join(namespace.Namespace, ".") }} |
|||
if parseErr == nil { |
|||
<a class="btn btn-outline-primary btn-sm" href={ templ.SafeURL(fmt.Sprintf("/object-store/s3tables/buckets/%s/namespaces/%s/tables", bucketName, namespaceName)) }> |
|||
<i class="fas fa-table"></i> |
|||
</a> |
|||
} else { |
|||
<button type="button" class="btn btn-outline-primary btn-sm" disabled title="Invalid bucket ARN"> |
|||
<i class="fas fa-table"></i> |
|||
</button> |
|||
} |
|||
<button type="button" class="btn btn-outline-danger btn-sm s3tables-delete-namespace-btn" data-namespace-name={ namespaceName } title="Delete"> |
|||
<i class="fas fa-trash"></i> |
|||
</button> |
|||
</div> |
|||
</td> |
|||
</tr> |
|||
} |
|||
if len(data.Namespaces) == 0 { |
|||
<tr> |
|||
<td colspan="3" class="text-center text-muted py-4"> |
|||
<i class="fas fa-layer-group fa-3x mb-3 text-muted"></i> |
|||
<div> |
|||
<h5>No namespaces found</h5> |
|||
<p>Create your first namespace to organize tables.</p> |
|||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createS3TablesNamespaceModal"> |
|||
<i class="fas fa-plus me-1"></i>Create Namespace |
|||
</button> |
|||
</div> |
|||
</td> |
|||
</tr> |
|||
} |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="modal fade" id="createS3TablesNamespaceModal" tabindex="-1" aria-labelledby="createS3TablesNamespaceModalLabel" aria-hidden="true"> |
|||
<div class="modal-dialog"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<h5 class="modal-title" id="createS3TablesNamespaceModalLabel"> |
|||
<i class="fas fa-plus me-2"></i>Create Namespace |
|||
</h5> |
|||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |
|||
</div> |
|||
<form id="createS3TablesNamespaceForm"> |
|||
<div class="modal-body"> |
|||
<input type="hidden" name="bucket_arn" value={ data.BucketARN }/> |
|||
<div class="mb-3"> |
|||
<label for="s3tablesNamespaceName" class="form-label">Namespace</label> |
|||
<input type="text" class="form-control" id="s3tablesNamespaceName" name="name" placeholder="analytics" required/> |
|||
<div class="form-text">Namespaces use a single level (no dots).</div> |
|||
</div> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> |
|||
<button type="submit" class="btn btn-primary"> |
|||
<i class="fas fa-plus me-1"></i>Create |
|||
</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="modal fade" id="deleteS3TablesNamespaceModal" tabindex="-1" aria-labelledby="deleteS3TablesNamespaceModalLabel" aria-hidden="true"> |
|||
<div class="modal-dialog"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<h5 class="modal-title" id="deleteS3TablesNamespaceModalLabel"> |
|||
<i class="fas fa-exclamation-triangle me-2 text-warning"></i>Delete Namespace |
|||
</h5> |
|||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |
|||
</div> |
|||
<div class="modal-body"> |
|||
<p>Are you sure you want to delete the namespace <strong id="deleteS3TablesNamespaceName"></strong>?</p> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> |
|||
<button type="button" class="btn btn-danger" onclick="deleteS3TablesNamespace()"> |
|||
<i class="fas fa-trash me-1"></i>Delete |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<script> |
|||
let s3tablesNamespaceDeleteModal = null; |
|||
|
|||
document.addEventListener('DOMContentLoaded', function() { |
|||
s3tablesNamespaceDeleteModal = new bootstrap.Modal(document.getElementById('deleteS3TablesNamespaceModal')); |
|||
|
|||
document.querySelectorAll('.s3tables-delete-namespace-btn').forEach(button => { |
|||
button.addEventListener('click', function() { |
|||
document.getElementById('deleteS3TablesNamespaceName').textContent = this.dataset.namespaceName || ''; |
|||
document.getElementById('deleteS3TablesNamespaceModal').dataset.namespaceName = this.dataset.namespaceName || ''; |
|||
s3tablesNamespaceDeleteModal.show(); |
|||
}); |
|||
}); |
|||
|
|||
document.getElementById('createS3TablesNamespaceForm').addEventListener('submit', async function(e) { |
|||
e.preventDefault(); |
|||
const name = document.getElementById('s3tablesNamespaceName').value.trim(); |
|||
try { |
|||
const response = await fetch('/api/s3tables/namespaces', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ bucket_arn: dataBucketArn, name: name }) |
|||
}); |
|||
const payload = await response.json(); |
|||
if (!response.ok) { |
|||
alert(payload.error || 'Failed to create namespace'); |
|||
return; |
|||
} |
|||
alert('Namespace created'); |
|||
location.reload(); |
|||
} catch (error) { |
|||
alert('Failed to create namespace: ' + error.message); |
|||
} |
|||
}); |
|||
|
|||
}); |
|||
|
|||
const dataBucketArn = document.getElementById('s3tables-namespaces-content').dataset.bucketArn || ''; |
|||
|
|||
async function deleteS3TablesNamespace() { |
|||
const namespace = document.getElementById('deleteS3TablesNamespaceModal').dataset.namespaceName; |
|||
if (!namespace) return; |
|||
try { |
|||
const response = await fetch(`/api/s3tables/namespaces?bucket=${encodeURIComponent(dataBucketArn)}&name=${encodeURIComponent(namespace)}`, { method: 'DELETE' }); |
|||
const payload = await response.json(); |
|||
if (!response.ok) { |
|||
alert(payload.error || 'Failed to delete namespace'); |
|||
return; |
|||
} |
|||
alert('Namespace deleted'); |
|||
location.reload(); |
|||
} catch (error) { |
|||
alert('Failed to delete namespace: ' + error.message); |
|||
} |
|||
} |
|||
|
|||
</script> |
|||
} |
|||
@ -0,0 +1,198 @@ |
|||
// Code generated by templ - DO NOT EDIT.
|
|||
|
|||
// templ: version: v0.3.960
|
|||
package app |
|||
|
|||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
|||
|
|||
import "github.com/a-h/templ" |
|||
import templruntime "github.com/a-h/templ/runtime" |
|||
|
|||
import ( |
|||
"fmt" |
|||
"strings" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/admin/dash" |
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" |
|||
) |
|||
|
|||
func S3TablesNamespaces(data dash.S3TablesNamespacesData) templ.Component { |
|||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { |
|||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context |
|||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { |
|||
return templ_7745c5c3_CtxErr |
|||
} |
|||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) |
|||
if !templ_7745c5c3_IsBuffer { |
|||
defer func() { |
|||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) |
|||
if templ_7745c5c3_Err == nil { |
|||
templ_7745c5c3_Err = templ_7745c5c3_BufErr |
|||
} |
|||
}() |
|||
} |
|||
ctx = templ.InitializeContext(ctx) |
|||
templ_7745c5c3_Var1 := templ.GetChildren(ctx) |
|||
if templ_7745c5c3_Var1 == nil { |
|||
templ_7745c5c3_Var1 = templ.NopComponent |
|||
} |
|||
ctx = templ.ClearChildren(ctx) |
|||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom\"><h1 class=\"h2\"><i class=\"fas fa-layer-group me-2\"></i>S3 Tables Namespaces</h1><div class=\"btn-toolbar mb-2 mb-md-0\"><div class=\"btn-group me-2\"><button type=\"button\" class=\"btn btn-sm btn-primary\" data-bs-toggle=\"modal\" data-bs-target=\"#createS3TablesNamespaceModal\"><i class=\"fas fa-plus me-1\"></i>Create Namespace</button></div></div></div><div class=\"mb-3\"><a href=\"/object-store/s3tables/buckets\" class=\"btn btn-sm btn-outline-secondary\"><i class=\"fas fa-arrow-left me-1\"></i>Back to Buckets</a> <span class=\"text-muted ms-2\">Bucket ARN: ") |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
var templ_7745c5c3_Var2 string |
|||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.BucketARN) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3tables_namespaces.templ`, Line: 28, Col: 60} |
|||
} |
|||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span></div><div id=\"s3tables-namespaces-content\" data-bucket-arn=\"") |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
var templ_7745c5c3_Var3 string |
|||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.BucketARN) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3tables_namespaces.templ`, Line: 30, Col: 71} |
|||
} |
|||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"><div class=\"row mb-4\"><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-primary shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-primary text-uppercase mb-1\">Total Namespaces</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">") |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
var templ_7745c5c3_Var4 string |
|||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalNamespaces)) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3tables_namespaces.templ`, Line: 41, Col: 50} |
|||
} |
|||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></div><div class=\"col-auto\"><i class=\"fas fa-layer-group fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-info shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-info text-uppercase mb-1\">Last Updated</div><div class=\"h6 mb-0 font-weight-bold text-gray-800\">") |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
var templ_7745c5c3_Var5 string |
|||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("15:04")) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3tables_namespaces.templ`, Line: 60, Col: 43} |
|||
} |
|||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></div><div class=\"col-auto\"><i class=\"fas fa-clock fa-2x text-gray-300\"></i></div></div></div></div></div></div><div class=\"row\"><div class=\"col-12\"><div class=\"card shadow mb-4\"><div class=\"card-header py-3 d-flex flex-row align-items-center justify-content-between\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-layer-group me-2\"></i>Namespaces</h6></div><div class=\"card-body\"><div class=\"table-responsive\"><table class=\"table table-hover\" width=\"100%\" cellspacing=\"0\" id=\"s3tablesNamespacesTable\"><thead><tr><th>Namespace</th><th>Created</th><th>Actions</th></tr></thead> <tbody>") |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
for _, namespace := range data.Namespaces { |
|||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<tr><td>") |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
var templ_7745c5c3_Var6 string |
|||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(namespace.Namespace, ".")) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3tables_namespaces.templ`, Line: 92, Col: 55} |
|||
} |
|||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</td><td>") |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
var templ_7745c5c3_Var7 string |
|||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(namespace.CreatedAt.Format("2006-01-02 15:04")) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3tables_namespaces.templ`, Line: 93, Col: 63} |
|||
} |
|||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</td><td><div class=\"btn-group btn-group-sm\" role=\"group\">") |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
bucketName, parseErr := s3tables.ParseBucketNameFromARN(data.BucketARN) |
|||
namespaceName := strings.Join(namespace.Namespace, ".") |
|||
if parseErr == nil { |
|||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a class=\"btn btn-outline-primary btn-sm\" href=\"") |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
var templ_7745c5c3_Var8 templ.SafeURL |
|||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/object-store/s3tables/buckets/%s/namespaces/%s/tables", bucketName, namespaceName))) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3tables_namespaces.templ`, Line: 99, Col: 174} |
|||
} |
|||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"><i class=\"fas fa-table\"></i></a> ") |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
} else { |
|||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<button type=\"button\" class=\"btn btn-outline-primary btn-sm\" disabled title=\"Invalid bucket ARN\"><i class=\"fas fa-table\"></i></button> ") |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
} |
|||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<button type=\"button\" class=\"btn btn-outline-danger btn-sm s3tables-delete-namespace-btn\" data-namespace-name=\"") |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
var templ_7745c5c3_Var9 string |
|||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(namespaceName) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3tables_namespaces.templ`, Line: 107, Col: 138} |
|||
} |
|||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" title=\"Delete\"><i class=\"fas fa-trash\"></i></button></div></td></tr>") |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
} |
|||
if len(data.Namespaces) == 0 { |
|||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<tr><td colspan=\"3\" class=\"text-center text-muted py-4\"><i class=\"fas fa-layer-group fa-3x mb-3 text-muted\"></i><div><h5>No namespaces found</h5><p>Create your first namespace to organize tables.</p><button type=\"button\" class=\"btn btn-primary\" data-bs-toggle=\"modal\" data-bs-target=\"#createS3TablesNamespaceModal\"><i class=\"fas fa-plus me-1\"></i>Create Namespace</button></div></td></tr>") |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
} |
|||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</tbody></table></div></div></div></div></div></div><div class=\"modal fade\" id=\"createS3TablesNamespaceModal\" tabindex=\"-1\" aria-labelledby=\"createS3TablesNamespaceModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createS3TablesNamespaceModalLabel\"><i class=\"fas fa-plus me-2\"></i>Create Namespace</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"createS3TablesNamespaceForm\"><div class=\"modal-body\"><input type=\"hidden\" name=\"bucket_arn\" value=\"") |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
var templ_7745c5c3_Var10 string |
|||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.BucketARN) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3tables_namespaces.templ`, Line: 147, Col: 66} |
|||
} |
|||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"><div class=\"mb-3\"><label for=\"s3tablesNamespaceName\" class=\"form-label\">Namespace</label> <input type=\"text\" class=\"form-control\" id=\"s3tablesNamespaceName\" name=\"name\" placeholder=\"analytics\" required><div class=\"form-text\">Namespaces use a single level (no dots).</div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-plus me-1\"></i>Create</button></div></form></div></div></div><div class=\"modal fade\" id=\"deleteS3TablesNamespaceModal\" tabindex=\"-1\" aria-labelledby=\"deleteS3TablesNamespaceModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"deleteS3TablesNamespaceModalLabel\"><i class=\"fas fa-exclamation-triangle me-2 text-warning\"></i>Delete Namespace</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><p>Are you sure you want to delete the namespace <strong id=\"deleteS3TablesNamespaceName\"></strong>?</p></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-danger\" onclick=\"deleteS3TablesNamespace()\"><i class=\"fas fa-trash me-1\"></i>Delete</button></div></div></div></div><script>\n\t\tlet s3tablesNamespaceDeleteModal = null;\n\n\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\ts3tablesNamespaceDeleteModal = new bootstrap.Modal(document.getElementById('deleteS3TablesNamespaceModal'));\n\n\t\t\tdocument.querySelectorAll('.s3tables-delete-namespace-btn').forEach(button => {\n\t\t\t\tbutton.addEventListener('click', function() {\n\t\t\t\t\tdocument.getElementById('deleteS3TablesNamespaceName').textContent = this.dataset.namespaceName || '';\n\t\t\t\t\tdocument.getElementById('deleteS3TablesNamespaceModal').dataset.namespaceName = this.dataset.namespaceName || '';\n\t\t\t\t\ts3tablesNamespaceDeleteModal.show();\n\t\t\t\t});\n\t\t\t});\n\n\t\t\tdocument.getElementById('createS3TablesNamespaceForm').addEventListener('submit', async function(e) {\n\t\t\t\te.preventDefault();\n\t\t\t\tconst name = document.getElementById('s3tablesNamespaceName').value.trim();\n\t\t\t\ttry {\n\t\t\t\t\t\tconst response = await fetch('/api/s3tables/namespaces', {\n\t\t\t\t\t\t\tmethod: 'POST',\n\t\t\t\t\t\t\theaders: { 'Content-Type': 'application/json' },\n\t\t\t\t\t\t\tbody: JSON.stringify({ bucket_arn: dataBucketArn, name: name })\n\t\t\t\t\t\t});\n\t\t\t\t\tconst payload = await response.json();\n\t\t\t\t\tif (!response.ok) {\n\t\t\t\t\t\talert(payload.error || 'Failed to create namespace');\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\talert('Namespace created');\n\t\t\t\t\tlocation.reload();\n\t\t\t\t} catch (error) {\n\t\t\t\t\talert('Failed to create namespace: ' + error.message);\n\t\t\t\t}\n\t\t\t});\n\n\t\t});\n\n\t\tconst dataBucketArn = document.getElementById('s3tables-namespaces-content').dataset.bucketArn || '';\n\n\t\tasync function deleteS3TablesNamespace() {\n\t\t\tconst namespace = document.getElementById('deleteS3TablesNamespaceModal').dataset.namespaceName;\n\t\t\tif (!namespace) return;\n\t\t\ttry {\n\t\t\t\tconst response = await fetch(`/api/s3tables/namespaces?bucket=${encodeURIComponent(dataBucketArn)}&name=${encodeURIComponent(namespace)}`, { method: 'DELETE' });\n\t\t\t\tconst payload = await response.json();\n\t\t\t\tif (!response.ok) {\n\t\t\t\t\talert(payload.error || 'Failed to delete namespace');\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\talert('Namespace deleted');\n\t\t\t\tlocation.reload();\n\t\t\t} catch (error) {\n\t\t\t\talert('Failed to delete namespace: ' + error.message);\n\t\t\t}\n\t\t}\n\n\t</script>") |
|||
if templ_7745c5c3_Err != nil { |
|||
return templ_7745c5c3_Err |
|||
} |
|||
return nil |
|||
}) |
|||
} |
|||
|
|||
var _ = templruntime.GeneratedTemplate |
|||
@ -0,0 +1,294 @@ |
|||
package app |
|||
|
|||
import ( |
|||
"fmt" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/admin/dash" |
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" |
|||
) |
|||
|
|||
templ S3TablesTables(data dash.S3TablesTablesData) { |
|||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> |
|||
<h1 class="h2"> |
|||
<i class="fas fa-table me-2"></i>S3 Tables |
|||
</h1> |
|||
<div class="btn-toolbar mb-2 mb-md-0"> |
|||
<div class="btn-group me-2"> |
|||
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#createS3TablesTableModal"> |
|||
<i class="fas fa-plus me-1"></i>Create Table |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="mb-3"> |
|||
{{ bucketName, parseErr := s3tables.ParseBucketNameFromARN(data.BucketARN) }} |
|||
if parseErr == nil { |
|||
<a href={ templ.SafeURL(fmt.Sprintf("/object-store/s3tables/buckets/%s/namespaces", bucketName)) } class="btn btn-sm btn-outline-secondary"> |
|||
<i class="fas fa-arrow-left me-1"></i>Back to Namespaces |
|||
</a> |
|||
} else { |
|||
<button type="button" class="btn btn-sm btn-outline-secondary" disabled title="Invalid bucket ARN"> |
|||
<i class="fas fa-arrow-left me-1"></i>Back to Namespaces |
|||
</button> |
|||
} |
|||
<span class="text-muted ms-2">Bucket ARN: { data.BucketARN }</span> |
|||
<span class="text-muted ms-2">Namespace: { data.Namespace }</span> |
|||
if parseErr != nil { |
|||
<span class="text-danger ms-2">Invalid bucket ARN</span> |
|||
} |
|||
</div> |
|||
<div id="s3tables-tables-content" data-bucket-arn={ data.BucketARN } data-namespace={ data.Namespace }> |
|||
<div class="row mb-4"> |
|||
<div class="col-xl-4 col-md-6 mb-4"> |
|||
<div class="card border-left-primary shadow h-100 py-2"> |
|||
<div class="card-body"> |
|||
<div class="row no-gutters align-items-center"> |
|||
<div class="col mr-2"> |
|||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1"> |
|||
Total Tables |
|||
</div> |
|||
<div class="h5 mb-0 font-weight-bold text-gray-800"> |
|||
{ fmt.Sprintf("%d", data.TotalTables) } |
|||
</div> |
|||
</div> |
|||
<div class="col-auto"> |
|||
<i class="fas fa-table fa-2x text-gray-300"></i> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="col-xl-4 col-md-6 mb-4"> |
|||
<div class="card border-left-info shadow h-100 py-2"> |
|||
<div class="card-body"> |
|||
<div class="row no-gutters align-items-center"> |
|||
<div class="col mr-2"> |
|||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1"> |
|||
Last Updated |
|||
</div> |
|||
<div class="h6 mb-0 font-weight-bold text-gray-800"> |
|||
{ data.LastUpdated.Format("15:04") } |
|||
</div> |
|||
</div> |
|||
<div class="col-auto"> |
|||
<i class="fas fa-clock fa-2x text-gray-300"></i> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="row"> |
|||
<div class="col-12"> |
|||
<div class="card shadow mb-4"> |
|||
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between"> |
|||
<h6 class="m-0 font-weight-bold text-primary"> |
|||
<i class="fas fa-table me-2"></i>Tables |
|||
</h6> |
|||
</div> |
|||
<div class="card-body"> |
|||
<div class="table-responsive"> |
|||
<table class="table table-hover" width="100%" cellspacing="0" id="s3tablesTablesTable"> |
|||
<thead> |
|||
<tr> |
|||
<th>Name</th> |
|||
<th>Table ARN</th> |
|||
<th>Created</th> |
|||
<th>Modified</th> |
|||
<th>Metadata</th> |
|||
<th>Actions</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
for _, table := range data.Tables { |
|||
<tr> |
|||
{{ tableName := table.Name }} |
|||
<td>{ tableName }</td> |
|||
<td class="text-muted small">{ table.TableARN }</td> |
|||
<td>{ table.CreatedAt.Format("2006-01-02 15:04") }</td> |
|||
<td>{ table.ModifiedAt.Format("2006-01-02 15:04") }</td> |
|||
<td> |
|||
if table.MetadataLocation != "" { |
|||
<span class="text-muted small">{ table.MetadataLocation }</span> |
|||
} else { |
|||
<span class="text-muted">-</span> |
|||
} |
|||
</td> |
|||
<td> |
|||
<div class="btn-group btn-group-sm" role="group"> |
|||
<button type="button" class="btn btn-outline-success btn-sm s3tables-tags-btn" data-resource-arn={ table.TableARN } title="Tags"> |
|||
<i class="fas fa-tags"></i> |
|||
</button> |
|||
<button type="button" class="btn btn-outline-info btn-sm s3tables-table-policy-btn" data-table-arn={ table.TableARN } data-table-name={ tableName } title="Table Policy"> |
|||
<i class="fas fa-shield-alt"></i> |
|||
</button> |
|||
<button type="button" class="btn btn-outline-danger btn-sm s3tables-delete-table-btn" data-table-name={ tableName } title="Delete"> |
|||
<i class="fas fa-trash"></i> |
|||
</button> |
|||
</div> |
|||
</td> |
|||
</tr> |
|||
} |
|||
if len(data.Tables) == 0 { |
|||
<tr> |
|||
<td colspan="6" class="text-center text-muted py-4"> |
|||
<i class="fas fa-table fa-3x mb-3 text-muted"></i> |
|||
<div> |
|||
<h5>No tables found</h5> |
|||
<p>Create your first table to start storing data.</p> |
|||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createS3TablesTableModal"> |
|||
<i class="fas fa-plus me-1"></i>Create Table |
|||
</button> |
|||
</div> |
|||
</td> |
|||
</tr> |
|||
} |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="modal fade" id="createS3TablesTableModal" tabindex="-1" aria-labelledby="createS3TablesTableModalLabel" aria-hidden="true"> |
|||
<div class="modal-dialog modal-lg"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<h5 class="modal-title" id="createS3TablesTableModalLabel"> |
|||
<i class="fas fa-plus me-2"></i>Create Table |
|||
</h5> |
|||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |
|||
</div> |
|||
<form id="createS3TablesTableForm"> |
|||
<div class="modal-body"> |
|||
<div class="mb-3"> |
|||
<label for="s3tablesTableName" class="form-label">Table Name</label> |
|||
<input type="text" class="form-control" id="s3tablesTableName" name="name" required/> |
|||
</div> |
|||
<div class="mb-3"> |
|||
<label for="s3tablesTableFormat" class="form-label">Format</label> |
|||
<select class="form-select" id="s3tablesTableFormat" name="format"> |
|||
<option value="ICEBERG" selected>ICEBERG</option> |
|||
</select> |
|||
</div> |
|||
<div class="mb-3"> |
|||
<label for="s3tablesTableMetadata" class="form-label">Metadata JSON (optional)</label> |
|||
<textarea class="form-control" id="s3tablesTableMetadata" name="metadata" rows="6" placeholder="{ }"></textarea> |
|||
</div> |
|||
<div class="mb-3"> |
|||
<label for="s3tablesTableTags" class="form-label">Tags</label> |
|||
<input type="text" class="form-control" id="s3tablesTableTags" name="tags" placeholder="key1=value1,key2=value2"/> |
|||
</div> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> |
|||
<button type="submit" class="btn btn-primary"> |
|||
<i class="fas fa-plus me-1"></i>Create |
|||
</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="modal fade" id="deleteS3TablesTableModal" tabindex="-1" aria-labelledby="deleteS3TablesTableModalLabel" aria-hidden="true"> |
|||
<div class="modal-dialog"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<h5 class="modal-title" id="deleteS3TablesTableModalLabel"> |
|||
<i class="fas fa-exclamation-triangle me-2 text-warning"></i>Delete Table |
|||
</h5> |
|||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |
|||
</div> |
|||
<div class="modal-body"> |
|||
<p>Are you sure you want to delete the table <strong id="deleteS3TablesTableName"></strong>?</p> |
|||
<div class="mb-3"> |
|||
<label for="deleteS3TablesTableVersion" class="form-label">Version Token (optional)</label> |
|||
<input type="text" class="form-control" id="deleteS3TablesTableVersion" placeholder="Version token"/> |
|||
</div> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> |
|||
<button type="button" class="btn btn-danger" onclick="deleteS3TablesTable()"> |
|||
<i class="fas fa-trash me-1"></i>Delete |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="modal fade" id="s3tablesTablePolicyModal" tabindex="-1" aria-labelledby="s3tablesTablePolicyModalLabel" aria-hidden="true"> |
|||
<div class="modal-dialog modal-lg"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<h5 class="modal-title" id="s3tablesTablePolicyModalLabel"> |
|||
<i class="fas fa-shield-alt me-2"></i>Table Policy |
|||
</h5> |
|||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |
|||
</div> |
|||
<form id="s3tablesTablePolicyForm"> |
|||
<div class="modal-body"> |
|||
<input type="hidden" id="s3tablesTablePolicyBucketArn" name="bucket_arn"/> |
|||
<input type="hidden" id="s3tablesTablePolicyNamespace" name="namespace"/> |
|||
<input type="hidden" id="s3tablesTablePolicyName" name="name"/> |
|||
<div class="mb-3"> |
|||
<label for="s3tablesTablePolicyText" class="form-label">Policy JSON</label> |
|||
<textarea class="form-control" id="s3tablesTablePolicyText" name="policy" rows="12" placeholder="{ }"></textarea> |
|||
</div> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> |
|||
<button type="button" class="btn btn-outline-danger" onclick="deleteS3TablesTablePolicy()"> |
|||
<i class="fas fa-trash me-1"></i>Delete Policy |
|||
</button> |
|||
<button type="submit" class="btn btn-primary"> |
|||
<i class="fas fa-save me-1"></i>Save Policy |
|||
</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="modal fade" id="s3tablesTagsModal" tabindex="-1" aria-labelledby="s3tablesTagsModalLabel" aria-hidden="true"> |
|||
<div class="modal-dialog modal-lg"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<h5 class="modal-title" id="s3tablesTagsModalLabel"> |
|||
<i class="fas fa-tags me-2"></i>Resource Tags |
|||
</h5> |
|||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |
|||
</div> |
|||
<form id="s3tablesTagsForm"> |
|||
<div class="modal-body"> |
|||
<input type="hidden" id="s3tablesTagsResourceArn" name="resource_arn"/> |
|||
<div class="mb-3"> |
|||
<label class="form-label">Existing Tags</label> |
|||
<pre class="bg-light p-3 border rounded" id="s3tablesTagsList">Loading...</pre> |
|||
</div> |
|||
<div class="mb-3"> |
|||
<label for="s3tablesTagsInput" class="form-label">Add or Update Tags</label> |
|||
<input type="text" class="form-control" id="s3tablesTagsInput" placeholder="key1=value1,key2=value2"/> |
|||
</div> |
|||
<div class="mb-3"> |
|||
<label for="s3tablesTagsDeleteInput" class="form-label">Remove Tag Keys</label> |
|||
<input type="text" class="form-control" id="s3tablesTagsDeleteInput" placeholder="key1,key2"/> |
|||
</div> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> |
|||
<button type="button" class="btn btn-outline-danger" onclick="deleteS3TablesTags()"> |
|||
<i class="fas fa-trash me-1"></i>Remove Tags |
|||
</button> |
|||
<button type="submit" class="btn btn-primary"> |
|||
<i class="fas fa-save me-1"></i>Update Tags |
|||
</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<script> |
|||
document.addEventListener('DOMContentLoaded', function() { |
|||
initS3TablesTables(); |
|||
}); |
|||
</script> |
|||
} |
|||
317
weed/admin/view/app/s3tables_tables_templ.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,98 @@ |
|||
package s3tables |
|||
|
|||
import ( |
|||
"bytes" |
|||
"context" |
|||
"encoding/json" |
|||
"errors" |
|||
"io" |
|||
"net/http" |
|||
"net/http/httptest" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" |
|||
) |
|||
|
|||
// Manager provides reusable S3 Tables operations for shell/admin without HTTP routing.
|
|||
type Manager struct { |
|||
handler *S3TablesHandler |
|||
} |
|||
|
|||
// NewManager creates a new Manager.
|
|||
func NewManager() *Manager { |
|||
return &Manager{handler: NewS3TablesHandler()} |
|||
} |
|||
|
|||
// SetRegion sets the AWS region for ARN generation.
|
|||
func (m *Manager) SetRegion(region string) { |
|||
m.handler.SetRegion(region) |
|||
} |
|||
|
|||
// SetAccountID sets the AWS account ID for ARN generation.
|
|||
func (m *Manager) SetAccountID(accountID string) { |
|||
m.handler.SetAccountID(accountID) |
|||
} |
|||
|
|||
// Execute runs an S3 Tables operation and decodes the response into resp (if provided).
|
|||
func (m *Manager) Execute(ctx context.Context, filerClient FilerClient, operation string, req interface{}, resp interface{}, identity string) error { |
|||
body, err := json.Marshal(req) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "/", bytes.NewReader(body)) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
httpReq.Header.Set("Content-Type", "application/x-amz-json-1.1") |
|||
httpReq.Header.Set("X-Amz-Target", "S3Tables."+operation) |
|||
if identity != "" { |
|||
httpReq.Header.Set(s3_constants.AmzAccountId, identity) |
|||
httpReq = httpReq.WithContext(s3_constants.SetIdentityNameInContext(httpReq.Context(), identity)) |
|||
} |
|||
recorder := httptest.NewRecorder() |
|||
m.handler.HandleRequest(recorder, httpReq, filerClient) |
|||
return decodeS3TablesHTTPResponse(recorder, resp) |
|||
} |
|||
|
|||
func decodeS3TablesHTTPResponse(recorder *httptest.ResponseRecorder, resp interface{}) error { |
|||
result := recorder.Result() |
|||
defer result.Body.Close() |
|||
data, err := io.ReadAll(result.Body) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
if result.StatusCode >= http.StatusBadRequest { |
|||
var errResp S3TablesError |
|||
if len(data) > 0 { |
|||
if jsonErr := json.Unmarshal(data, &errResp); jsonErr == nil && (errResp.Type != "" || errResp.Message != "") { |
|||
return &errResp |
|||
} |
|||
} |
|||
return &S3TablesError{Type: ErrCodeInternalError, Message: string(bytes.TrimSpace(data))} |
|||
} |
|||
if resp == nil || len(data) == 0 { |
|||
return nil |
|||
} |
|||
if err := json.Unmarshal(data, resp); err != nil { |
|||
return err |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// ManagerClient adapts a SeaweedFilerClient to the FilerClient interface.
|
|||
type ManagerClient struct { |
|||
client filer_pb.SeaweedFilerClient |
|||
} |
|||
|
|||
// NewManagerClient wraps a filer client.
|
|||
func NewManagerClient(client filer_pb.SeaweedFilerClient) *ManagerClient { |
|||
return &ManagerClient{client: client} |
|||
} |
|||
|
|||
// WithFilerClient implements FilerClient.
|
|||
func (m *ManagerClient) WithFilerClient(streamingMode bool, fn func(client filer_pb.SeaweedFilerClient) error) error { |
|||
if m.client == nil { |
|||
return errors.New("nil filer client") |
|||
} |
|||
return fn(m.client) |
|||
} |
|||
@ -0,0 +1,254 @@ |
|||
package shell |
|||
|
|||
import ( |
|||
"context" |
|||
"errors" |
|||
"flag" |
|||
"fmt" |
|||
"io" |
|||
"os" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" |
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" |
|||
) |
|||
|
|||
func init() { |
|||
Commands = append(Commands, &commandS3TablesBucket{}) |
|||
} |
|||
|
|||
type commandS3TablesBucket struct{} |
|||
|
|||
func (c *commandS3TablesBucket) Name() string { |
|||
return "s3tables.bucket" |
|||
} |
|||
|
|||
func (c *commandS3TablesBucket) Help() string { |
|||
return `manage s3tables table buckets |
|||
|
|||
# create a table bucket |
|||
s3tables.bucket -create -name <bucket> -account <account_id> [-tags key1=val1,key2=val2] |
|||
|
|||
# list table buckets |
|||
s3tables.bucket -list -account <account_id> [-prefix <prefix>] [-limit <n>] [-continuation <token>] |
|||
|
|||
# get a table bucket |
|||
s3tables.bucket -get -name <bucket> -account <account_id> |
|||
|
|||
# delete a table bucket |
|||
s3tables.bucket -delete -name <bucket> -account <account_id> |
|||
|
|||
# manage bucket policy |
|||
s3tables.bucket -put-policy -name <bucket> -account <account_id> -file policy.json |
|||
s3tables.bucket -get-policy -name <bucket> -account <account_id> |
|||
s3tables.bucket -delete-policy -name <bucket> -account <account_id> |
|||
` |
|||
} |
|||
|
|||
func (c *commandS3TablesBucket) HasTag(CommandTag) bool { |
|||
return false |
|||
} |
|||
|
|||
func (c *commandS3TablesBucket) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { |
|||
cmd := flag.NewFlagSet(c.Name(), flag.ContinueOnError) |
|||
create := cmd.Bool("create", false, "create table bucket") |
|||
list := cmd.Bool("list", false, "list table buckets") |
|||
get := cmd.Bool("get", false, "get table bucket") |
|||
deleteBucket := cmd.Bool("delete", false, "delete table bucket") |
|||
putPolicy := cmd.Bool("put-policy", false, "put table bucket policy") |
|||
getPolicy := cmd.Bool("get-policy", false, "get table bucket policy") |
|||
deletePolicy := cmd.Bool("delete-policy", false, "delete table bucket policy") |
|||
|
|||
name := cmd.String("name", "", "table bucket name") |
|||
prefix := cmd.String("prefix", "", "bucket prefix") |
|||
limit := cmd.Int("limit", 100, "max buckets to return") |
|||
continuation := cmd.String("continuation", "", "continuation token") |
|||
tags := cmd.String("tags", "", "comma separated tags key=value") |
|||
policyFile := cmd.String("file", "", "policy file (json)") |
|||
account := cmd.String("account", "", "owner account id") |
|||
|
|||
if err := cmd.Parse(args); err != nil { |
|||
return err |
|||
} |
|||
|
|||
actions := []*bool{create, list, get, deleteBucket, putPolicy, getPolicy, deletePolicy} |
|||
count := 0 |
|||
for _, action := range actions { |
|||
if *action { |
|||
count++ |
|||
} |
|||
} |
|||
if count != 1 { |
|||
return fmt.Errorf("exactly one action must be specified") |
|||
} |
|||
|
|||
switch { |
|||
case *create: |
|||
if *name == "" { |
|||
return fmt.Errorf("-name is required") |
|||
} |
|||
if *account == "" { |
|||
return fmt.Errorf("-account is required") |
|||
} |
|||
if err := ensureNoS3BucketNameConflict(commandEnv, *name); err != nil { |
|||
return err |
|||
} |
|||
req := &s3tables.CreateTableBucketRequest{Name: *name} |
|||
if *tags != "" { |
|||
parsed, err := parseS3TablesTags(*tags) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
req.Tags = parsed |
|||
} |
|||
var resp s3tables.CreateTableBucketResponse |
|||
if err := executeS3Tables(commandEnv, "CreateTableBucket", req, &resp, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintf(writer, "ARN: %s\n", resp.ARN) |
|||
case *list: |
|||
if *account == "" { |
|||
return fmt.Errorf("-account is required") |
|||
} |
|||
req := &s3tables.ListTableBucketsRequest{Prefix: *prefix, ContinuationToken: *continuation, MaxBuckets: *limit} |
|||
var resp s3tables.ListTableBucketsResponse |
|||
if err := executeS3Tables(commandEnv, "ListTableBuckets", req, &resp, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
if len(resp.TableBuckets) == 0 { |
|||
fmt.Fprintln(writer, "No table buckets found") |
|||
return nil |
|||
} |
|||
for _, bucket := range resp.TableBuckets { |
|||
fmt.Fprintf(writer, "Name: %s\n", bucket.Name) |
|||
fmt.Fprintf(writer, "ARN: %s\n", bucket.ARN) |
|||
fmt.Fprintf(writer, "CreatedAt: %s\n", bucket.CreatedAt.Format(timeFormat)) |
|||
fmt.Fprintln(writer, "---") |
|||
} |
|||
if resp.ContinuationToken != "" { |
|||
fmt.Fprintf(writer, "ContinuationToken: %s\n", resp.ContinuationToken) |
|||
} |
|||
case *get: |
|||
if *name == "" { |
|||
return fmt.Errorf("-name is required") |
|||
} |
|||
if *account == "" { |
|||
return fmt.Errorf("-account is required") |
|||
} |
|||
accountID := *account |
|||
arn, err := buildS3TablesBucketARN(*name, accountID) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
req := &s3tables.GetTableBucketRequest{TableBucketARN: arn} |
|||
var resp s3tables.GetTableBucketResponse |
|||
if err := executeS3Tables(commandEnv, "GetTableBucket", req, &resp, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintf(writer, "Name: %s\n", resp.Name) |
|||
fmt.Fprintf(writer, "ARN: %s\n", resp.ARN) |
|||
fmt.Fprintf(writer, "OwnerAccountID: %s\n", resp.OwnerAccountID) |
|||
fmt.Fprintf(writer, "CreatedAt: %s\n", resp.CreatedAt.Format(timeFormat)) |
|||
case *deleteBucket: |
|||
if *name == "" { |
|||
return fmt.Errorf("-name is required") |
|||
} |
|||
if *account == "" { |
|||
return fmt.Errorf("-account is required") |
|||
} |
|||
accountID := *account |
|||
arn, err := buildS3TablesBucketARN(*name, accountID) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
req := &s3tables.DeleteTableBucketRequest{TableBucketARN: arn} |
|||
if err := executeS3Tables(commandEnv, "DeleteTableBucket", req, nil, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintln(writer, "Deleted table bucket") |
|||
case *putPolicy: |
|||
if *name == "" { |
|||
return fmt.Errorf("-name is required") |
|||
} |
|||
if *account == "" { |
|||
return fmt.Errorf("-account is required") |
|||
} |
|||
if *policyFile == "" { |
|||
return fmt.Errorf("-file is required") |
|||
} |
|||
content, err := os.ReadFile(*policyFile) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
accountID := *account |
|||
arn, err := buildS3TablesBucketARN(*name, accountID) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
req := &s3tables.PutTableBucketPolicyRequest{TableBucketARN: arn, ResourcePolicy: string(content)} |
|||
if err := executeS3Tables(commandEnv, "PutTableBucketPolicy", req, nil, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintln(writer, "Bucket policy updated") |
|||
case *getPolicy: |
|||
if *name == "" { |
|||
return fmt.Errorf("-name is required") |
|||
} |
|||
if *account == "" { |
|||
return fmt.Errorf("-account is required") |
|||
} |
|||
accountID := *account |
|||
arn, err := buildS3TablesBucketARN(*name, accountID) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
req := &s3tables.GetTableBucketPolicyRequest{TableBucketARN: arn} |
|||
var resp s3tables.GetTableBucketPolicyResponse |
|||
if err := executeS3Tables(commandEnv, "GetTableBucketPolicy", req, &resp, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintln(writer, resp.ResourcePolicy) |
|||
case *deletePolicy: |
|||
if *name == "" { |
|||
return fmt.Errorf("-name is required") |
|||
} |
|||
if *account == "" { |
|||
return fmt.Errorf("-account is required") |
|||
} |
|||
accountID := *account |
|||
arn, err := buildS3TablesBucketARN(*name, accountID) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
req := &s3tables.DeleteTableBucketPolicyRequest{TableBucketARN: arn} |
|||
if err := executeS3Tables(commandEnv, "DeleteTableBucketPolicy", req, nil, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintln(writer, "Bucket policy deleted") |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
func ensureNoS3BucketNameConflict(commandEnv *CommandEnv, bucketName string) error { |
|||
return commandEnv.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { |
|||
resp, err := client.GetFilerConfiguration(context.Background(), &filer_pb.GetFilerConfigurationRequest{}) |
|||
if err != nil { |
|||
return fmt.Errorf("get filer configuration: %w", err) |
|||
} |
|||
filerBucketsPath := resp.DirBuckets |
|||
if filerBucketsPath == "" { |
|||
filerBucketsPath = s3_constants.DefaultBucketsPath |
|||
} |
|||
_, err = filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{ |
|||
Directory: filerBucketsPath, |
|||
Name: bucketName, |
|||
}) |
|||
if err == nil { |
|||
return fmt.Errorf("bucket name %s is already used by an object store bucket", bucketName) |
|||
} |
|||
if errors.Is(err, filer_pb.ErrNotFound) { |
|||
return nil |
|||
} |
|||
return err |
|||
}) |
|||
} |
|||
@ -0,0 +1,131 @@ |
|||
package shell |
|||
|
|||
import ( |
|||
"flag" |
|||
"fmt" |
|||
"io" |
|||
"strings" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" |
|||
) |
|||
|
|||
func init() { |
|||
Commands = append(Commands, &commandS3TablesNamespace{}) |
|||
} |
|||
|
|||
type commandS3TablesNamespace struct{} |
|||
|
|||
func (c *commandS3TablesNamespace) Name() string { |
|||
return "s3tables.namespace" |
|||
} |
|||
|
|||
func (c *commandS3TablesNamespace) Help() string { |
|||
return `manage s3tables namespaces |
|||
|
|||
# create a namespace |
|||
s3tables.namespace -create -bucket <bucket> -account <account_id> -name <namespace> |
|||
|
|||
# list namespaces |
|||
s3tables.namespace -list -bucket <bucket> -account <account_id> [-prefix <prefix>] [-limit <n>] [-continuation <token>] |
|||
|
|||
# get namespace details |
|||
s3tables.namespace -get -bucket <bucket> -account <account_id> -name <namespace> |
|||
|
|||
# delete namespace |
|||
s3tables.namespace -delete -bucket <bucket> -account <account_id> -name <namespace> |
|||
` |
|||
} |
|||
|
|||
func (c *commandS3TablesNamespace) HasTag(CommandTag) bool { |
|||
return false |
|||
} |
|||
|
|||
func (c *commandS3TablesNamespace) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { |
|||
cmd := flag.NewFlagSet(c.Name(), flag.ContinueOnError) |
|||
create := cmd.Bool("create", false, "create namespace") |
|||
list := cmd.Bool("list", false, "list namespaces") |
|||
get := cmd.Bool("get", false, "get namespace") |
|||
deleteNamespace := cmd.Bool("delete", false, "delete namespace") |
|||
|
|||
bucketName := cmd.String("bucket", "", "table bucket name") |
|||
account := cmd.String("account", "", "owner account id") |
|||
name := cmd.String("name", "", "namespace name") |
|||
prefix := cmd.String("prefix", "", "namespace prefix") |
|||
limit := cmd.Int("limit", 100, "max namespaces to return") |
|||
continuation := cmd.String("continuation", "", "continuation token") |
|||
|
|||
if err := cmd.Parse(args); err != nil { |
|||
return err |
|||
} |
|||
|
|||
actions := []*bool{create, list, get, deleteNamespace} |
|||
count := 0 |
|||
for _, action := range actions { |
|||
if *action { |
|||
count++ |
|||
} |
|||
} |
|||
if count != 1 { |
|||
return fmt.Errorf("exactly one action must be specified") |
|||
} |
|||
if *bucketName == "" { |
|||
return fmt.Errorf("-bucket is required") |
|||
} |
|||
if *account == "" { |
|||
return fmt.Errorf("-account is required") |
|||
} |
|||
|
|||
bucketArn, err := buildS3TablesBucketARN(*bucketName, *account) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
namespace := strings.TrimSpace(*name) |
|||
if (namespace == "" || namespace == "-") && (*create || *get || *deleteNamespace) { |
|||
return fmt.Errorf("-name is required") |
|||
} |
|||
|
|||
switch { |
|||
case *create: |
|||
req := &s3tables.CreateNamespaceRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}} |
|||
var resp s3tables.CreateNamespaceResponse |
|||
if err := executeS3Tables(commandEnv, "CreateNamespace", req, &resp, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintf(writer, "Namespace: %s\n", strings.Join(resp.Namespace, "/")) |
|||
case *list: |
|||
req := &s3tables.ListNamespacesRequest{TableBucketARN: bucketArn, Prefix: *prefix, ContinuationToken: *continuation, MaxNamespaces: *limit} |
|||
var resp s3tables.ListNamespacesResponse |
|||
if err := executeS3Tables(commandEnv, "ListNamespaces", req, &resp, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
if len(resp.Namespaces) == 0 { |
|||
fmt.Fprintln(writer, "No namespaces found") |
|||
return nil |
|||
} |
|||
for _, ns := range resp.Namespaces { |
|||
fmt.Fprintf(writer, "Namespace: %s\n", strings.Join(ns.Namespace, "/")) |
|||
fmt.Fprintf(writer, "CreatedAt: %s\n", ns.CreatedAt.Format(timeFormat)) |
|||
fmt.Fprintln(writer, "---") |
|||
} |
|||
if resp.ContinuationToken != "" { |
|||
fmt.Fprintf(writer, "ContinuationToken: %s\n", resp.ContinuationToken) |
|||
} |
|||
case *get: |
|||
req := &s3tables.GetNamespaceRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}} |
|||
var resp s3tables.GetNamespaceResponse |
|||
if err := executeS3Tables(commandEnv, "GetNamespace", req, &resp, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintf(writer, "Namespace: %s\n", strings.Join(resp.Namespace, "/")) |
|||
fmt.Fprintf(writer, "OwnerAccountID: %s\n", resp.OwnerAccountID) |
|||
fmt.Fprintf(writer, "CreatedAt: %s\n", resp.CreatedAt.Format(timeFormat)) |
|||
case *deleteNamespace: |
|||
req := &s3tables.DeleteNamespaceRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}} |
|||
if err := executeS3Tables(commandEnv, "DeleteNamespace", req, nil, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintln(writer, "Namespace deleted") |
|||
} |
|||
return nil |
|||
} |
|||
@ -0,0 +1,205 @@ |
|||
package shell |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"flag" |
|||
"fmt" |
|||
"io" |
|||
"os" |
|||
"strings" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" |
|||
) |
|||
|
|||
func init() { |
|||
Commands = append(Commands, &commandS3TablesTable{}) |
|||
} |
|||
|
|||
type commandS3TablesTable struct{} |
|||
|
|||
func (c *commandS3TablesTable) Name() string { |
|||
return "s3tables.table" |
|||
} |
|||
|
|||
func (c *commandS3TablesTable) Help() string { |
|||
return `manage s3tables tables |
|||
|
|||
# create a table |
|||
s3tables.table -create -bucket <bucket> -account <account_id> -namespace <namespace> -name <table> -format ICEBERG [-metadata metadata.json] [-tags key=value] |
|||
|
|||
# list tables |
|||
s3tables.table -list -bucket <bucket> -account <account_id> [-namespace <namespace>] [-prefix <prefix>] [-limit <n>] [-continuation <token>] |
|||
|
|||
# get table details |
|||
s3tables.table -get -bucket <bucket> -account <account_id> -namespace <namespace> -name <table> |
|||
|
|||
# delete table |
|||
s3tables.table -delete -bucket <bucket> -account <account_id> -namespace <namespace> -name <table> [-version <token>] |
|||
|
|||
# manage table policy |
|||
s3tables.table -put-policy -bucket <bucket> -account <account_id> -namespace <namespace> -name <table> -file policy.json |
|||
s3tables.table -get-policy -bucket <bucket> -account <account_id> -namespace <namespace> -name <table> |
|||
s3tables.table -delete-policy -bucket <bucket> -account <account_id> -namespace <namespace> -name <table> |
|||
` |
|||
} |
|||
|
|||
func (c *commandS3TablesTable) HasTag(CommandTag) bool { |
|||
return false |
|||
} |
|||
|
|||
func (c *commandS3TablesTable) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { |
|||
cmd := flag.NewFlagSet(c.Name(), flag.ContinueOnError) |
|||
create := cmd.Bool("create", false, "create table") |
|||
list := cmd.Bool("list", false, "list tables") |
|||
get := cmd.Bool("get", false, "get table") |
|||
deleteTable := cmd.Bool("delete", false, "delete table") |
|||
putPolicy := cmd.Bool("put-policy", false, "put table policy") |
|||
getPolicy := cmd.Bool("get-policy", false, "get table policy") |
|||
deletePolicy := cmd.Bool("delete-policy", false, "delete table policy") |
|||
|
|||
bucketName := cmd.String("bucket", "", "table bucket name") |
|||
account := cmd.String("account", "", "owner account id") |
|||
namespace := cmd.String("namespace", "", "namespace") |
|||
name := cmd.String("name", "", "table name") |
|||
format := cmd.String("format", "ICEBERG", "table format") |
|||
metadataFile := cmd.String("metadata", "", "table metadata json file") |
|||
tags := cmd.String("tags", "", "comma separated tags key=value") |
|||
prefix := cmd.String("prefix", "", "table name prefix") |
|||
limit := cmd.Int("limit", 100, "max tables to return") |
|||
continuation := cmd.String("continuation", "", "continuation token") |
|||
version := cmd.String("version", "", "version token") |
|||
policyFile := cmd.String("file", "", "policy file (json)") |
|||
|
|||
if err := cmd.Parse(args); err != nil { |
|||
return err |
|||
} |
|||
|
|||
actions := []*bool{create, list, get, deleteTable, putPolicy, getPolicy, deletePolicy} |
|||
count := 0 |
|||
for _, action := range actions { |
|||
if *action { |
|||
count++ |
|||
} |
|||
} |
|||
if count != 1 { |
|||
return fmt.Errorf("exactly one action must be specified") |
|||
} |
|||
if *bucketName == "" { |
|||
return fmt.Errorf("-bucket is required") |
|||
} |
|||
if *account == "" { |
|||
return fmt.Errorf("-account is required") |
|||
} |
|||
|
|||
bucketArn, err := buildS3TablesBucketARN(*bucketName, *account) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
ns := strings.TrimSpace(*namespace) |
|||
if (*create || *get || *deleteTable || *putPolicy || *getPolicy || *deletePolicy) && ns == "" { |
|||
return fmt.Errorf("-namespace is required") |
|||
} |
|||
if (*create || *get || *deleteTable || *putPolicy || *getPolicy || *deletePolicy) && *name == "" { |
|||
return fmt.Errorf("-name is required") |
|||
} |
|||
|
|||
switch { |
|||
case *create: |
|||
var metadata *s3tables.TableMetadata |
|||
if *metadataFile != "" { |
|||
content, err := os.ReadFile(*metadataFile) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
if err := json.Unmarshal(content, &metadata); err != nil { |
|||
return err |
|||
} |
|||
} |
|||
req := &s3tables.CreateTableRequest{TableBucketARN: bucketArn, Namespace: []string{ns}, Name: *name, Format: *format, Metadata: metadata} |
|||
if *tags != "" { |
|||
parsed, err := parseS3TablesTags(*tags) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
req.Tags = parsed |
|||
} |
|||
var resp s3tables.CreateTableResponse |
|||
if err := executeS3Tables(commandEnv, "CreateTable", req, &resp, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintf(writer, "TableARN: %s\n", resp.TableARN) |
|||
fmt.Fprintf(writer, "VersionToken: %s\n", resp.VersionToken) |
|||
case *list: |
|||
var nsList []string |
|||
if ns != "" { |
|||
nsList = []string{ns} |
|||
} |
|||
req := &s3tables.ListTablesRequest{TableBucketARN: bucketArn, Namespace: nsList, Prefix: *prefix, ContinuationToken: *continuation, MaxTables: *limit} |
|||
var resp s3tables.ListTablesResponse |
|||
if err := executeS3Tables(commandEnv, "ListTables", req, &resp, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
if len(resp.Tables) == 0 { |
|||
fmt.Fprintln(writer, "No tables found") |
|||
return nil |
|||
} |
|||
for _, table := range resp.Tables { |
|||
fmt.Fprintf(writer, "Name: %s\n", table.Name) |
|||
fmt.Fprintf(writer, "TableARN: %s\n", table.TableARN) |
|||
fmt.Fprintf(writer, "Namespace: %s\n", strings.Join(table.Namespace, "/")) |
|||
fmt.Fprintf(writer, "CreatedAt: %s\n", table.CreatedAt.Format(timeFormat)) |
|||
fmt.Fprintf(writer, "ModifiedAt: %s\n", table.ModifiedAt.Format(timeFormat)) |
|||
fmt.Fprintln(writer, "---") |
|||
} |
|||
if resp.ContinuationToken != "" { |
|||
fmt.Fprintf(writer, "ContinuationToken: %s\n", resp.ContinuationToken) |
|||
} |
|||
case *get: |
|||
req := &s3tables.GetTableRequest{TableBucketARN: bucketArn, Namespace: []string{ns}, Name: *name} |
|||
var resp s3tables.GetTableResponse |
|||
if err := executeS3Tables(commandEnv, "GetTable", req, &resp, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintf(writer, "Name: %s\n", resp.Name) |
|||
fmt.Fprintf(writer, "TableARN: %s\n", resp.TableARN) |
|||
fmt.Fprintf(writer, "Namespace: %s\n", strings.Join(resp.Namespace, "/")) |
|||
fmt.Fprintf(writer, "OwnerAccountID: %s\n", resp.OwnerAccountID) |
|||
fmt.Fprintf(writer, "CreatedAt: %s\n", resp.CreatedAt.Format(timeFormat)) |
|||
fmt.Fprintf(writer, "ModifiedAt: %s\n", resp.ModifiedAt.Format(timeFormat)) |
|||
fmt.Fprintf(writer, "VersionToken: %s\n", resp.VersionToken) |
|||
case *deleteTable: |
|||
req := &s3tables.DeleteTableRequest{TableBucketARN: bucketArn, Namespace: []string{ns}, Name: *name, VersionToken: *version} |
|||
if err := executeS3Tables(commandEnv, "DeleteTable", req, nil, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintln(writer, "Table deleted") |
|||
case *putPolicy: |
|||
if *policyFile == "" { |
|||
return fmt.Errorf("-file is required") |
|||
} |
|||
content, err := os.ReadFile(*policyFile) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
req := &s3tables.PutTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{ns}, Name: *name, ResourcePolicy: string(content)} |
|||
if err := executeS3Tables(commandEnv, "PutTablePolicy", req, nil, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintln(writer, "Table policy updated") |
|||
case *getPolicy: |
|||
req := &s3tables.GetTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{ns}, Name: *name} |
|||
var resp s3tables.GetTablePolicyResponse |
|||
if err := executeS3Tables(commandEnv, "GetTablePolicy", req, &resp, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintln(writer, resp.ResourcePolicy) |
|||
case *deletePolicy: |
|||
req := &s3tables.DeleteTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{ns}, Name: *name} |
|||
if err := executeS3Tables(commandEnv, "DeleteTablePolicy", req, nil, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintln(writer, "Table policy deleted") |
|||
} |
|||
return nil |
|||
} |
|||
@ -0,0 +1,131 @@ |
|||
package shell |
|||
|
|||
import ( |
|||
"flag" |
|||
"fmt" |
|||
"io" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" |
|||
) |
|||
|
|||
func init() { |
|||
Commands = append(Commands, &commandS3TablesTag{}) |
|||
} |
|||
|
|||
type commandS3TablesTag struct{} |
|||
|
|||
func (c *commandS3TablesTag) Name() string { |
|||
return "s3tables.tag" |
|||
} |
|||
|
|||
func (c *commandS3TablesTag) Help() string { |
|||
return `manage s3tables tags |
|||
|
|||
# tag a table bucket |
|||
s3tables.tag -put -bucket <bucket> -account <account_id> -tags key1=val1,key2=val2 |
|||
|
|||
# tag a table |
|||
s3tables.tag -put -bucket <bucket> -account <account_id> -namespace <namespace> -name <table> -tags key1=val1,key2=val2 |
|||
|
|||
# list tags for a resource |
|||
s3tables.tag -list -bucket <bucket> -account <account_id> [-namespace <namespace> -name <table>] |
|||
|
|||
# remove tags |
|||
s3tables.tag -delete -bucket <bucket> -account <account_id> [-namespace <namespace> -name <table>] -keys key1,key2 |
|||
` |
|||
} |
|||
|
|||
func (c *commandS3TablesTag) HasTag(CommandTag) bool { |
|||
return false |
|||
} |
|||
|
|||
func (c *commandS3TablesTag) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { |
|||
cmd := flag.NewFlagSet(c.Name(), flag.ContinueOnError) |
|||
put := cmd.Bool("put", false, "tag resource") |
|||
list := cmd.Bool("list", false, "list tags") |
|||
del := cmd.Bool("delete", false, "delete tags") |
|||
|
|||
bucket := cmd.String("bucket", "", "table bucket name") |
|||
account := cmd.String("account", "", "owner account id") |
|||
namespace := cmd.String("namespace", "", "namespace") |
|||
name := cmd.String("name", "", "table name") |
|||
tags := cmd.String("tags", "", "comma separated tags key=value") |
|||
keys := cmd.String("keys", "", "comma separated tag keys") |
|||
|
|||
if err := cmd.Parse(args); err != nil { |
|||
return err |
|||
} |
|||
|
|||
actions := []*bool{put, list, del} |
|||
count := 0 |
|||
for _, action := range actions { |
|||
if *action { |
|||
count++ |
|||
} |
|||
} |
|||
if count != 1 { |
|||
return fmt.Errorf("exactly one action must be specified") |
|||
} |
|||
if *bucket == "" { |
|||
return fmt.Errorf("-bucket is required") |
|||
} |
|||
if *account == "" { |
|||
return fmt.Errorf("-account is required") |
|||
} |
|||
resourceArn, err := buildS3TablesBucketARN(*bucket, *account) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
if *namespace != "" || *name != "" { |
|||
if *namespace == "" || *name == "" { |
|||
return fmt.Errorf("-namespace and -name are required for table tags") |
|||
} |
|||
resourceArn, err = buildS3TablesTableARN(*bucket, *namespace, *name, *account) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} |
|||
|
|||
switch { |
|||
case *put: |
|||
if *tags == "" { |
|||
return fmt.Errorf("-tags is required") |
|||
} |
|||
parsed, err := parseS3TablesTags(*tags) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
req := &s3tables.TagResourceRequest{ResourceARN: resourceArn, Tags: parsed} |
|||
if err := executeS3Tables(commandEnv, "TagResource", req, nil, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintln(writer, "Tags updated") |
|||
case *list: |
|||
req := &s3tables.ListTagsForResourceRequest{ResourceARN: resourceArn} |
|||
var resp s3tables.ListTagsForResourceResponse |
|||
if err := executeS3Tables(commandEnv, "ListTagsForResource", req, &resp, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
if len(resp.Tags) == 0 { |
|||
fmt.Fprintln(writer, "No tags found") |
|||
return nil |
|||
} |
|||
for k, v := range resp.Tags { |
|||
fmt.Fprintf(writer, "%s=%s\n", k, v) |
|||
} |
|||
case *del: |
|||
if *keys == "" { |
|||
return fmt.Errorf("-keys is required") |
|||
} |
|||
parsed, err := parseS3TablesTagKeys(*keys) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
req := &s3tables.UntagResourceRequest{ResourceARN: resourceArn, TagKeys: parsed} |
|||
if err := executeS3Tables(commandEnv, "UntagResource", req, nil, *account); err != nil { |
|||
return parseS3TablesError(err) |
|||
} |
|||
fmt.Fprintln(writer, "Tags removed") |
|||
} |
|||
return nil |
|||
} |
|||
@ -0,0 +1,89 @@ |
|||
package shell |
|||
|
|||
import ( |
|||
"context" |
|||
"errors" |
|||
"fmt" |
|||
"strings" |
|||
"time" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/pb" |
|||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" |
|||
"google.golang.org/grpc" |
|||
) |
|||
|
|||
const s3TablesDefaultRegion = "" |
|||
const timeFormat = "2006-01-02T15:04:05Z07:00" |
|||
|
|||
func withFilerClient(commandEnv *CommandEnv, fn func(client filer_pb.SeaweedFilerClient) error) error { |
|||
return pb.WithGrpcClient(false, 0, func(conn *grpc.ClientConn) error { |
|||
client := filer_pb.NewSeaweedFilerClient(conn) |
|||
return fn(client) |
|||
}, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) |
|||
} |
|||
|
|||
func executeS3Tables(commandEnv *CommandEnv, operation string, req interface{}, resp interface{}, accountID string) error { |
|||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
|||
defer cancel() |
|||
return withFilerClient(commandEnv, func(client filer_pb.SeaweedFilerClient) error { |
|||
manager := s3tables.NewManager() |
|||
mgrClient := s3tables.NewManagerClient(client) |
|||
return manager.Execute(ctx, mgrClient, operation, req, resp, accountID) |
|||
}) |
|||
} |
|||
|
|||
func parseS3TablesError(err error) error { |
|||
if err == nil { |
|||
return nil |
|||
} |
|||
var s3Err *s3tables.S3TablesError |
|||
if errors.As(err, &s3Err) { |
|||
if s3Err.Message != "" { |
|||
return fmt.Errorf("%s: %s", s3Err.Type, s3Err.Message) |
|||
} |
|||
return fmt.Errorf("%s", s3Err.Type) |
|||
} |
|||
return err |
|||
} |
|||
|
|||
func parseS3TablesTags(value string) (map[string]string, error) { |
|||
parsed := make(map[string]string) |
|||
for _, kv := range strings.Split(value, ",") { |
|||
if kv == "" { |
|||
continue |
|||
} |
|||
parts := strings.SplitN(kv, "=", 2) |
|||
if len(parts) != 2 { |
|||
return nil, fmt.Errorf("invalid tag: %s", kv) |
|||
} |
|||
parsed[parts[0]] = parts[1] |
|||
} |
|||
if err := s3tables.ValidateTags(parsed); err != nil { |
|||
return nil, err |
|||
} |
|||
return parsed, nil |
|||
} |
|||
|
|||
func parseS3TablesTagKeys(value string) ([]string, error) { |
|||
var keys []string |
|||
for _, key := range strings.Split(value, ",") { |
|||
key = strings.TrimSpace(key) |
|||
if key == "" { |
|||
continue |
|||
} |
|||
keys = append(keys, key) |
|||
} |
|||
if len(keys) == 0 { |
|||
return nil, fmt.Errorf("tagKeys are required") |
|||
} |
|||
return keys, nil |
|||
} |
|||
|
|||
func buildS3TablesBucketARN(bucketName, accountID string) (string, error) { |
|||
return s3tables.BuildBucketARN(s3TablesDefaultRegion, accountID, bucketName) |
|||
} |
|||
|
|||
func buildS3TablesTableARN(bucketName, namespace, tableName, accountID string) (string, error) { |
|||
return s3tables.BuildTableARN(s3TablesDefaultRegion, accountID, bucketName, namespace, tableName) |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue