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 flows
This commit is contained in:
@@ -8,6 +8,7 @@ A modern web-based administration interface for SeaweedFS clusters built with Go
|
||||
- **Master Management**: Monitor master nodes and leadership status
|
||||
- **Volume Server Management**: View volume servers, capacity, and health
|
||||
- **Object Store Bucket Management**: Create, delete, and manage Object Store buckets with web interface
|
||||
- **S3 Tables Management**: Manage table buckets, namespaces, tables, tags, and policies via the admin UI
|
||||
- **System Health**: Overall cluster health monitoring
|
||||
- **Responsive Design**: Bootstrap-based UI that works on all devices
|
||||
- **Authentication**: Optional user authentication with sessions
|
||||
@@ -96,7 +97,6 @@ make fmt
|
||||
weed/admin/
|
||||
├── Makefile # Admin-specific build tasks
|
||||
├── README.md # This file
|
||||
├── S3_BUCKETS.md # Object Store bucket management documentation
|
||||
├── admin.go # Main application entry point
|
||||
├── dash/ # Server and handler logic
|
||||
│ ├── admin_server.go # HTTP server setup
|
||||
@@ -110,20 +110,20 @@ weed/admin/
|
||||
├── app/ # Application templates
|
||||
│ ├── admin.templ # Main dashboard template
|
||||
│ ├── s3_buckets.templ # Object Store bucket management template
|
||||
│ ├── s3tables_*.templ # S3 Tables management templates
|
||||
│ └── *_templ.go # Generated Go code
|
||||
└── layout/ # Layout templates
|
||||
├── layout.templ # Base layout template
|
||||
└── layout_templ.go # Generated Go code
|
||||
```
|
||||
|
||||
### S3 Bucket Management
|
||||
### Object Store Management
|
||||
|
||||
The admin interface includes comprehensive Object Store bucket management capabilities. See [S3_BUCKETS.md](S3_BUCKETS.md) for detailed documentation on:
|
||||
The admin interface includes Object Store and S3 Tables management capabilities:
|
||||
|
||||
- Creating and deleting Object Store buckets
|
||||
- Viewing bucket contents and metadata
|
||||
- Managing bucket permissions and settings
|
||||
- API endpoints for programmatic access
|
||||
- Create/delete Object Store buckets and adjust quotas or ownership.
|
||||
- Manage S3 Tables buckets, namespaces, and tables.
|
||||
- Update S3 Tables policies and tags via the UI and API endpoints.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -276,4 +276,4 @@ The admin component follows a clean architecture:
|
||||
- **Business Logic**: Handler functions in `dash/` package
|
||||
- **Data Layer**: Communicates with SeaweedFS masters and filers
|
||||
|
||||
This separation makes the code maintainable and testable.
|
||||
This separation makes the code maintainable and testable.
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
|
||||
|
||||
_ "github.com/seaweedfs/seaweedfs/weed/credential/grpc" // Register gRPC credential store
|
||||
@@ -101,6 +102,8 @@ type AdminServer struct {
|
||||
collectionStatsCache map[string]collectionStats
|
||||
lastCollectionStatsUpdate time.Time
|
||||
collectionStatsCacheThreshold time.Duration
|
||||
|
||||
s3TablesManager *s3tables.Manager
|
||||
}
|
||||
|
||||
// Type definitions moved to types.go
|
||||
@@ -132,6 +135,7 @@ func NewAdminServer(masters string, templateFS http.FileSystem, dataDir string)
|
||||
filerCacheExpiration: 30 * time.Second, // Cache filers for 30 seconds
|
||||
configPersistence: NewConfigPersistence(dataDir),
|
||||
collectionStatsCacheThreshold: 30 * time.Second,
|
||||
s3TablesManager: newS3TablesManager(),
|
||||
}
|
||||
|
||||
// Initialize topic retention purger
|
||||
|
||||
@@ -39,9 +39,11 @@ type FileBrowserData struct {
|
||||
Breadcrumbs []BreadcrumbItem `json:"breadcrumbs"`
|
||||
Entries []FileEntry `json:"entries"`
|
||||
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
IsBucketPath bool `json:"is_bucket_path"`
|
||||
BucketName string `json:"bucket_name"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
IsBucketPath bool `json:"is_bucket_path"`
|
||||
BucketName string `json:"bucket_name"`
|
||||
IsTableBucketPath bool `json:"is_table_bucket_path"`
|
||||
TableBucketName string `json:"table_bucket_name"`
|
||||
// Pagination fields
|
||||
PageSize int `json:"page_size"`
|
||||
HasNextPage bool `json:"has_next_page"`
|
||||
@@ -227,15 +229,28 @@ func (s *AdminServer) GetFileBrowser(dir string, lastFileName string, pageSize i
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a table bucket path
|
||||
isTableBucketPath := false
|
||||
tableBucketName := ""
|
||||
if strings.HasPrefix(dir, "/table-buckets/") {
|
||||
isTableBucketPath = true
|
||||
pathParts := strings.Split(strings.Trim(dir, "/"), "/")
|
||||
if len(pathParts) >= 2 {
|
||||
tableBucketName = pathParts[1]
|
||||
}
|
||||
}
|
||||
|
||||
return &FileBrowserData{
|
||||
CurrentPath: dir,
|
||||
ParentPath: parentPath,
|
||||
Breadcrumbs: breadcrumbs,
|
||||
Entries: entries,
|
||||
|
||||
LastUpdated: time.Now(),
|
||||
IsBucketPath: isBucketPath,
|
||||
BucketName: bucketName,
|
||||
LastUpdated: time.Now(),
|
||||
IsBucketPath: isBucketPath,
|
||||
BucketName: bucketName,
|
||||
IsTableBucketPath: isTableBucketPath,
|
||||
TableBucketName: tableBucketName,
|
||||
// Pagination metadata
|
||||
PageSize: pageSize,
|
||||
HasNextPage: hasNextPage,
|
||||
@@ -268,13 +283,17 @@ func (s *AdminServer) generateBreadcrumbs(dir string) []BreadcrumbItem {
|
||||
}
|
||||
currentPath += "/" + part
|
||||
|
||||
// Special handling for bucket paths
|
||||
displayName := part
|
||||
if len(breadcrumbs) == 1 && part == "buckets" {
|
||||
displayName = "Object Store Buckets"
|
||||
} else if len(breadcrumbs) == 2 && strings.HasPrefix(dir, "/buckets/") {
|
||||
displayName = "📦 " + part // Add bucket icon to bucket name
|
||||
}
|
||||
// Special handling for bucket paths
|
||||
displayName := part
|
||||
if len(breadcrumbs) == 1 && part == "buckets" {
|
||||
displayName = "Object Store Buckets"
|
||||
} else if len(breadcrumbs) == 1 && part == "table-buckets" {
|
||||
displayName = "Table Buckets"
|
||||
} else if len(breadcrumbs) == 2 && strings.HasPrefix(dir, "/buckets/") {
|
||||
displayName = "📦 " + part // Add bucket icon to bucket name
|
||||
} else if len(breadcrumbs) == 2 && strings.HasPrefix(dir, "/table-buckets/") {
|
||||
displayName = "🧊 " + part
|
||||
}
|
||||
|
||||
breadcrumbs = append(breadcrumbs, BreadcrumbItem{
|
||||
Name: displayName,
|
||||
|
||||
@@ -51,6 +51,15 @@ func TestGenerateBreadcrumbs(t *testing.T) {
|
||||
{Name: "📦 mybucket", Path: "/buckets/mybucket"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "table bucket path",
|
||||
path: "/table-buckets/mytablebucket",
|
||||
expected: []BreadcrumbItem{
|
||||
{Name: "Root", Path: "/"},
|
||||
{Name: "Table Buckets", Path: "/table-buckets"},
|
||||
{Name: "🧊 mytablebucket", Path: "/table-buckets/mytablebucket"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bucket nested path",
|
||||
path: "/buckets/mybucket/folder",
|
||||
@@ -61,6 +70,16 @@ func TestGenerateBreadcrumbs(t *testing.T) {
|
||||
{Name: "folder", Path: "/buckets/mybucket/folder"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "table bucket nested path",
|
||||
path: "/table-buckets/mytablebucket/folder",
|
||||
expected: []BreadcrumbItem{
|
||||
{Name: "Root", Path: "/"},
|
||||
{Name: "Table Buckets", Path: "/table-buckets"},
|
||||
{Name: "🧊 mytablebucket", Path: "/table-buckets/mytablebucket"},
|
||||
{Name: "folder", Path: "/table-buckets/mytablebucket/folder"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "path with trailing slash",
|
||||
path: "/folder/",
|
||||
@@ -176,6 +195,11 @@ func TestParentPathCalculationLogic(t *testing.T) {
|
||||
currentDir: "/buckets/mybucket",
|
||||
expected: "/buckets",
|
||||
},
|
||||
{
|
||||
name: "table bucket directory",
|
||||
currentDir: "/table-buckets/mytablebucket",
|
||||
expected: "/table-buckets",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
605
weed/admin/dash/s3tables_management.go
Normal file
605
weed/admin/dash/s3tables_management.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
"github.com/seaweedfs/seaweedfs/weed/stats"
|
||||
)
|
||||
|
||||
@@ -86,6 +88,9 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser,
|
||||
protected.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers)
|
||||
protected.GET("/object-store/policies", h.policyHandlers.ShowPolicies)
|
||||
protected.GET("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts)
|
||||
protected.GET("/object-store/s3tables/buckets", h.ShowS3TablesBuckets)
|
||||
protected.GET("/object-store/s3tables/buckets/:bucket/namespaces", h.ShowS3TablesNamespaces)
|
||||
protected.GET("/object-store/s3tables/buckets/:bucket/namespaces/:namespace/tables", h.ShowS3TablesTables)
|
||||
|
||||
// File browser routes
|
||||
protected.GET("/files", h.fileBrowserHandlers.ShowFileBrowser)
|
||||
@@ -174,6 +179,29 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser,
|
||||
objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy)
|
||||
}
|
||||
|
||||
// S3 Tables API routes
|
||||
s3TablesApi := api.Group("/s3tables")
|
||||
{
|
||||
s3TablesApi.GET("/buckets", h.adminServer.ListS3TablesBucketsAPI)
|
||||
s3TablesApi.POST("/buckets", dash.RequireWriteAccess(), h.adminServer.CreateS3TablesBucket)
|
||||
s3TablesApi.DELETE("/buckets", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesBucket)
|
||||
s3TablesApi.GET("/namespaces", h.adminServer.ListS3TablesNamespacesAPI)
|
||||
s3TablesApi.POST("/namespaces", dash.RequireWriteAccess(), h.adminServer.CreateS3TablesNamespace)
|
||||
s3TablesApi.DELETE("/namespaces", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesNamespace)
|
||||
s3TablesApi.GET("/tables", h.adminServer.ListS3TablesTablesAPI)
|
||||
s3TablesApi.POST("/tables", dash.RequireWriteAccess(), h.adminServer.CreateS3TablesTable)
|
||||
s3TablesApi.DELETE("/tables", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesTable)
|
||||
s3TablesApi.PUT("/bucket-policy", dash.RequireWriteAccess(), h.adminServer.PutS3TablesBucketPolicy)
|
||||
s3TablesApi.GET("/bucket-policy", h.adminServer.GetS3TablesBucketPolicy)
|
||||
s3TablesApi.DELETE("/bucket-policy", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesBucketPolicy)
|
||||
s3TablesApi.PUT("/table-policy", dash.RequireWriteAccess(), h.adminServer.PutS3TablesTablePolicy)
|
||||
s3TablesApi.GET("/table-policy", h.adminServer.GetS3TablesTablePolicy)
|
||||
s3TablesApi.DELETE("/table-policy", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesTablePolicy)
|
||||
s3TablesApi.PUT("/tags", dash.RequireWriteAccess(), h.adminServer.TagS3TablesResource)
|
||||
s3TablesApi.GET("/tags", h.adminServer.ListS3TablesTags)
|
||||
s3TablesApi.DELETE("/tags", dash.RequireWriteAccess(), h.adminServer.UntagS3TablesResource)
|
||||
}
|
||||
|
||||
// File management API routes
|
||||
filesApi := api.Group("/files")
|
||||
{
|
||||
@@ -228,6 +256,9 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser,
|
||||
r.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers)
|
||||
r.GET("/object-store/policies", h.policyHandlers.ShowPolicies)
|
||||
r.GET("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts)
|
||||
r.GET("/object-store/s3tables/buckets", h.ShowS3TablesBuckets)
|
||||
r.GET("/object-store/s3tables/buckets/:bucket/namespaces", h.ShowS3TablesNamespaces)
|
||||
r.GET("/object-store/s3tables/buckets/:bucket/namespaces/:namespace/tables", h.ShowS3TablesTables)
|
||||
|
||||
// File browser routes
|
||||
r.GET("/files", h.fileBrowserHandlers.ShowFileBrowser)
|
||||
@@ -315,6 +346,29 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser,
|
||||
objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy)
|
||||
}
|
||||
|
||||
// S3 Tables API routes
|
||||
s3TablesApi := api.Group("/s3tables")
|
||||
{
|
||||
s3TablesApi.GET("/buckets", h.adminServer.ListS3TablesBucketsAPI)
|
||||
s3TablesApi.POST("/buckets", h.adminServer.CreateS3TablesBucket)
|
||||
s3TablesApi.DELETE("/buckets", h.adminServer.DeleteS3TablesBucket)
|
||||
s3TablesApi.GET("/namespaces", h.adminServer.ListS3TablesNamespacesAPI)
|
||||
s3TablesApi.POST("/namespaces", h.adminServer.CreateS3TablesNamespace)
|
||||
s3TablesApi.DELETE("/namespaces", h.adminServer.DeleteS3TablesNamespace)
|
||||
s3TablesApi.GET("/tables", h.adminServer.ListS3TablesTablesAPI)
|
||||
s3TablesApi.POST("/tables", h.adminServer.CreateS3TablesTable)
|
||||
s3TablesApi.DELETE("/tables", h.adminServer.DeleteS3TablesTable)
|
||||
s3TablesApi.PUT("/bucket-policy", h.adminServer.PutS3TablesBucketPolicy)
|
||||
s3TablesApi.GET("/bucket-policy", h.adminServer.GetS3TablesBucketPolicy)
|
||||
s3TablesApi.DELETE("/bucket-policy", h.adminServer.DeleteS3TablesBucketPolicy)
|
||||
s3TablesApi.PUT("/table-policy", h.adminServer.PutS3TablesTablePolicy)
|
||||
s3TablesApi.GET("/table-policy", h.adminServer.GetS3TablesTablePolicy)
|
||||
s3TablesApi.DELETE("/table-policy", h.adminServer.DeleteS3TablesTablePolicy)
|
||||
s3TablesApi.PUT("/tags", h.adminServer.TagS3TablesResource)
|
||||
s3TablesApi.GET("/tags", h.adminServer.ListS3TablesTags)
|
||||
s3TablesApi.DELETE("/tags", h.adminServer.UntagS3TablesResource)
|
||||
}
|
||||
|
||||
// File management API routes
|
||||
filesApi := api.Group("/files")
|
||||
{
|
||||
@@ -398,6 +452,91 @@ func (h *AdminHandlers) ShowS3Buckets(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ShowS3TablesBuckets renders the S3 Tables buckets page
|
||||
func (h *AdminHandlers) ShowS3TablesBuckets(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
|
||||
data, err := h.adminServer.GetS3TablesBucketsData(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get S3 Tables buckets: " + err.Error()})
|
||||
return
|
||||
}
|
||||
data.Username = username
|
||||
|
||||
c.Header("Content-Type", "text/html")
|
||||
component := app.S3TablesBuckets(data)
|
||||
layoutComponent := layout.Layout(c, component)
|
||||
if err := layoutComponent.Render(c.Request.Context(), c.Writer); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// ShowS3TablesNamespaces renders namespaces for a table bucket
|
||||
func (h *AdminHandlers) ShowS3TablesNamespaces(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
|
||||
bucketName := c.Param("bucket")
|
||||
arn, err := buildS3TablesBucketArn(bucketName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.adminServer.GetS3TablesNamespacesData(c.Request.Context(), arn)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get S3 Tables namespaces: " + err.Error()})
|
||||
return
|
||||
}
|
||||
data.Username = username
|
||||
|
||||
c.Header("Content-Type", "text/html")
|
||||
component := app.S3TablesNamespaces(data)
|
||||
layoutComponent := layout.Layout(c, component)
|
||||
if err := layoutComponent.Render(c.Request.Context(), c.Writer); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// ShowS3TablesTables renders tables for a namespace
|
||||
func (h *AdminHandlers) ShowS3TablesTables(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
|
||||
bucketName := c.Param("bucket")
|
||||
namespace := c.Param("namespace")
|
||||
arn, err := buildS3TablesBucketArn(bucketName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.adminServer.GetS3TablesTablesData(c.Request.Context(), arn, namespace)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get S3 Tables tables: " + err.Error()})
|
||||
return
|
||||
}
|
||||
data.Username = username
|
||||
|
||||
c.Header("Content-Type", "text/html")
|
||||
component := app.S3TablesTables(data)
|
||||
layoutComponent := layout.Layout(c, component)
|
||||
if err := layoutComponent.Render(c.Request.Context(), c.Writer); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
func buildS3TablesBucketArn(bucketName string) (string, error) {
|
||||
return s3tables.BuildBucketARN(s3tables.DefaultRegion, s3_constants.AccountAdminId, bucketName)
|
||||
}
|
||||
|
||||
// ShowBucketDetails returns detailed information about a specific bucket
|
||||
func (h *AdminHandlers) ShowBucketDetails(c *gin.Context) {
|
||||
bucketName := c.Param("bucket")
|
||||
|
||||
479
weed/admin/static/js/s3tables.js
Normal file
479
weed/admin/static/js/s3tables.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ templ FileBrowser(data dash.FileBrowserData) {
|
||||
<h1 class="h2">
|
||||
if data.IsBucketPath && data.BucketName != "" {
|
||||
<i class="fas fa-cube me-2"></i>S3 Bucket: {data.BucketName}
|
||||
} else if data.IsTableBucketPath && data.TableBucketName != "" {
|
||||
<i class="fas fa-table me-2"></i>Table Bucket: {data.TableBucketName}
|
||||
} else {
|
||||
<i class="fas fa-folder-open me-2"></i>File Browser
|
||||
}
|
||||
@@ -26,6 +28,10 @@ templ FileBrowser(data dash.FileBrowserData) {
|
||||
<a href="/object-store/buckets" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>Back to Buckets
|
||||
</a>
|
||||
} else if data.IsTableBucketPath && data.TableBucketName != "" {
|
||||
<a href="/object-store/s3tables/buckets" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>Back to Table Buckets
|
||||
</a>
|
||||
}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="createFolder()">
|
||||
<i class="fas fa-folder-plus me-1"></i>New Folder
|
||||
@@ -72,13 +78,18 @@ templ FileBrowser(data dash.FileBrowserData) {
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center flex-wrap">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-folder-open me-2"></i>
|
||||
if data.CurrentPath == "/" {
|
||||
if data.CurrentPath == "/" {
|
||||
<a href="/files?path=/" class="text-decoration-none text-primary">Root Directory</a>
|
||||
} else if data.CurrentPath == "/buckets" {
|
||||
<a href="/files?path=/buckets" class="text-decoration-none text-primary">Object Store Buckets Directory</a>
|
||||
<a href="/object-store/buckets" class="btn btn-sm btn-outline-primary ms-2">
|
||||
<i class="fas fa-cube me-1"></i>Manage Buckets
|
||||
</a>
|
||||
} else if data.CurrentPath == "/table-buckets" {
|
||||
<a href="/files?path=/table-buckets" class="text-decoration-none text-primary">Table Buckets Directory</a>
|
||||
<a href="/object-store/s3tables/buckets" class="btn btn-sm btn-outline-primary ms-2">
|
||||
<i class="fas fa-table me-1"></i>Manage Table Buckets
|
||||
</a>
|
||||
} else {
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files?path=%s", data.CurrentPath)) } class="text-decoration-none text-primary">{ filepath.Base(data.CurrentPath) }</a>
|
||||
}
|
||||
@@ -767,4 +778,4 @@ func getMimeDisplayName(mime string) string {
|
||||
}
|
||||
return "File"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -220,6 +220,30 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
<option value="GetBucketObjectLockConfiguration">Get Bucket Object Lock Configuration</option>
|
||||
<option value="PutBucketObjectLockConfiguration">Put Bucket Object Lock Configuration</option>
|
||||
</optgroup>
|
||||
<optgroup label="S3 Tables Permissions">
|
||||
<option value="S3TablesAdmin">S3 Tables Admin (Full Access)</option>
|
||||
<option value="CreateTableBucket">Create Table Bucket</option>
|
||||
<option value="GetTableBucket">Get Table Bucket</option>
|
||||
<option value="ListTableBuckets">List Table Buckets</option>
|
||||
<option value="DeleteTableBucket">Delete Table Bucket</option>
|
||||
<option value="PutTableBucketPolicy">Put Table Bucket Policy</option>
|
||||
<option value="GetTableBucketPolicy">Get Table Bucket Policy</option>
|
||||
<option value="DeleteTableBucketPolicy">Delete Table Bucket Policy</option>
|
||||
<option value="CreateNamespace">Create Namespace</option>
|
||||
<option value="GetNamespace">Get Namespace</option>
|
||||
<option value="ListNamespaces">List Namespaces</option>
|
||||
<option value="DeleteNamespace">Delete Namespace</option>
|
||||
<option value="CreateTable">Create Table</option>
|
||||
<option value="GetTable">Get Table</option>
|
||||
<option value="ListTables">List Tables</option>
|
||||
<option value="DeleteTable">Delete Table</option>
|
||||
<option value="PutTablePolicy">Put Table Policy</option>
|
||||
<option value="GetTablePolicy">Get Table Policy</option>
|
||||
<option value="DeleteTablePolicy">Delete Table Policy</option>
|
||||
<option value="TagResource">Tag Resource</option>
|
||||
<option value="ListTagsForResource">List Tags</option>
|
||||
<option value="UntagResource">Untag Resource</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<small class="form-text text-muted">Hold Ctrl/Cmd to select multiple permissions</small>
|
||||
</div>
|
||||
@@ -304,6 +328,30 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
<option value="GetBucketObjectLockConfiguration">Get Bucket Object Lock Configuration</option>
|
||||
<option value="PutBucketObjectLockConfiguration">Put Bucket Object Lock Configuration</option>
|
||||
</optgroup>
|
||||
<optgroup label="S3 Tables Permissions">
|
||||
<option value="S3TablesAdmin">S3 Tables Admin (Full Access)</option>
|
||||
<option value="CreateTableBucket">Create Table Bucket</option>
|
||||
<option value="GetTableBucket">Get Table Bucket</option>
|
||||
<option value="ListTableBuckets">List Table Buckets</option>
|
||||
<option value="DeleteTableBucket">Delete Table Bucket</option>
|
||||
<option value="PutTableBucketPolicy">Put Table Bucket Policy</option>
|
||||
<option value="GetTableBucketPolicy">Get Table Bucket Policy</option>
|
||||
<option value="DeleteTableBucketPolicy">Delete Table Bucket Policy</option>
|
||||
<option value="CreateNamespace">Create Namespace</option>
|
||||
<option value="GetNamespace">Get Namespace</option>
|
||||
<option value="ListNamespaces">List Namespaces</option>
|
||||
<option value="DeleteNamespace">Delete Namespace</option>
|
||||
<option value="CreateTable">Create Table</option>
|
||||
<option value="GetTable">Get Table</option>
|
||||
<option value="ListTables">List Tables</option>
|
||||
<option value="DeleteTable">Delete Table</option>
|
||||
<option value="PutTablePolicy">Put Table Policy</option>
|
||||
<option value="GetTablePolicy">Get Table Policy</option>
|
||||
<option value="DeleteTablePolicy">Delete Table Policy</option>
|
||||
<option value="TagResource">Tag Resource</option>
|
||||
<option value="ListTagsForResource">List Tags</option>
|
||||
<option value="UntagResource">Untag Resource</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
@@ -457,6 +505,32 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
// Global variable to store available buckets
|
||||
var availableBuckets = [];
|
||||
var bucketPermissionCounter = 0;
|
||||
const s3TablesPermissions = new Set([
|
||||
'CreateTableBucket',
|
||||
'GetTableBucket',
|
||||
'ListTableBuckets',
|
||||
'DeleteTableBucket',
|
||||
'PutTableBucketPolicy',
|
||||
'GetTableBucketPolicy',
|
||||
'DeleteTableBucketPolicy',
|
||||
'CreateNamespace',
|
||||
'GetNamespace',
|
||||
'ListNamespaces',
|
||||
'DeleteNamespace',
|
||||
'CreateTable',
|
||||
'GetTable',
|
||||
'ListTables',
|
||||
'DeleteTable',
|
||||
'PutTablePolicy',
|
||||
'GetTablePolicy',
|
||||
'DeleteTablePolicy',
|
||||
'TagResource',
|
||||
'ListTagsForResource',
|
||||
'UntagResource'
|
||||
]);
|
||||
function isS3TablesPermission(permission) {
|
||||
return permission === 'S3TablesAdmin' || s3TablesPermissions.has(permission);
|
||||
}
|
||||
|
||||
// Load buckets
|
||||
async function loadBuckets() {
|
||||
@@ -464,10 +538,8 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
const response = await fetch('/api/s3/buckets');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
availableBuckets = data.buckets || [];
|
||||
availableBuckets = (data.buckets || []).map(bucket => ({ name: bucket.name, type: 's3' }));
|
||||
console.log('Loaded', availableBuckets.length, 'buckets');
|
||||
// Populate bucket selection dropdowns
|
||||
populateBucketSelections();
|
||||
} else {
|
||||
console.warn('Failed to load buckets');
|
||||
availableBuckets = [];
|
||||
@@ -476,6 +548,20 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
console.error('Error loading buckets:', error);
|
||||
availableBuckets = [];
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/s3tables/buckets');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const tableBuckets = (data.buckets || data.tableBuckets || []).map(bucket => ({ name: bucket.name, type: 's3tables' }));
|
||||
availableBuckets = availableBuckets.concat(tableBuckets);
|
||||
} else {
|
||||
console.warn('Failed to load table buckets');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error loading table buckets:', error);
|
||||
}
|
||||
// Populate bucket selection dropdowns
|
||||
populateBucketSelections();
|
||||
}
|
||||
|
||||
// Load policies
|
||||
@@ -556,8 +642,8 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
select.innerHTML = '';
|
||||
availableBuckets.forEach(bucket => {
|
||||
const option = document.createElement('option');
|
||||
option.value = bucket.name;
|
||||
option.textContent = bucket.name;
|
||||
option.value = bucket.type + ':' + bucket.name;
|
||||
option.textContent = bucket.type === 's3tables' ? `Table: ${bucket.name}` : bucket.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
@@ -584,11 +670,25 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
const globalBucketPerms = [];
|
||||
|
||||
actions.forEach(action => {
|
||||
if (action.includes(':')) {
|
||||
if (action.startsWith('s3tables:')) {
|
||||
const actionValue = action.slice('s3tables:'.length);
|
||||
if (actionValue === '*') {
|
||||
globalBucketPerms.push('S3TablesAdmin');
|
||||
return;
|
||||
}
|
||||
const parts = actionValue.split(':');
|
||||
const perm = parts[0];
|
||||
const bucket = parts.length > 1 ? parts.slice(1).join(':').replace(/\/\*$/, '') : '';
|
||||
if (bucket) {
|
||||
bucketActions.push({ permission: perm, bucketId: 's3tables:' + bucket });
|
||||
} else {
|
||||
globalBucketPerms.push(perm);
|
||||
}
|
||||
} else if (action.includes(':')) {
|
||||
const parts = action.split(':');
|
||||
const perm = parts[0];
|
||||
const bucket = parts.slice(1).join(':').replace(/\/\*$/, '');
|
||||
bucketActions.push({ permission: perm, bucket: bucket });
|
||||
bucketActions.push({ permission: perm, bucketId: 's3:' + bucket });
|
||||
} else {
|
||||
globalBucketPerms.push(action);
|
||||
}
|
||||
@@ -601,7 +701,7 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
} else if (bucketActions.length > 0) {
|
||||
// Get unique permissions and buckets
|
||||
const perms = [...new Set(bucketActions.map(ba => ba.permission))];
|
||||
const buckets = [...new Set(bucketActions.map(ba => ba.bucket))];
|
||||
const buckets = [...new Set(bucketActions.map(ba => ba.bucketId))];
|
||||
|
||||
result.permissions = perms;
|
||||
result.applyToAll = false;
|
||||
@@ -611,6 +711,16 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseBucketOptionValue(value) {
|
||||
if (value.startsWith('s3tables:')) {
|
||||
return { type: 's3tables', name: value.slice('s3tables:'.length) };
|
||||
}
|
||||
if (value.startsWith('s3:')) {
|
||||
return { type: 's3', name: value.slice('s3:'.length) };
|
||||
}
|
||||
return { type: 's3', name: value };
|
||||
}
|
||||
|
||||
// Build bucket permission action strings using original permissions dropdown
|
||||
/**
|
||||
* Builds bucket permission strings based on selected permissions and bucket scope.
|
||||
@@ -627,10 +737,8 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
// Get selected permissions from the original multi-select
|
||||
const selectedPerms = Array.from(permSelect.selectedOptions).map(opt => opt.value);
|
||||
|
||||
// If Admin is selected, return just Admin (it overrides everything)
|
||||
if (selectedPerms.includes('Admin')) {
|
||||
return ['Admin'];
|
||||
}
|
||||
const hasAdmin = selectedPerms.includes('Admin');
|
||||
const hasS3TablesAdmin = selectedPerms.includes('S3TablesAdmin');
|
||||
|
||||
if (selectedPerms.length === 0) {
|
||||
return [];
|
||||
@@ -663,13 +771,30 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
|
||||
if (applyToAll) {
|
||||
// Return global permissions (no bucket specification)
|
||||
return selectedPerms;
|
||||
const actions = [];
|
||||
if (hasAdmin) {
|
||||
actions.push('Admin');
|
||||
}
|
||||
if (hasS3TablesAdmin) {
|
||||
actions.push('s3tables:*');
|
||||
}
|
||||
selectedPerms.forEach(perm => {
|
||||
if (perm === 'Admin' || perm === 'S3TablesAdmin') {
|
||||
return;
|
||||
}
|
||||
if (isS3TablesPermission(perm)) {
|
||||
actions.push('s3tables:' + perm);
|
||||
} else {
|
||||
actions.push(perm);
|
||||
}
|
||||
});
|
||||
return actions;
|
||||
} else {
|
||||
// Get selected specific buckets
|
||||
const bucketSelect = document.getElementById(mode === 'edit' ? 'editSelectedBuckets' : 'selectedBuckets');
|
||||
if (!bucketSelect) return null;
|
||||
|
||||
const selectedBuckets = Array.from(bucketSelect.selectedOptions).map(opt => opt.value);
|
||||
const selectedBuckets = [...new Set(Array.from(bucketSelect.selectedOptions).map(opt => opt.value))];
|
||||
|
||||
// Return null to signal validation failure if no buckets selected
|
||||
if (selectedBuckets.length === 0) {
|
||||
@@ -678,13 +803,29 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
|
||||
// Build bucket-scoped permissions
|
||||
const actions = [];
|
||||
if (hasAdmin) {
|
||||
actions.push('Admin');
|
||||
}
|
||||
if (hasS3TablesAdmin) {
|
||||
actions.push('s3tables:*');
|
||||
}
|
||||
selectedPerms.forEach(perm => {
|
||||
if (perm === 'Admin' || perm === 'S3TablesAdmin') {
|
||||
return;
|
||||
}
|
||||
selectedBuckets.forEach(bucket => {
|
||||
actions.push(perm + ':' + bucket);
|
||||
const bucketInfo = parseBucketOptionValue(bucket);
|
||||
if (isS3TablesPermission(perm)) {
|
||||
if (bucketInfo.type === 's3tables') {
|
||||
actions.push('s3tables:' + perm + ':' + bucketInfo.name);
|
||||
}
|
||||
} else if (bucketInfo.type === 's3') {
|
||||
actions.push(perm + ':' + bucketInfo.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return actions;
|
||||
return [...new Set(actions)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -834,6 +975,16 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
// Get permissions with bucket scope applied
|
||||
const allActions = buildBucketPermissions('create');
|
||||
|
||||
if (allActions === null) {
|
||||
showAlert('Please select at least one bucket when using specific bucket permissions', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allActions || allActions.length === 0) {
|
||||
showAlert('At least one permission must be selected', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = {
|
||||
username: formData.get('username'),
|
||||
email: formData.get('email'),
|
||||
@@ -887,15 +1038,15 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
// Get permissions with bucket scope applied
|
||||
const allActions = buildBucketPermissions('edit');
|
||||
|
||||
// Validate that permissions are not empty
|
||||
if (!allActions || allActions.length === 0) {
|
||||
showAlert('At least one permission must be selected', 'error');
|
||||
// Check for null (validation failure from buildBucketPermissions)
|
||||
if (allActions === null) {
|
||||
showAlert('Please select at least one bucket when using specific bucket permissions', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for null (validation failure from buildBucketPermissionsNew)
|
||||
if (allActions === null) {
|
||||
showAlert('Please select at least one bucket when using specific bucket permissions', 'error');
|
||||
// Validate that permissions are not empty
|
||||
if (!allActions || allActions.length === 0) {
|
||||
showAlert('At least one permission must be selected', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1154,4 +1305,4 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
}
|
||||
|
||||
// Helper functions for template
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
275
weed/admin/view/app/s3tables_buckets.templ
Normal file
275
weed/admin/view/app/s3tables_buckets.templ
Normal 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
Normal file
222
weed/admin/view/app/s3tables_buckets_templ.go
Normal file
File diff suppressed because one or more lines are too long
242
weed/admin/view/app/s3tables_namespaces.templ
Normal file
242
weed/admin/view/app/s3tables_namespaces.templ
Normal 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>
|
||||
}
|
||||
198
weed/admin/view/app/s3tables_namespaces_templ.go
Normal file
198
weed/admin/view/app/s3tables_namespaces_templ.go
Normal file
@@ -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
|
||||
294
weed/admin/view/app/s3tables_tables.templ
Normal file
294
weed/admin/view/app/s3tables_tables.templ
Normal file
@@ -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
Normal file
317
weed/admin/view/app/s3tables_tables_templ.go
Normal file
File diff suppressed because one or more lines are too long
@@ -163,6 +163,11 @@ templ Layout(c *gin.Context, content templ.Component) {
|
||||
<i class="fas fa-cube me-2"></i>Buckets
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-2" href="/object-store/s3tables/buckets">
|
||||
<i class="fas fa-table me-2"></i>Table Buckets
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-2" href="/object-store/users">
|
||||
<i class="fas fa-users me-2"></i>Users
|
||||
@@ -362,6 +367,7 @@ templ Layout(c *gin.Context, content templ.Component) {
|
||||
<!-- Custom JS -->
|
||||
<script src="/static/js/admin.js"></script>
|
||||
<script src="/static/js/iam-utils.js"></script>
|
||||
<script src="/static/js/s3tables.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
@@ -430,4 +436,4 @@ templ LoginForm(c *gin.Context, title string, errorMessage string) {
|
||||
<script src="/static/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" id=\"storageSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/storage/volumes\"><i class=\"fas fa-database me-2\"></i>Volumes</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/storage/ec-shards\"><i class=\"fas fa-th-large me-2\"></i>EC Volumes</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/storage/collections\"><i class=\"fas fa-layer-group me-2\"></i>Collections</a></li></ul></div></li></ul><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MANAGEMENT</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/files\"><i class=\"fas fa-folder me-2\"></i>File Browser</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#objectStoreSubmenu\" aria-expanded=\"false\" aria-controls=\"objectStoreSubmenu\"><i class=\"fas fa-cloud me-2\"></i>Object Store <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"objectStoreSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/buckets\"><i class=\"fas fa-cube me-2\"></i>Buckets</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/users\"><i class=\"fas fa-users me-2\"></i>Users</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/service-accounts\"><i class=\"fas fa-robot me-2\"></i>Service Accounts</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/policies\"><i class=\"fas fa-shield-alt me-2\"></i>Policies</a></li></ul></div></li><li class=\"nav-item\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" id=\"storageSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/storage/volumes\"><i class=\"fas fa-database me-2\"></i>Volumes</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/storage/ec-shards\"><i class=\"fas fa-th-large me-2\"></i>EC Volumes</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/storage/collections\"><i class=\"fas fa-layer-group me-2\"></i>Collections</a></li></ul></div></li></ul><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MANAGEMENT</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/files\"><i class=\"fas fa-folder me-2\"></i>File Browser</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#objectStoreSubmenu\" aria-expanded=\"false\" aria-controls=\"objectStoreSubmenu\"><i class=\"fas fa-cloud me-2\"></i>Object Store <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"objectStoreSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/buckets\"><i class=\"fas fa-cube me-2\"></i>Buckets</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/s3tables/buckets\"><i class=\"fas fa-table me-2\"></i>Table Buckets</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/users\"><i class=\"fas fa-users me-2\"></i>Users</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/service-accounts\"><i class=\"fas fa-robot me-2\"></i>Service Accounts</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/policies\"><i class=\"fas fa-shield-alt me-2\"></i>Policies</a></li></ul></div></li><li class=\"nav-item\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -271,7 +271,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
|
||||
var templ_7745c5c3_Var13 templ.SafeURL
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 282, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 287, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -306,7 +306,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 283, Col: 109}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 288, Col: 109}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -324,7 +324,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
|
||||
var templ_7745c5c3_Var17 templ.SafeURL
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 286, Col: 110}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 291, Col: 110}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -359,7 +359,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 287, Col: 109}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 292, Col: 109}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -392,7 +392,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
|
||||
var templ_7745c5c3_Var21 templ.SafeURL
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 299, Col: 106}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 304, Col: 106}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -427,7 +427,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 300, Col: 105}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 305, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -488,7 +488,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year()))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 347, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 352, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -501,7 +501,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 347, Col: 102}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 352, Col: 102}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -553,7 +553,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 374, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 379, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -566,7 +566,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 388, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 393, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -584,7 +584,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 395, Col: 45}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 400, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -427,7 +427,7 @@ func (store *FilerEtcStore) DeleteAccessKey(ctx context.Context, username string
|
||||
|
||||
func (store *FilerEtcStore) saveIdentity(ctx context.Context, identity *iam_pb.Identity) error {
|
||||
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
data, err := json.Marshal(identity)
|
||||
data, err := json.MarshalIndent(identity, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func (store *FilerEtcStore) saveServiceAccount(ctx context.Context, sa *iam_pb.S
|
||||
return err
|
||||
}
|
||||
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
data, err := json.Marshal(sa)
|
||||
data, err := json.MarshalIndent(sa, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package s3tables
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
@@ -169,6 +171,44 @@ func (h *S3TablesHandler) getAccountID(r *http.Request) string {
|
||||
return h.accountID
|
||||
}
|
||||
|
||||
// getIdentityActions extracts the action list from the identity object in the request context.
|
||||
// Uses reflection to avoid import cycles with s3api package.
|
||||
func getIdentityActions(r *http.Request) []string {
|
||||
identityRaw := s3_constants.GetIdentityFromContext(r)
|
||||
if identityRaw == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use reflection to access the Actions field to avoid import cycle
|
||||
val := reflect.ValueOf(identityRaw)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
if val.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
|
||||
actionsField := val.FieldByName("Actions")
|
||||
if !actionsField.IsValid() || actionsField.Kind() != reflect.Slice {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert actions to string slice
|
||||
actions := make([]string, actionsField.Len())
|
||||
for i := 0; i < actionsField.Len(); i++ {
|
||||
action := actionsField.Index(i)
|
||||
// Action is likely a custom type (e.g., type Action string)
|
||||
// Convert to string using String() or direct string conversion
|
||||
if action.Kind() == reflect.String {
|
||||
actions[i] = action.String()
|
||||
} else if action.CanInterface() {
|
||||
// Try to convert via fmt.Sprint
|
||||
actions[i] = fmt.Sprint(action.Interface())
|
||||
}
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// Request/Response helpers
|
||||
|
||||
func (h *S3TablesHandler) readRequestBody(r *http.Request, v interface{}) error {
|
||||
@@ -235,3 +275,29 @@ func isAuthError(err error) bool {
|
||||
var authErr *AuthError
|
||||
return errors.As(err, &authErr) || errors.Is(err, ErrAccessDenied)
|
||||
}
|
||||
|
||||
func (h *S3TablesHandler) readTags(ctx context.Context, client filer_pb.SeaweedFilerClient, path string) (map[string]string, error) {
|
||||
data, err := h.getExtendedAttribute(ctx, client, path, ExtendedKeyTags)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAttributeNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
tags := make(map[string]string)
|
||||
if err := json.Unmarshal(data, &tags); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal tags: %w", err)
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func mapKeys(tags map[string]string) []string {
|
||||
if len(tags) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(tags))
|
||||
for key := range tags {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
// handleCreateTableBucket creates a new table bucket
|
||||
@@ -34,10 +35,30 @@ func (h *S3TablesHandler) handleCreateTableBucket(w http.ResponseWriter, r *http
|
||||
|
||||
bucketPath := getTableBucketPath(req.Name)
|
||||
|
||||
// Check if bucket already exists
|
||||
exists := false
|
||||
// Check if bucket already exists and ensure no conflict with object store buckets
|
||||
tableBucketExists := false
|
||||
s3BucketExists := false
|
||||
err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := filer_pb.LookupEntry(r.Context(), client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
resp, err := client.GetFilerConfiguration(r.Context(), &filer_pb.GetFilerConfigurationRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bucketsPath := resp.DirBuckets
|
||||
if bucketsPath == "" {
|
||||
bucketsPath = s3_constants.DefaultBucketsPath
|
||||
}
|
||||
_, err = filer_pb.LookupEntry(r.Context(), client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: bucketsPath,
|
||||
Name: req.Name,
|
||||
})
|
||||
if err != nil {
|
||||
if !errors.Is(err, filer_pb.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
s3BucketExists = true
|
||||
}
|
||||
_, err = filer_pb.LookupEntry(r.Context(), client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: TablesPath,
|
||||
Name: req.Name,
|
||||
})
|
||||
@@ -47,7 +68,7 @@ func (h *S3TablesHandler) handleCreateTableBucket(w http.ResponseWriter, r *http
|
||||
}
|
||||
return err
|
||||
}
|
||||
exists = true
|
||||
tableBucketExists = true
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -57,7 +78,12 @@ func (h *S3TablesHandler) handleCreateTableBucket(w http.ResponseWriter, r *http
|
||||
return err
|
||||
}
|
||||
|
||||
if exists {
|
||||
if s3BucketExists {
|
||||
h.writeError(w, http.StatusConflict, ErrCodeBucketAlreadyExists, fmt.Sprintf("bucket name %s is already used by an object store bucket", req.Name))
|
||||
return fmt.Errorf("bucket name conflicts with object store bucket")
|
||||
}
|
||||
|
||||
if tableBucketExists {
|
||||
h.writeError(w, http.StatusConflict, ErrCodeBucketAlreadyExists, fmt.Sprintf("table bucket %s already exists", req.Name))
|
||||
return fmt.Errorf("bucket already exists")
|
||||
}
|
||||
|
||||
@@ -65,9 +65,13 @@ func (h *S3TablesHandler) handleGetTableBucket(w http.ResponseWriter, r *http.Re
|
||||
return err
|
||||
}
|
||||
|
||||
// Check permission
|
||||
bucketARN := h.generateTableBucketARN(metadata.OwnerAccountID, bucketName)
|
||||
principal := h.getAccountID(r)
|
||||
if !CanGetTableBucket(principal, metadata.OwnerAccountID, bucketPolicy) {
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("GetTableBucket", principal, metadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to get table bucket details")
|
||||
return ErrAccessDenied
|
||||
}
|
||||
@@ -91,10 +95,12 @@ func (h *S3TablesHandler) handleListTableBuckets(w http.ResponseWriter, r *http.
|
||||
return err
|
||||
}
|
||||
|
||||
// Check permission
|
||||
principal := h.getAccountID(r)
|
||||
accountID := h.getAccountID(r)
|
||||
if !CanListTableBuckets(principal, accountID, "") {
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("ListTableBuckets", principal, accountID, "", "", &PolicyContext{
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to list table buckets")
|
||||
return NewAuthError("ListTableBuckets", principal, "not authorized to list table buckets")
|
||||
}
|
||||
@@ -171,12 +177,28 @@ func (h *S3TablesHandler) handleListTableBuckets(w http.ResponseWriter, r *http.
|
||||
continue
|
||||
}
|
||||
|
||||
if metadata.OwnerAccountID != accountID {
|
||||
bucketPath := getTableBucketPath(entry.Entry.Name)
|
||||
bucketPolicy := ""
|
||||
policyData, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyPolicy)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrAttributeNotFound) {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
bucketPolicy = string(policyData)
|
||||
}
|
||||
|
||||
bucketARN := h.generateTableBucketARN(metadata.OwnerAccountID, entry.Entry.Name)
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("GetTableBucket", accountID, metadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
||||
TableBucketName: entry.Entry.Name,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
continue
|
||||
}
|
||||
|
||||
buckets = append(buckets, TableBucketSummary{
|
||||
ARN: h.generateTableBucketARN(metadata.OwnerAccountID, entry.Entry.Name),
|
||||
ARN: bucketARN,
|
||||
Name: entry.Entry.Name,
|
||||
CreatedAt: metadata.CreatedAt,
|
||||
})
|
||||
@@ -267,9 +289,13 @@ func (h *S3TablesHandler) handleDeleteTableBucket(w http.ResponseWriter, r *http
|
||||
bucketPolicy = string(policyData)
|
||||
}
|
||||
|
||||
// 2. Check permission
|
||||
bucketARN := h.generateTableBucketARN(metadata.OwnerAccountID, bucketName)
|
||||
principal := h.getAccountID(r)
|
||||
if !CanDeleteTableBucket(principal, metadata.OwnerAccountID, bucketPolicy) {
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("DeleteTableBucket", principal, metadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
return NewAuthError("DeleteTableBucket", principal, fmt.Sprintf("not authorized to delete bucket %s", bucketName))
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ func (h *S3TablesHandler) handleCreateNamespace(w http.ResponseWriter, r *http.R
|
||||
bucketPath := getTableBucketPath(bucketName)
|
||||
var bucketMetadata tableBucketMetadata
|
||||
var bucketPolicy string
|
||||
var bucketTags map[string]string
|
||||
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
|
||||
if err != nil {
|
||||
@@ -62,6 +63,10 @@ func (h *S3TablesHandler) handleCreateNamespace(w http.ResponseWriter, r *http.R
|
||||
} else if !errors.Is(err, ErrAttributeNotFound) {
|
||||
return fmt.Errorf("failed to fetch bucket policy: %v", err)
|
||||
}
|
||||
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -75,9 +80,15 @@ func (h *S3TablesHandler) handleCreateNamespace(w http.ResponseWriter, r *http.R
|
||||
return err
|
||||
}
|
||||
|
||||
// Check permission
|
||||
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
|
||||
principal := h.getAccountID(r)
|
||||
if !CanCreateNamespace(principal, bucketMetadata.OwnerAccountID, bucketPolicy) {
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("CreateNamespace", principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
Namespace: namespaceName,
|
||||
TableBucketTags: bucketTags,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to create namespace in this bucket")
|
||||
return ErrAccessDenied
|
||||
}
|
||||
@@ -172,6 +183,7 @@ func (h *S3TablesHandler) handleGetNamespace(w http.ResponseWriter, r *http.Requ
|
||||
// Get namespace and bucket policy
|
||||
var metadata namespaceMetadata
|
||||
var bucketPolicy string
|
||||
var bucketTags map[string]string
|
||||
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyMetadata)
|
||||
if err != nil {
|
||||
@@ -188,6 +200,10 @@ func (h *S3TablesHandler) handleGetNamespace(w http.ResponseWriter, r *http.Requ
|
||||
} else if !errors.Is(err, ErrAttributeNotFound) {
|
||||
return fmt.Errorf("failed to fetch bucket policy: %v", err)
|
||||
}
|
||||
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -201,9 +217,15 @@ func (h *S3TablesHandler) handleGetNamespace(w http.ResponseWriter, r *http.Requ
|
||||
return err
|
||||
}
|
||||
|
||||
// Check permission
|
||||
bucketARN := h.generateTableBucketARN(metadata.OwnerAccountID, bucketName)
|
||||
principal := h.getAccountID(r)
|
||||
if !CanGetNamespace(principal, metadata.OwnerAccountID, bucketPolicy) {
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("GetNamespace", principal, metadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
Namespace: namespaceName,
|
||||
TableBucketTags: bucketTags,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchNamespace, "namespace not found")
|
||||
return ErrAccessDenied
|
||||
}
|
||||
@@ -247,6 +269,7 @@ func (h *S3TablesHandler) handleListNamespaces(w http.ResponseWriter, r *http.Re
|
||||
// Check permission (check bucket ownership)
|
||||
var bucketMetadata tableBucketMetadata
|
||||
var bucketPolicy string
|
||||
var bucketTags map[string]string
|
||||
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
|
||||
if err != nil {
|
||||
@@ -263,6 +286,10 @@ func (h *S3TablesHandler) handleListNamespaces(w http.ResponseWriter, r *http.Re
|
||||
} else if !errors.Is(err, ErrAttributeNotFound) {
|
||||
return fmt.Errorf("failed to fetch bucket policy: %v", err)
|
||||
}
|
||||
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -276,8 +303,14 @@ func (h *S3TablesHandler) handleListNamespaces(w http.ResponseWriter, r *http.Re
|
||||
return err
|
||||
}
|
||||
|
||||
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
|
||||
principal := h.getAccountID(r)
|
||||
if !CanListNamespaces(principal, bucketMetadata.OwnerAccountID, bucketPolicy) {
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("ListNamespaces", principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
TableBucketTags: bucketTags,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
|
||||
return ErrAccessDenied
|
||||
}
|
||||
@@ -419,6 +452,7 @@ func (h *S3TablesHandler) handleDeleteNamespace(w http.ResponseWriter, r *http.R
|
||||
// Check if namespace exists and get metadata for permission check
|
||||
var metadata namespaceMetadata
|
||||
var bucketPolicy string
|
||||
var bucketTags map[string]string
|
||||
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyMetadata)
|
||||
if err != nil {
|
||||
@@ -435,6 +469,10 @@ func (h *S3TablesHandler) handleDeleteNamespace(w http.ResponseWriter, r *http.R
|
||||
} else if !errors.Is(err, ErrAttributeNotFound) {
|
||||
return fmt.Errorf("failed to fetch bucket policy: %v", err)
|
||||
}
|
||||
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -448,9 +486,15 @@ func (h *S3TablesHandler) handleDeleteNamespace(w http.ResponseWriter, r *http.R
|
||||
return err
|
||||
}
|
||||
|
||||
// Check permission
|
||||
bucketARN := h.generateTableBucketARN(metadata.OwnerAccountID, bucketName)
|
||||
principal := h.getAccountID(r)
|
||||
if !CanDeleteNamespace(principal, metadata.OwnerAccountID, bucketPolicy) {
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("DeleteNamespace", principal, metadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
Namespace: namespaceName,
|
||||
TableBucketTags: bucketTags,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchNamespace, "namespace not found")
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
@@ -88,9 +88,13 @@ func (h *S3TablesHandler) handlePutTableBucketPolicy(w http.ResponseWriter, r *h
|
||||
return err
|
||||
}
|
||||
|
||||
// Check permission
|
||||
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
|
||||
principal := h.getAccountID(r)
|
||||
if !CanPutTableBucketPolicy(principal, bucketMetadata.OwnerAccountID, "") {
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("PutTableBucketPolicy", principal, bucketMetadata.OwnerAccountID, "", bucketARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to put table bucket policy")
|
||||
return NewAuthError("PutTableBucketPolicy", principal, "not authorized to put table bucket policy")
|
||||
}
|
||||
@@ -161,9 +165,13 @@ func (h *S3TablesHandler) handleGetTableBucketPolicy(w http.ResponseWriter, r *h
|
||||
return err
|
||||
}
|
||||
|
||||
// Check permission
|
||||
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
|
||||
principal := h.getAccountID(r)
|
||||
if !CanGetTableBucketPolicy(principal, bucketMetadata.OwnerAccountID, string(policy)) {
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("GetTableBucketPolicy", principal, bucketMetadata.OwnerAccountID, string(policy), bucketARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to get table bucket policy")
|
||||
return NewAuthError("GetTableBucketPolicy", principal, "not authorized to get table bucket policy")
|
||||
}
|
||||
@@ -232,9 +240,13 @@ func (h *S3TablesHandler) handleDeleteTableBucketPolicy(w http.ResponseWriter, r
|
||||
return err
|
||||
}
|
||||
|
||||
// Check permission
|
||||
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
|
||||
principal := h.getAccountID(r)
|
||||
if !CanDeleteTableBucketPolicy(principal, bucketMetadata.OwnerAccountID, bucketPolicy) {
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("DeleteTableBucketPolicy", principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to delete table bucket policy")
|
||||
return NewAuthError("DeleteTableBucketPolicy", principal, "not authorized to delete table bucket policy")
|
||||
}
|
||||
@@ -326,9 +338,15 @@ func (h *S3TablesHandler) handlePutTablePolicy(w http.ResponseWriter, r *http.Re
|
||||
return err
|
||||
}
|
||||
|
||||
// Check permission
|
||||
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
|
||||
principal := h.getAccountID(r)
|
||||
if !CanPutTablePolicy(principal, metadata.OwnerAccountID, bucketPolicy) {
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("PutTablePolicy", principal, metadata.OwnerAccountID, bucketPolicy, tableARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
Namespace: namespaceName,
|
||||
TableName: tableName,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to put table policy")
|
||||
return NewAuthError("PutTablePolicy", principal, "not authorized to put table policy")
|
||||
}
|
||||
@@ -427,9 +445,15 @@ func (h *S3TablesHandler) handleGetTablePolicy(w http.ResponseWriter, r *http.Re
|
||||
return err
|
||||
}
|
||||
|
||||
// Check permission
|
||||
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
|
||||
principal := h.getAccountID(r)
|
||||
if !CanGetTablePolicy(principal, metadata.OwnerAccountID, bucketPolicy) {
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("GetTablePolicy", principal, metadata.OwnerAccountID, bucketPolicy, tableARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
Namespace: namespaceName,
|
||||
TableName: tableName,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to get table policy")
|
||||
return NewAuthError("GetTablePolicy", principal, "not authorized to get table policy")
|
||||
}
|
||||
@@ -510,9 +534,15 @@ func (h *S3TablesHandler) handleDeleteTablePolicy(w http.ResponseWriter, r *http
|
||||
return err
|
||||
}
|
||||
|
||||
// Check permission
|
||||
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
|
||||
principal := h.getAccountID(r)
|
||||
if !CanDeleteTablePolicy(principal, metadata.OwnerAccountID, bucketPolicy) {
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("DeleteTablePolicy", principal, metadata.OwnerAccountID, bucketPolicy, tableARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
Namespace: namespaceName,
|
||||
TableName: tableName,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to delete table policy")
|
||||
return NewAuthError("DeleteTablePolicy", principal, "not authorized to delete table policy")
|
||||
}
|
||||
@@ -558,6 +588,8 @@ func (h *S3TablesHandler) handleTagResource(w http.ResponseWriter, r *http.Reque
|
||||
// Read existing tags and merge, AND check permissions based on metadata ownership
|
||||
existingTags := make(map[string]string)
|
||||
var bucketPolicy string
|
||||
var bucketTags map[string]string
|
||||
requestTagKeys := mapKeys(req.Tags)
|
||||
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Read metadata for ownership check
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMetadata)
|
||||
@@ -582,23 +614,36 @@ func (h *S3TablesHandler) handleTagResource(w http.ResponseWriter, r *http.Reque
|
||||
} else {
|
||||
bucketPolicy = string(policyData)
|
||||
}
|
||||
}
|
||||
|
||||
// Check Permission inside the closure because we just got the ID
|
||||
principal := h.getAccountID(r)
|
||||
if !CanManageTags(principal, ownerAccountID, bucketPolicy) {
|
||||
return NewAuthError("TagResource", principal, "not authorized to tag resource")
|
||||
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Read existing tags
|
||||
data, err = h.getExtendedAttribute(r.Context(), client, resourcePath, extendedKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAttributeNotFound) {
|
||||
return nil // No existing tags, which is fine.
|
||||
if !errors.Is(err, ErrAttributeNotFound) {
|
||||
return err
|
||||
}
|
||||
return err // Propagate other errors.
|
||||
} else if err := json.Unmarshal(data, &existingTags); err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, &existingTags)
|
||||
|
||||
resourceARN := req.ResourceARN
|
||||
principal := h.getAccountID(r)
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("TagResource", principal, ownerAccountID, bucketPolicy, resourceARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
TableBucketTags: bucketTags,
|
||||
RequestTags: req.Tags,
|
||||
TagKeys: requestTagKeys,
|
||||
ResourceTags: existingTags,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
return NewAuthError("TagResource", principal, "not authorized to tag resource")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -662,6 +707,7 @@ func (h *S3TablesHandler) handleListTagsForResource(w http.ResponseWriter, r *ht
|
||||
|
||||
tags := make(map[string]string)
|
||||
var bucketPolicy string
|
||||
var bucketTags map[string]string
|
||||
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Read metadata for ownership check
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMetadata)
|
||||
@@ -686,12 +732,10 @@ func (h *S3TablesHandler) handleListTagsForResource(w http.ResponseWriter, r *ht
|
||||
} else {
|
||||
bucketPolicy = string(policyData)
|
||||
}
|
||||
}
|
||||
|
||||
// Check Permission
|
||||
principal := h.getAccountID(r)
|
||||
if !CheckPermission("ListTagsForResource", principal, ownerAccountID, bucketPolicy) {
|
||||
return NewAuthError("ListTagsForResource", principal, "not authorized to list tags for resource")
|
||||
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
data, err = h.getExtendedAttribute(r.Context(), client, resourcePath, extendedKey)
|
||||
@@ -701,7 +745,22 @@ func (h *S3TablesHandler) handleListTagsForResource(w http.ResponseWriter, r *ht
|
||||
}
|
||||
return err // Propagate other errors.
|
||||
}
|
||||
return json.Unmarshal(data, &tags)
|
||||
if err := json.Unmarshal(data, &tags); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resourceARN := req.ResourceARN
|
||||
principal := h.getAccountID(r)
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("ListTagsForResource", principal, ownerAccountID, bucketPolicy, resourceARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
TableBucketTags: bucketTags,
|
||||
ResourceTags: tags,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
return NewAuthError("ListTagsForResource", principal, "not authorized to list tags for resource")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -754,6 +813,7 @@ func (h *S3TablesHandler) handleUntagResource(w http.ResponseWriter, r *http.Req
|
||||
// Read existing tags, check permission
|
||||
tags := make(map[string]string)
|
||||
var bucketPolicy string
|
||||
var bucketTags map[string]string
|
||||
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Read metadata for ownership check
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMetadata)
|
||||
@@ -778,12 +838,10 @@ func (h *S3TablesHandler) handleUntagResource(w http.ResponseWriter, r *http.Req
|
||||
} else {
|
||||
bucketPolicy = string(policyData)
|
||||
}
|
||||
}
|
||||
|
||||
// Check Permission
|
||||
principal := h.getAccountID(r)
|
||||
if !CanManageTags(principal, ownerAccountID, bucketPolicy) {
|
||||
return NewAuthError("UntagResource", principal, "not authorized to untag resource")
|
||||
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
data, err = h.getExtendedAttribute(r.Context(), client, resourcePath, extendedKey)
|
||||
@@ -793,7 +851,23 @@ func (h *S3TablesHandler) handleUntagResource(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, &tags)
|
||||
if err := json.Unmarshal(data, &tags); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resourceARN := req.ResourceARN
|
||||
principal := h.getAccountID(r)
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("UntagResource", principal, ownerAccountID, bucketPolicy, resourceARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
TableBucketTags: bucketTags,
|
||||
TagKeys: req.TagKeys,
|
||||
ResourceTags: tags,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
return NewAuthError("UntagResource", principal, "not authorized to untag resource")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -90,11 +90,13 @@ func (h *S3TablesHandler) handleCreateTable(w http.ResponseWriter, r *http.Reque
|
||||
bucketPath := getTableBucketPath(bucketName)
|
||||
namespacePolicy := ""
|
||||
bucketPolicy := ""
|
||||
bucketTags := map[string]string{}
|
||||
var data []byte
|
||||
var bucketMetadata tableBucketMetadata
|
||||
|
||||
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Fetch bucket metadata to use correct owner for bucket policy evaluation
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
|
||||
data, err = h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(data, &bucketMetadata); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal bucket metadata: %w", err)
|
||||
@@ -118,6 +120,11 @@ func (h *S3TablesHandler) handleCreateTable(w http.ResponseWriter, r *http.Reque
|
||||
} else if !errors.Is(err, ErrAttributeNotFound) {
|
||||
return fmt.Errorf("failed to fetch bucket policy: %v", err)
|
||||
}
|
||||
if tags, err := h.readTags(r.Context(), client, bucketPath); err != nil {
|
||||
return err
|
||||
} else if tags != nil {
|
||||
bucketTags = tags
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -127,11 +134,26 @@ func (h *S3TablesHandler) handleCreateTable(w http.ResponseWriter, r *http.Reque
|
||||
return err
|
||||
}
|
||||
|
||||
// Check authorization: namespace policy OR bucket policy OR ownership
|
||||
// Use namespace owner for namespace policy (consistent with namespace authorization)
|
||||
nsAllowed := CanCreateTable(accountID, namespaceMetadata.OwnerAccountID, namespacePolicy)
|
||||
// Use bucket owner for bucket policy (bucket policy applies to bucket-level operations)
|
||||
bucketAllowed := CanCreateTable(accountID, bucketMetadata.OwnerAccountID, bucketPolicy)
|
||||
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
|
||||
identityActions := getIdentityActions(r)
|
||||
nsAllowed := CheckPermissionWithContext("CreateTable", accountID, namespaceMetadata.OwnerAccountID, namespacePolicy, bucketARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
Namespace: namespaceName,
|
||||
TableName: tableName,
|
||||
RequestTags: req.Tags,
|
||||
TagKeys: mapKeys(req.Tags),
|
||||
TableBucketTags: bucketTags,
|
||||
IdentityActions: identityActions,
|
||||
})
|
||||
bucketAllowed := CheckPermissionWithContext("CreateTable", accountID, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
Namespace: namespaceName,
|
||||
TableName: tableName,
|
||||
RequestTags: req.Tags,
|
||||
TagKeys: mapKeys(req.Tags),
|
||||
TableBucketTags: bucketTags,
|
||||
IdentityActions: identityActions,
|
||||
})
|
||||
|
||||
if !nsAllowed && !bucketAllowed {
|
||||
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to create table in this namespace")
|
||||
@@ -290,6 +312,8 @@ func (h *S3TablesHandler) handleGetTable(w http.ResponseWriter, r *http.Request,
|
||||
bucketPath := getTableBucketPath(bucketName)
|
||||
tablePolicy := ""
|
||||
bucketPolicy := ""
|
||||
bucketTags := map[string]string{}
|
||||
tableTags := map[string]string{}
|
||||
var bucketMetadata tableBucketMetadata
|
||||
|
||||
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
@@ -310,6 +334,11 @@ func (h *S3TablesHandler) handleGetTable(w http.ResponseWriter, r *http.Request,
|
||||
} else if !errors.Is(err, ErrAttributeNotFound) {
|
||||
return fmt.Errorf("failed to fetch table policy: %v", err)
|
||||
}
|
||||
if tags, err := h.readTags(r.Context(), client, tablePath); err != nil {
|
||||
return err
|
||||
} else if tags != nil {
|
||||
tableTags = tags
|
||||
}
|
||||
|
||||
// Fetch bucket policy if it exists
|
||||
policyData, err = h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyPolicy)
|
||||
@@ -318,6 +347,11 @@ func (h *S3TablesHandler) handleGetTable(w http.ResponseWriter, r *http.Request,
|
||||
} else if !errors.Is(err, ErrAttributeNotFound) {
|
||||
return fmt.Errorf("failed to fetch bucket policy: %v", err)
|
||||
}
|
||||
if tags, err := h.readTags(r.Context(), client, bucketPath); err != nil {
|
||||
return err
|
||||
} else if tags != nil {
|
||||
bucketTags = tags
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -327,19 +361,31 @@ func (h *S3TablesHandler) handleGetTable(w http.ResponseWriter, r *http.Request,
|
||||
return err
|
||||
}
|
||||
|
||||
// Check authorization: table policy OR bucket policy OR ownership
|
||||
// Use table owner for table policy (table-level access control)
|
||||
tableAllowed := CanGetTable(accountID, metadata.OwnerAccountID, tablePolicy)
|
||||
// Use bucket owner for bucket policy (bucket-level access control)
|
||||
bucketAllowed := CanGetTable(accountID, bucketMetadata.OwnerAccountID, bucketPolicy)
|
||||
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespace+"/"+tableName)
|
||||
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
|
||||
identityActions := getIdentityActions(r)
|
||||
tableAllowed := CheckPermissionWithContext("GetTable", accountID, metadata.OwnerAccountID, tablePolicy, tableARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
Namespace: namespace,
|
||||
TableName: tableName,
|
||||
TableBucketTags: bucketTags,
|
||||
ResourceTags: tableTags,
|
||||
IdentityActions: identityActions,
|
||||
})
|
||||
bucketAllowed := CheckPermissionWithContext("GetTable", accountID, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
Namespace: namespace,
|
||||
TableName: tableName,
|
||||
TableBucketTags: bucketTags,
|
||||
ResourceTags: tableTags,
|
||||
IdentityActions: identityActions,
|
||||
})
|
||||
|
||||
if !tableAllowed && !bucketAllowed {
|
||||
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespace+"/"+tableName)
|
||||
|
||||
resp := &GetTableResponse{
|
||||
Name: metadata.Name,
|
||||
TableARN: tableARN,
|
||||
@@ -412,6 +458,7 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
|
||||
var nsMeta namespaceMetadata
|
||||
var bucketMeta tableBucketMetadata
|
||||
var namespacePolicy, bucketPolicy string
|
||||
bucketTags := map[string]string{}
|
||||
|
||||
// Fetch namespace metadata and policy
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyMetadata)
|
||||
@@ -446,10 +493,26 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
|
||||
} else if !errors.Is(err, ErrAttributeNotFound) {
|
||||
return fmt.Errorf("failed to fetch bucket policy: %v", err)
|
||||
}
|
||||
if tags, err := h.readTags(r.Context(), client, bucketPath); err != nil {
|
||||
return err
|
||||
} else if tags != nil {
|
||||
bucketTags = tags
|
||||
}
|
||||
|
||||
// Authorize listing: namespace policy OR bucket policy OR ownership
|
||||
nsAllowed := CanListTables(accountID, nsMeta.OwnerAccountID, namespacePolicy)
|
||||
bucketAllowed := CanListTables(accountID, bucketMeta.OwnerAccountID, bucketPolicy)
|
||||
bucketARN := h.generateTableBucketARN(bucketMeta.OwnerAccountID, bucketName)
|
||||
identityActions := getIdentityActions(r)
|
||||
nsAllowed := CheckPermissionWithContext("ListTables", accountID, nsMeta.OwnerAccountID, namespacePolicy, bucketARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
Namespace: namespaceName,
|
||||
TableBucketTags: bucketTags,
|
||||
IdentityActions: identityActions,
|
||||
})
|
||||
bucketAllowed := CheckPermissionWithContext("ListTables", accountID, bucketMeta.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
Namespace: namespaceName,
|
||||
TableBucketTags: bucketTags,
|
||||
IdentityActions: identityActions,
|
||||
})
|
||||
if !nsAllowed && !bucketAllowed {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
@@ -460,6 +523,7 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
|
||||
bucketPath := getTableBucketPath(bucketName)
|
||||
var bucketMeta tableBucketMetadata
|
||||
var bucketPolicy string
|
||||
bucketTags := map[string]string{}
|
||||
|
||||
// Fetch bucket metadata and policy
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
|
||||
@@ -477,9 +541,19 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
|
||||
} else if !errors.Is(err, ErrAttributeNotFound) {
|
||||
return fmt.Errorf("failed to fetch bucket policy: %v", err)
|
||||
}
|
||||
if tags, err := h.readTags(r.Context(), client, bucketPath); err != nil {
|
||||
return err
|
||||
} else if tags != nil {
|
||||
bucketTags = tags
|
||||
}
|
||||
|
||||
// Authorize listing: bucket policy OR ownership
|
||||
if !CanListTables(accountID, bucketMeta.OwnerAccountID, bucketPolicy) {
|
||||
bucketARN := h.generateTableBucketARN(bucketMeta.OwnerAccountID, bucketName)
|
||||
identityActions := getIdentityActions(r)
|
||||
if !CheckPermissionWithContext("ListTables", accountID, bucketMeta.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
TableBucketTags: bucketTags,
|
||||
IdentityActions: identityActions,
|
||||
}) {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
@@ -731,6 +805,10 @@ func (h *S3TablesHandler) handleDeleteTable(w http.ResponseWriter, r *http.Reque
|
||||
// Check if table exists and enforce VersionToken if provided
|
||||
var metadata tableMetadataInternal
|
||||
var tablePolicy string
|
||||
var bucketPolicy string
|
||||
var bucketTags map[string]string
|
||||
var tableTags map[string]string
|
||||
var bucketMetadata tableBucketMetadata
|
||||
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
data, err := h.getExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyMetadata)
|
||||
if err != nil {
|
||||
@@ -759,6 +837,33 @@ func (h *S3TablesHandler) handleDeleteTable(w http.ResponseWriter, r *http.Reque
|
||||
tablePolicy = string(policyData)
|
||||
}
|
||||
|
||||
tableTags, err = h.readTags(r.Context(), client, tablePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bucketPath := getTableBucketPath(bucketName)
|
||||
data, err = h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(data, &bucketMetadata); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal bucket metadata: %w", err)
|
||||
}
|
||||
} else if !errors.Is(err, ErrAttributeNotFound) {
|
||||
return fmt.Errorf("failed to fetch bucket metadata: %w", err)
|
||||
}
|
||||
policyData, err = h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyPolicy)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrAttributeNotFound) {
|
||||
return fmt.Errorf("failed to fetch bucket policy: %w", err)
|
||||
}
|
||||
} else {
|
||||
bucketPolicy = string(policyData)
|
||||
}
|
||||
bucketTags, err = h.readTags(r.Context(), client, bucketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -773,9 +878,27 @@ func (h *S3TablesHandler) handleDeleteTable(w http.ResponseWriter, r *http.Reque
|
||||
return err
|
||||
}
|
||||
|
||||
// Check permission using table and bucket policies
|
||||
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
|
||||
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
|
||||
principal := h.getAccountID(r)
|
||||
if !CanDeleteTable(principal, metadata.OwnerAccountID, tablePolicy) {
|
||||
identityActions := getIdentityActions(r)
|
||||
tableAllowed := CheckPermissionWithContext("DeleteTable", principal, metadata.OwnerAccountID, tablePolicy, tableARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
Namespace: namespaceName,
|
||||
TableName: tableName,
|
||||
TableBucketTags: bucketTags,
|
||||
ResourceTags: tableTags,
|
||||
IdentityActions: identityActions,
|
||||
})
|
||||
bucketAllowed := CheckPermissionWithContext("DeleteTable", principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
|
||||
TableBucketName: bucketName,
|
||||
Namespace: namespaceName,
|
||||
TableName: tableName,
|
||||
TableBucketTags: bucketTags,
|
||||
ResourceTags: tableTags,
|
||||
IdentityActions: identityActions,
|
||||
})
|
||||
if !tableAllowed && !bucketAllowed {
|
||||
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to delete table")
|
||||
return NewAuthError("DeleteTable", principal, "not authorized to delete table")
|
||||
}
|
||||
|
||||
98
weed/s3api/s3tables/manager.go
Normal file
98
weed/s3api/s3tables/manager.go
Normal 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)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
// Permission represents a specific action permission
|
||||
@@ -65,24 +66,63 @@ func (pd *PolicyDocument) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type Statement struct {
|
||||
Effect string `json:"Effect"` // "Allow" or "Deny"
|
||||
Principal interface{} `json:"Principal"` // Can be string, []string, or map
|
||||
Action interface{} `json:"Action"` // Can be string or []string
|
||||
Resource interface{} `json:"Resource"` // Can be string or []string
|
||||
Effect string `json:"Effect"` // "Allow" or "Deny"
|
||||
Principal interface{} `json:"Principal"` // Can be string, []string, or map
|
||||
Action interface{} `json:"Action"` // Can be string or []string
|
||||
Resource interface{} `json:"Resource"` // Can be string or []string
|
||||
Condition map[string]map[string]interface{} `json:"Condition,omitempty"`
|
||||
}
|
||||
|
||||
type PolicyContext struct {
|
||||
Namespace string
|
||||
TableName string
|
||||
TableBucketName string
|
||||
IdentityActions []string
|
||||
RequestTags map[string]string
|
||||
ResourceTags map[string]string
|
||||
TableBucketTags map[string]string
|
||||
TagKeys []string
|
||||
SSEAlgorithm string
|
||||
KMSKeyArn string
|
||||
StorageClass string
|
||||
}
|
||||
|
||||
// CheckPermissionWithResource checks if a principal has permission to perform an operation on a specific resource
|
||||
func CheckPermissionWithResource(operation, principal, owner, resourcePolicy, resourceARN string) bool {
|
||||
return CheckPermissionWithContext(operation, principal, owner, resourcePolicy, resourceARN, nil)
|
||||
}
|
||||
|
||||
// CheckPermission checks if a principal has permission to perform an operation
|
||||
// (without resource-specific validation - for backward compatibility)
|
||||
func CheckPermission(operation, principal, owner, resourcePolicy string) bool {
|
||||
return CheckPermissionWithContext(operation, principal, owner, resourcePolicy, "", nil)
|
||||
}
|
||||
|
||||
// CheckPermissionWithContext checks permission with optional resource and condition context.
|
||||
func CheckPermissionWithContext(operation, principal, owner, resourcePolicy, resourceARN string, ctx *PolicyContext) bool {
|
||||
// Deny access if identities are empty
|
||||
if principal == "" || owner == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Admin always has permission.
|
||||
if principal == s3_constants.AccountAdminId {
|
||||
return true
|
||||
}
|
||||
|
||||
return checkPermission(operation, principal, owner, resourcePolicy, resourceARN, ctx)
|
||||
}
|
||||
|
||||
func checkPermission(operation, principal, owner, resourcePolicy, resourceARN string, ctx *PolicyContext) bool {
|
||||
// Owner always has permission
|
||||
if principal == owner {
|
||||
return true
|
||||
}
|
||||
|
||||
if hasIdentityPermission(operation, ctx) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If no policy is provided, deny access (default deny)
|
||||
if resourcePolicy == "" {
|
||||
return false
|
||||
@@ -121,6 +161,10 @@ func CheckPermissionWithResource(operation, principal, owner, resourcePolicy, re
|
||||
continue
|
||||
}
|
||||
|
||||
if !matchesConditions(stmt.Condition, ctx) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Statement matches - check effect
|
||||
if stmt.Effect == "Allow" {
|
||||
hasAllow = true
|
||||
@@ -133,62 +177,29 @@ func CheckPermissionWithResource(operation, principal, owner, resourcePolicy, re
|
||||
return hasAllow
|
||||
}
|
||||
|
||||
// CheckPermission checks if a principal has permission to perform an operation
|
||||
// (without resource-specific validation - for backward compatibility)
|
||||
func CheckPermission(operation, principal, owner, resourcePolicy string) bool {
|
||||
// Deny access if identities are empty
|
||||
if principal == "" || owner == "" {
|
||||
func hasIdentityPermission(operation string, ctx *PolicyContext) bool {
|
||||
if ctx == nil || len(ctx.IdentityActions) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Owner always has permission
|
||||
if principal == owner {
|
||||
return true
|
||||
}
|
||||
|
||||
// If no policy is provided, deny access (default deny)
|
||||
if resourcePolicy == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalize operation to full IAM-style action name (e.g., "s3tables:CreateTableBucket")
|
||||
// if not already prefixed
|
||||
fullAction := operation
|
||||
if !strings.Contains(operation, ":") {
|
||||
fullAction = "s3tables:" + operation
|
||||
}
|
||||
|
||||
// Parse and evaluate policy
|
||||
var policy PolicyDocument
|
||||
if err := json.Unmarshal([]byte(resourcePolicy), &policy); err != nil {
|
||||
return false
|
||||
candidates := []string{operation, fullAction}
|
||||
if ctx.TableBucketName != "" {
|
||||
candidates = append(candidates, operation+":"+ctx.TableBucketName, fullAction+":"+ctx.TableBucketName)
|
||||
}
|
||||
|
||||
// Evaluate policy statements
|
||||
// Default is deny, so we need an explicit allow
|
||||
hasAllow := false
|
||||
|
||||
for _, stmt := range policy.Statement {
|
||||
// Check if principal matches
|
||||
if !matchesPrincipal(stmt.Principal, principal) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if action matches (using normalized full action name)
|
||||
if !matchesAction(stmt.Action, fullAction) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Statement matches - check effect
|
||||
if stmt.Effect == "Allow" {
|
||||
hasAllow = true
|
||||
} else if stmt.Effect == "Deny" {
|
||||
// Explicit deny always wins
|
||||
return false
|
||||
for _, action := range ctx.IdentityActions {
|
||||
for _, candidate := range candidates {
|
||||
if action == candidate {
|
||||
return true
|
||||
}
|
||||
if strings.ContainsAny(action, "*?") && policy_engine.MatchesWildcard(action, candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hasAllow
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesPrincipal checks if the principal matches the statement's principal
|
||||
@@ -271,6 +282,74 @@ func matchesActionPattern(pattern, action string) bool {
|
||||
return policy_engine.MatchesWildcard(pattern, action)
|
||||
}
|
||||
|
||||
func matchesConditions(conditions map[string]map[string]interface{}, ctx *PolicyContext) bool {
|
||||
if len(conditions) == 0 {
|
||||
return true
|
||||
}
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
for operator, conditionValues := range conditions {
|
||||
if !matchesConditionOperator(operator, conditionValues, ctx) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matchesConditionOperator(operator string, conditionValues map[string]interface{}, ctx *PolicyContext) bool {
|
||||
evaluator, err := policy_engine.GetConditionEvaluator(operator)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for key, value := range conditionValues {
|
||||
contextVals := getConditionContextValues(key, ctx)
|
||||
if !evaluator.Evaluate(value, contextVals) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func getConditionContextValues(key string, ctx *PolicyContext) []string {
|
||||
switch key {
|
||||
case "s3tables:namespace":
|
||||
return []string{ctx.Namespace}
|
||||
case "s3tables:tableName":
|
||||
return []string{ctx.TableName}
|
||||
case "s3tables:tableBucketName":
|
||||
return []string{ctx.TableBucketName}
|
||||
case "s3tables:SSEAlgorithm":
|
||||
return []string{ctx.SSEAlgorithm}
|
||||
case "s3tables:KMSKeyArn":
|
||||
return []string{ctx.KMSKeyArn}
|
||||
case "s3tables:StorageClass":
|
||||
return []string{ctx.StorageClass}
|
||||
case "aws:TagKeys":
|
||||
return ctx.TagKeys
|
||||
}
|
||||
if strings.HasPrefix(key, "aws:RequestTag/") {
|
||||
tagKey := strings.TrimPrefix(key, "aws:RequestTag/")
|
||||
if val, ok := ctx.RequestTags[tagKey]; ok {
|
||||
return []string{val}
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(key, "aws:ResourceTag/") {
|
||||
tagKey := strings.TrimPrefix(key, "aws:ResourceTag/")
|
||||
if val, ok := ctx.ResourceTags[tagKey]; ok {
|
||||
return []string{val}
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(key, "s3tables:TableBucketTag/") {
|
||||
tagKey := strings.TrimPrefix(key, "s3tables:TableBucketTag/")
|
||||
if val, ok := ctx.TableBucketTags[tagKey]; ok {
|
||||
return []string{val}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchesResource checks if the resource ARN matches the statement's resource specification
|
||||
// Returns true if resource matches or if Resource is not specified (implicit match)
|
||||
func matchesResource(resourceSpec interface{}, resourceARN string) bool {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package s3tables
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatchesActionPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -88,3 +91,118 @@ func TestMatchesPrincipal(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluatePolicyWithConditions(t *testing.T) {
|
||||
policy := &PolicyDocument{
|
||||
Statement: []Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: "*",
|
||||
Action: "s3tables:GetTable",
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"StringEquals": {
|
||||
"s3tables:namespace": "default",
|
||||
},
|
||||
"StringLike": {
|
||||
"s3tables:tableName": "test_*",
|
||||
},
|
||||
"NumericGreaterThan": {
|
||||
"aws:RequestTag/priority": "10",
|
||||
},
|
||||
"Bool": {
|
||||
"aws:ResourceTag/is_public": "true",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
policyBytes, _ := json.Marshal(policy)
|
||||
policyStr := string(policyBytes)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx *PolicyContext
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
"all conditions match",
|
||||
&PolicyContext{
|
||||
Namespace: "default",
|
||||
TableName: "test_table",
|
||||
RequestTags: map[string]string{
|
||||
"priority": "15",
|
||||
},
|
||||
ResourceTags: map[string]string{
|
||||
"is_public": "true",
|
||||
},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"namespace mismatch",
|
||||
&PolicyContext{
|
||||
Namespace: "other",
|
||||
TableName: "test_table",
|
||||
RequestTags: map[string]string{
|
||||
"priority": "15",
|
||||
},
|
||||
ResourceTags: map[string]string{
|
||||
"is_public": "true",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"table name mismatch",
|
||||
&PolicyContext{
|
||||
Namespace: "default",
|
||||
TableName: "other_table",
|
||||
RequestTags: map[string]string{
|
||||
"priority": "15",
|
||||
},
|
||||
ResourceTags: map[string]string{
|
||||
"is_public": "true",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"numeric condition failure",
|
||||
&PolicyContext{
|
||||
Namespace: "default",
|
||||
TableName: "test_table",
|
||||
RequestTags: map[string]string{
|
||||
"priority": "5",
|
||||
},
|
||||
ResourceTags: map[string]string{
|
||||
"is_public": "true",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"bool condition failure",
|
||||
&PolicyContext{
|
||||
Namespace: "default",
|
||||
TableName: "test_table",
|
||||
RequestTags: map[string]string{
|
||||
"priority": "15",
|
||||
},
|
||||
ResourceTags: map[string]string{
|
||||
"is_public": "false",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// principal="user123", owner="owner123"
|
||||
result := CheckPermissionWithContext("s3tables:GetTable", "user123", "owner123", policyStr, "", tt.ctx)
|
||||
if result != tt.expected {
|
||||
t.Errorf("CheckPermissionWithContext() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
var (
|
||||
bucketARNPattern = regexp.MustCompile(`^arn:aws:s3tables:[^:]*:[^:]*:bucket/(` + bucketNamePatternStr + `)$`)
|
||||
tableARNPattern = regexp.MustCompile(`^arn:aws:s3tables:[^:]*:[^:]*:bucket/(` + bucketNamePatternStr + `)/table/(` + tableNamespacePatternStr + `)/(` + tableNamePatternStr + `)$`)
|
||||
tagPattern = regexp.MustCompile(`^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$`)
|
||||
)
|
||||
|
||||
// ARN parsing functions
|
||||
@@ -175,6 +176,80 @@ func validateBucketName(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateBucketName validates bucket name and returns an error if invalid.
|
||||
func ValidateBucketName(name string) error {
|
||||
return validateBucketName(name)
|
||||
}
|
||||
|
||||
// BuildBucketARN builds a bucket ARN with the provided region and account ID.
|
||||
// If region is empty, the ARN will omit the region field.
|
||||
func BuildBucketARN(region, accountID, bucketName string) (string, error) {
|
||||
if bucketName == "" {
|
||||
return "", fmt.Errorf("bucket name is required")
|
||||
}
|
||||
if err := validateBucketName(bucketName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if accountID == "" {
|
||||
accountID = DefaultAccountID
|
||||
}
|
||||
return buildARN(region, accountID, fmt.Sprintf("bucket/%s", bucketName)), nil
|
||||
}
|
||||
|
||||
// BuildTableARN builds a table ARN with the provided region and account ID.
|
||||
func BuildTableARN(region, accountID, bucketName, namespace, tableName string) (string, error) {
|
||||
if bucketName == "" {
|
||||
return "", fmt.Errorf("bucket name is required")
|
||||
}
|
||||
if err := validateBucketName(bucketName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if namespace == "" {
|
||||
return "", fmt.Errorf("namespace is required")
|
||||
}
|
||||
normalizedNamespace, err := validateNamespace([]string{namespace})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if tableName == "" {
|
||||
return "", fmt.Errorf("table name is required")
|
||||
}
|
||||
normalizedTable, err := validateTableName(tableName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if accountID == "" {
|
||||
accountID = DefaultAccountID
|
||||
}
|
||||
return buildARN(region, accountID, fmt.Sprintf("bucket/%s/table/%s/%s", bucketName, normalizedNamespace, normalizedTable)), nil
|
||||
}
|
||||
|
||||
func buildARN(region, accountID, resourcePath string) string {
|
||||
return fmt.Sprintf("arn:aws:s3tables:%s:%s:%s", region, accountID, resourcePath)
|
||||
}
|
||||
|
||||
// ValidateTags validates tags for S3 Tables.
|
||||
func ValidateTags(tags map[string]string) error {
|
||||
if len(tags) > 10 {
|
||||
return fmt.Errorf("validate tags: %d tags more than 10", len(tags))
|
||||
}
|
||||
for k, v := range tags {
|
||||
if len(k) > 128 {
|
||||
return fmt.Errorf("validate tags: tag key longer than 128")
|
||||
}
|
||||
if !tagPattern.MatchString(k) {
|
||||
return fmt.Errorf("validate tags key %s error, incorrect key", k)
|
||||
}
|
||||
if len(v) > 256 {
|
||||
return fmt.Errorf("validate tags: tag value longer than 256")
|
||||
}
|
||||
if !tagPattern.MatchString(v) {
|
||||
return fmt.Errorf("validate tags value %s error, incorrect value", v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidBucketName validates bucket name characters (kept for compatibility)
|
||||
// Deprecated: use validateBucketName instead
|
||||
func isValidBucketName(name string) bool {
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
@@ -78,25 +78,5 @@ func parseTagsHeader(tags string) (map[string]string, error) {
|
||||
}
|
||||
|
||||
func ValidateTags(tags map[string]string) error {
|
||||
if len(tags) > 10 {
|
||||
return fmt.Errorf("validate tags: %d tags more than 10", len(tags))
|
||||
}
|
||||
for k, v := range tags {
|
||||
if len(k) > 128 {
|
||||
return fmt.Errorf("validate tags: tag key longer than 128")
|
||||
}
|
||||
validateKey, err := regexp.MatchString(`^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$`, k)
|
||||
if !validateKey || err != nil {
|
||||
return fmt.Errorf("validate tags key %s error, incorrect key", k)
|
||||
}
|
||||
if len(v) > 256 {
|
||||
return fmt.Errorf("validate tags: tag value longer than 256")
|
||||
}
|
||||
validateValue, err := regexp.MatchString(`^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$`, v)
|
||||
if !validateValue || err != nil {
|
||||
return fmt.Errorf("validate tags value %s error, incorrect value", v)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return s3tables.ValidateTags(tags)
|
||||
}
|
||||
|
||||
254
weed/shell/command_s3tables_bucket.go
Normal file
254
weed/shell/command_s3tables_bucket.go
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
131
weed/shell/command_s3tables_namespace.go
Normal file
131
weed/shell/command_s3tables_namespace.go
Normal file
@@ -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
|
||||
}
|
||||
205
weed/shell/command_s3tables_table.go
Normal file
205
weed/shell/command_s3tables_table.go
Normal file
@@ -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
|
||||
}
|
||||
131
weed/shell/command_s3tables_tag.go
Normal file
131
weed/shell/command_s3tables_tag.go
Normal file
@@ -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
|
||||
}
|
||||
89
weed/shell/s3tables_helpers.go
Normal file
89
weed/shell/s3tables_helpers.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user