Browse Source
Admin UI: Add policies (#6968)
Admin UI: Add policies (#6968)
* add policies to UI, accessing filer directly * view, edit policies * add back buttons for "users" page * remove unused * fix ui dark mode when modal is closed * bucket view details button * fix browser buttons * filer action button works * clean up masters page * fix volume servers action buttons * fix collections page action button * fix properties page * more obvious * fix directory creation file mode * Update file_browser_handlers.go * directory permissionpull/6973/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 4921 additions and 2363 deletions
-
1weed/admin/dash/admin_server.go
-
80weed/admin/dash/file_browser_data.go
-
85weed/admin/dash/file_mode_utils.go
-
225weed/admin/dash/policies_management.go
-
27weed/admin/handlers/admin_handlers.go
-
15weed/admin/handlers/file_browser_handlers.go
-
121weed/admin/handlers/maintenance_handlers.go
-
273weed/admin/handlers/policy_handlers.go
-
211weed/admin/view/app/cluster_collections.templ
-
87weed/admin/view/app/cluster_collections_templ.go
-
56weed/admin/view/app/cluster_filers.templ
-
2weed/admin/view/app/cluster_filers_templ.go
-
119weed/admin/view/app/cluster_masters.templ
-
57weed/admin/view/app/cluster_masters_templ.go
-
177weed/admin/view/app/cluster_volume_servers.templ
-
148weed/admin/view/app/cluster_volume_servers_templ.go
-
376weed/admin/view/app/file_browser.templ
-
96weed/admin/view/app/file_browser_templ.go
-
350weed/admin/view/app/object_store_users.templ
-
2weed/admin/view/app/object_store_users_templ.go
-
658weed/admin/view/app/policies.templ
-
204weed/admin/view/app/policies_templ.go
-
297weed/admin/view/app/s3_buckets.templ
-
22weed/admin/view/app/s3_buckets_templ.go
-
5weed/admin/view/layout/layout.templ
-
24weed/admin/view/layout/layout_templ.go
-
22weed/credential/credential_store.go
-
188weed/credential/filer_etc/filer_etc_identity.go
-
114weed/credential/filer_etc/filer_etc_policy.go
-
180weed/credential/filer_etc/filer_etc_store.go
-
302weed/credential/memory/memory_identity.go
-
77weed/credential/memory/memory_policy.go
-
299weed/credential/memory/memory_store.go
-
446weed/credential/postgres/postgres_identity.go
-
130weed/credential/postgres/postgres_policy.go
-
449weed/credential/postgres/postgres_store.go
-
146weed/credential/test/policy_test.go
-
369weed/worker/tasks/balance/ui_templ.go
-
319weed/worker/tasks/erasure_coding/ui_templ.go
-
330weed/worker/tasks/vacuum/ui_templ.go
-
63weed/worker/types/task_ui_templ.go
@ -0,0 +1,85 @@ |
|||||
|
package dash |
||||
|
|
||||
|
// FormatFileMode converts file mode to Unix-style string representation (e.g., "drwxr-xr-x")
|
||||
|
// Handles both Go's os.ModeDir format and standard Unix file type bits
|
||||
|
func FormatFileMode(mode uint32) string { |
||||
|
var result []byte = make([]byte, 10) |
||||
|
|
||||
|
// File type - handle Go's os.ModeDir first, then standard Unix file type bits
|
||||
|
if mode&0x80000000 != 0 { // Go's os.ModeDir (0x80000000 = 2147483648)
|
||||
|
result[0] = 'd' |
||||
|
} else { |
||||
|
switch mode & 0170000 { // S_IFMT mask
|
||||
|
case 0040000: // S_IFDIR
|
||||
|
result[0] = 'd' |
||||
|
case 0100000: // S_IFREG
|
||||
|
result[0] = '-' |
||||
|
case 0120000: // S_IFLNK
|
||||
|
result[0] = 'l' |
||||
|
case 0020000: // S_IFCHR
|
||||
|
result[0] = 'c' |
||||
|
case 0060000: // S_IFBLK
|
||||
|
result[0] = 'b' |
||||
|
case 0010000: // S_IFIFO
|
||||
|
result[0] = 'p' |
||||
|
case 0140000: // S_IFSOCK
|
||||
|
result[0] = 's' |
||||
|
default: |
||||
|
result[0] = '-' // S_IFREG is default
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Permission bits (always use the lower 12 bits regardless of file type format)
|
||||
|
// Owner permissions
|
||||
|
if mode&0400 != 0 { // S_IRUSR
|
||||
|
result[1] = 'r' |
||||
|
} else { |
||||
|
result[1] = '-' |
||||
|
} |
||||
|
if mode&0200 != 0 { // S_IWUSR
|
||||
|
result[2] = 'w' |
||||
|
} else { |
||||
|
result[2] = '-' |
||||
|
} |
||||
|
if mode&0100 != 0 { // S_IXUSR
|
||||
|
result[3] = 'x' |
||||
|
} else { |
||||
|
result[3] = '-' |
||||
|
} |
||||
|
|
||||
|
// Group permissions
|
||||
|
if mode&0040 != 0 { // S_IRGRP
|
||||
|
result[4] = 'r' |
||||
|
} else { |
||||
|
result[4] = '-' |
||||
|
} |
||||
|
if mode&0020 != 0 { // S_IWGRP
|
||||
|
result[5] = 'w' |
||||
|
} else { |
||||
|
result[5] = '-' |
||||
|
} |
||||
|
if mode&0010 != 0 { // S_IXGRP
|
||||
|
result[6] = 'x' |
||||
|
} else { |
||||
|
result[6] = '-' |
||||
|
} |
||||
|
|
||||
|
// Other permissions
|
||||
|
if mode&0004 != 0 { // S_IROTH
|
||||
|
result[7] = 'r' |
||||
|
} else { |
||||
|
result[7] = '-' |
||||
|
} |
||||
|
if mode&0002 != 0 { // S_IWOTH
|
||||
|
result[8] = 'w' |
||||
|
} else { |
||||
|
result[8] = '-' |
||||
|
} |
||||
|
if mode&0001 != 0 { // S_IXOTH
|
||||
|
result[9] = 'x' |
||||
|
} else { |
||||
|
result[9] = '-' |
||||
|
} |
||||
|
|
||||
|
return string(result) |
||||
|
} |
@ -0,0 +1,225 @@ |
|||||
|
package dash |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/credential" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/glog" |
||||
|
) |
||||
|
|
||||
|
type IAMPolicy struct { |
||||
|
Name string `json:"name"` |
||||
|
Document credential.PolicyDocument `json:"document"` |
||||
|
DocumentJSON string `json:"document_json"` |
||||
|
CreatedAt time.Time `json:"created_at"` |
||||
|
UpdatedAt time.Time `json:"updated_at"` |
||||
|
} |
||||
|
|
||||
|
type PoliciesCollection struct { |
||||
|
Policies map[string]credential.PolicyDocument `json:"policies"` |
||||
|
} |
||||
|
|
||||
|
type PoliciesData struct { |
||||
|
Username string `json:"username"` |
||||
|
Policies []IAMPolicy `json:"policies"` |
||||
|
TotalPolicies int `json:"total_policies"` |
||||
|
LastUpdated time.Time `json:"last_updated"` |
||||
|
} |
||||
|
|
||||
|
// Policy management request structures
|
||||
|
type CreatePolicyRequest struct { |
||||
|
Name string `json:"name" binding:"required"` |
||||
|
Document credential.PolicyDocument `json:"document" binding:"required"` |
||||
|
DocumentJSON string `json:"document_json"` |
||||
|
} |
||||
|
|
||||
|
type UpdatePolicyRequest struct { |
||||
|
Document credential.PolicyDocument `json:"document" binding:"required"` |
||||
|
DocumentJSON string `json:"document_json"` |
||||
|
} |
||||
|
|
||||
|
// PolicyManager interface is now in the credential package
|
||||
|
|
||||
|
// CredentialStorePolicyManager implements credential.PolicyManager by delegating to the credential store
|
||||
|
type CredentialStorePolicyManager struct { |
||||
|
credentialManager *credential.CredentialManager |
||||
|
} |
||||
|
|
||||
|
// NewCredentialStorePolicyManager creates a new CredentialStorePolicyManager
|
||||
|
func NewCredentialStorePolicyManager(credentialManager *credential.CredentialManager) *CredentialStorePolicyManager { |
||||
|
return &CredentialStorePolicyManager{ |
||||
|
credentialManager: credentialManager, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// GetPolicies retrieves all IAM policies via credential store
|
||||
|
func (cspm *CredentialStorePolicyManager) GetPolicies(ctx context.Context) (map[string]credential.PolicyDocument, error) { |
||||
|
// Get policies from credential store
|
||||
|
// We'll use the credential store to access the filer indirectly
|
||||
|
// Since policies are stored separately, we need to access the underlying store
|
||||
|
store := cspm.credentialManager.GetStore() |
||||
|
glog.V(1).Infof("Getting policies from credential store: %T", store) |
||||
|
|
||||
|
// Check if the store supports policy management
|
||||
|
if policyStore, ok := store.(credential.PolicyManager); ok { |
||||
|
glog.V(1).Infof("Store supports policy management, calling GetPolicies") |
||||
|
policies, err := policyStore.GetPolicies(ctx) |
||||
|
if err != nil { |
||||
|
glog.Errorf("Error getting policies from store: %v", err) |
||||
|
return nil, err |
||||
|
} |
||||
|
glog.V(1).Infof("Got %d policies from store", len(policies)) |
||||
|
return policies, nil |
||||
|
} else { |
||||
|
// Fallback: use empty policies for stores that don't support policies
|
||||
|
glog.V(1).Infof("Credential store doesn't support policy management, returning empty policies") |
||||
|
return make(map[string]credential.PolicyDocument), nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// CreatePolicy creates a new IAM policy via credential store
|
||||
|
func (cspm *CredentialStorePolicyManager) CreatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error { |
||||
|
store := cspm.credentialManager.GetStore() |
||||
|
|
||||
|
if policyStore, ok := store.(credential.PolicyManager); ok { |
||||
|
return policyStore.CreatePolicy(ctx, name, document) |
||||
|
} |
||||
|
|
||||
|
return fmt.Errorf("credential store doesn't support policy creation") |
||||
|
} |
||||
|
|
||||
|
// UpdatePolicy updates an existing IAM policy via credential store
|
||||
|
func (cspm *CredentialStorePolicyManager) UpdatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error { |
||||
|
store := cspm.credentialManager.GetStore() |
||||
|
|
||||
|
if policyStore, ok := store.(credential.PolicyManager); ok { |
||||
|
return policyStore.UpdatePolicy(ctx, name, document) |
||||
|
} |
||||
|
|
||||
|
return fmt.Errorf("credential store doesn't support policy updates") |
||||
|
} |
||||
|
|
||||
|
// DeletePolicy deletes an IAM policy via credential store
|
||||
|
func (cspm *CredentialStorePolicyManager) DeletePolicy(ctx context.Context, name string) error { |
||||
|
store := cspm.credentialManager.GetStore() |
||||
|
|
||||
|
if policyStore, ok := store.(credential.PolicyManager); ok { |
||||
|
return policyStore.DeletePolicy(ctx, name) |
||||
|
} |
||||
|
|
||||
|
return fmt.Errorf("credential store doesn't support policy deletion") |
||||
|
} |
||||
|
|
||||
|
// GetPolicy retrieves a specific IAM policy via credential store
|
||||
|
func (cspm *CredentialStorePolicyManager) GetPolicy(ctx context.Context, name string) (*credential.PolicyDocument, error) { |
||||
|
store := cspm.credentialManager.GetStore() |
||||
|
|
||||
|
if policyStore, ok := store.(credential.PolicyManager); ok { |
||||
|
return policyStore.GetPolicy(ctx, name) |
||||
|
} |
||||
|
|
||||
|
return nil, fmt.Errorf("credential store doesn't support policy retrieval") |
||||
|
} |
||||
|
|
||||
|
// AdminServer policy management methods using credential.PolicyManager
|
||||
|
func (s *AdminServer) GetPolicyManager() credential.PolicyManager { |
||||
|
if s.credentialManager == nil { |
||||
|
glog.V(1).Infof("Credential manager is nil, policy management not available") |
||||
|
return nil |
||||
|
} |
||||
|
glog.V(1).Infof("Credential manager available, creating CredentialStorePolicyManager") |
||||
|
return NewCredentialStorePolicyManager(s.credentialManager) |
||||
|
} |
||||
|
|
||||
|
// GetPolicies retrieves all IAM policies
|
||||
|
func (s *AdminServer) GetPolicies() ([]IAMPolicy, error) { |
||||
|
policyManager := s.GetPolicyManager() |
||||
|
if policyManager == nil { |
||||
|
return nil, fmt.Errorf("policy manager not available") |
||||
|
} |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
policyMap, err := policyManager.GetPolicies(ctx) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
// Convert map[string]PolicyDocument to []IAMPolicy
|
||||
|
var policies []IAMPolicy |
||||
|
for name, doc := range policyMap { |
||||
|
policy := IAMPolicy{ |
||||
|
Name: name, |
||||
|
Document: doc, |
||||
|
DocumentJSON: "", // Will be populated if needed
|
||||
|
CreatedAt: time.Now(), |
||||
|
UpdatedAt: time.Now(), |
||||
|
} |
||||
|
policies = append(policies, policy) |
||||
|
} |
||||
|
|
||||
|
return policies, nil |
||||
|
} |
||||
|
|
||||
|
// CreatePolicy creates a new IAM policy
|
||||
|
func (s *AdminServer) CreatePolicy(name string, document credential.PolicyDocument) error { |
||||
|
policyManager := s.GetPolicyManager() |
||||
|
if policyManager == nil { |
||||
|
return fmt.Errorf("policy manager not available") |
||||
|
} |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
return policyManager.CreatePolicy(ctx, name, document) |
||||
|
} |
||||
|
|
||||
|
// UpdatePolicy updates an existing IAM policy
|
||||
|
func (s *AdminServer) UpdatePolicy(name string, document credential.PolicyDocument) error { |
||||
|
policyManager := s.GetPolicyManager() |
||||
|
if policyManager == nil { |
||||
|
return fmt.Errorf("policy manager not available") |
||||
|
} |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
return policyManager.UpdatePolicy(ctx, name, document) |
||||
|
} |
||||
|
|
||||
|
// DeletePolicy deletes an IAM policy
|
||||
|
func (s *AdminServer) DeletePolicy(name string) error { |
||||
|
policyManager := s.GetPolicyManager() |
||||
|
if policyManager == nil { |
||||
|
return fmt.Errorf("policy manager not available") |
||||
|
} |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
return policyManager.DeletePolicy(ctx, name) |
||||
|
} |
||||
|
|
||||
|
// GetPolicy retrieves a specific IAM policy
|
||||
|
func (s *AdminServer) GetPolicy(name string) (*IAMPolicy, error) { |
||||
|
policyManager := s.GetPolicyManager() |
||||
|
if policyManager == nil { |
||||
|
return nil, fmt.Errorf("policy manager not available") |
||||
|
} |
||||
|
|
||||
|
ctx := context.Background() |
||||
|
policyDoc, err := policyManager.GetPolicy(ctx, name) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
if policyDoc == nil { |
||||
|
return nil, nil |
||||
|
} |
||||
|
|
||||
|
// Convert PolicyDocument to IAMPolicy
|
||||
|
policy := &IAMPolicy{ |
||||
|
Name: name, |
||||
|
Document: *policyDoc, |
||||
|
DocumentJSON: "", // Will be populated if needed
|
||||
|
CreatedAt: time.Now(), |
||||
|
UpdatedAt: time.Now(), |
||||
|
} |
||||
|
|
||||
|
return policy, nil |
||||
|
} |
@ -0,0 +1,273 @@ |
|||||
|
package handlers |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"net/http" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/gin-gonic/gin" |
||||
|
"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/credential" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/glog" |
||||
|
) |
||||
|
|
||||
|
// PolicyHandlers contains all the HTTP handlers for policy management
|
||||
|
type PolicyHandlers struct { |
||||
|
adminServer *dash.AdminServer |
||||
|
} |
||||
|
|
||||
|
// NewPolicyHandlers creates a new instance of PolicyHandlers
|
||||
|
func NewPolicyHandlers(adminServer *dash.AdminServer) *PolicyHandlers { |
||||
|
return &PolicyHandlers{ |
||||
|
adminServer: adminServer, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ShowPolicies renders the policies management page
|
||||
|
func (h *PolicyHandlers) ShowPolicies(c *gin.Context) { |
||||
|
// Get policies data from the server
|
||||
|
policiesData := h.getPoliciesData(c) |
||||
|
|
||||
|
// Render HTML template
|
||||
|
c.Header("Content-Type", "text/html") |
||||
|
policiesComponent := app.Policies(policiesData) |
||||
|
layoutComponent := layout.Layout(c, policiesComponent) |
||||
|
err := layoutComponent.Render(c.Request.Context(), c.Writer) |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// GetPolicies returns the list of policies as JSON
|
||||
|
func (h *PolicyHandlers) GetPolicies(c *gin.Context) { |
||||
|
policies, err := h.adminServer.GetPolicies() |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get policies: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
c.JSON(http.StatusOK, gin.H{"policies": policies}) |
||||
|
} |
||||
|
|
||||
|
// CreatePolicy handles policy creation
|
||||
|
func (h *PolicyHandlers) CreatePolicy(c *gin.Context) { |
||||
|
var req dash.CreatePolicyRequest |
||||
|
if err := c.ShouldBindJSON(&req); err != nil { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Validate policy name
|
||||
|
if req.Name == "" { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Check if policy already exists
|
||||
|
existingPolicy, err := h.adminServer.GetPolicy(req.Name) |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
if existingPolicy != nil { |
||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Policy with this name already exists"}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Create the policy
|
||||
|
err = h.adminServer.CreatePolicy(req.Name, req.Document) |
||||
|
if err != nil { |
||||
|
glog.Errorf("Failed to create policy %s: %v", req.Name, err) |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create policy: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
c.JSON(http.StatusCreated, gin.H{ |
||||
|
"success": true, |
||||
|
"message": "Policy created successfully", |
||||
|
"policy": req.Name, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// GetPolicy returns a specific policy
|
||||
|
func (h *PolicyHandlers) GetPolicy(c *gin.Context) { |
||||
|
policyName := c.Param("name") |
||||
|
if policyName == "" { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
policy, err := h.adminServer.GetPolicy(policyName) |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get policy: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if policy == nil { |
||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
c.JSON(http.StatusOK, policy) |
||||
|
} |
||||
|
|
||||
|
// UpdatePolicy handles policy updates
|
||||
|
func (h *PolicyHandlers) UpdatePolicy(c *gin.Context) { |
||||
|
policyName := c.Param("name") |
||||
|
if policyName == "" { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
var req dash.UpdatePolicyRequest |
||||
|
if err := c.ShouldBindJSON(&req); err != nil { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Check if policy exists
|
||||
|
existingPolicy, err := h.adminServer.GetPolicy(policyName) |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
if existingPolicy == nil { |
||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Update the policy
|
||||
|
err = h.adminServer.UpdatePolicy(policyName, req.Document) |
||||
|
if err != nil { |
||||
|
glog.Errorf("Failed to update policy %s: %v", policyName, err) |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update policy: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
c.JSON(http.StatusOK, gin.H{ |
||||
|
"success": true, |
||||
|
"message": "Policy updated successfully", |
||||
|
"policy": policyName, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// DeletePolicy handles policy deletion
|
||||
|
func (h *PolicyHandlers) DeletePolicy(c *gin.Context) { |
||||
|
policyName := c.Param("name") |
||||
|
if policyName == "" { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Check if policy exists
|
||||
|
existingPolicy, err := h.adminServer.GetPolicy(policyName) |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
if existingPolicy == nil { |
||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Delete the policy
|
||||
|
err = h.adminServer.DeletePolicy(policyName) |
||||
|
if err != nil { |
||||
|
glog.Errorf("Failed to delete policy %s: %v", policyName, err) |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete policy: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
c.JSON(http.StatusOK, gin.H{ |
||||
|
"success": true, |
||||
|
"message": "Policy deleted successfully", |
||||
|
"policy": policyName, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// ValidatePolicy validates a policy document without saving it
|
||||
|
func (h *PolicyHandlers) ValidatePolicy(c *gin.Context) { |
||||
|
var req struct { |
||||
|
Document credential.PolicyDocument `json:"document" binding:"required"` |
||||
|
} |
||||
|
|
||||
|
if err := c.ShouldBindJSON(&req); err != nil { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Basic validation
|
||||
|
if req.Document.Version == "" { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Policy version is required"}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if len(req.Document.Statement) == 0 { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Policy must have at least one statement"}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Validate each statement
|
||||
|
for i, statement := range req.Document.Statement { |
||||
|
if statement.Effect != "Allow" && statement.Effect != "Deny" { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{ |
||||
|
"error": fmt.Sprintf("Statement %d: Effect must be 'Allow' or 'Deny'", i+1), |
||||
|
}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if len(statement.Action) == 0 { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{ |
||||
|
"error": fmt.Sprintf("Statement %d: Action is required", i+1), |
||||
|
}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if len(statement.Resource) == 0 { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{ |
||||
|
"error": fmt.Sprintf("Statement %d: Resource is required", i+1), |
||||
|
}) |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
c.JSON(http.StatusOK, gin.H{ |
||||
|
"valid": true, |
||||
|
"message": "Policy document is valid", |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// getPoliciesData retrieves policies data from the server
|
||||
|
func (h *PolicyHandlers) getPoliciesData(c *gin.Context) dash.PoliciesData { |
||||
|
username := c.GetString("username") |
||||
|
if username == "" { |
||||
|
username = "admin" |
||||
|
} |
||||
|
|
||||
|
// Get policies
|
||||
|
policies, err := h.adminServer.GetPolicies() |
||||
|
if err != nil { |
||||
|
glog.Errorf("Failed to get policies: %v", err) |
||||
|
// Return empty data on error
|
||||
|
return dash.PoliciesData{ |
||||
|
Username: username, |
||||
|
Policies: []dash.IAMPolicy{}, |
||||
|
TotalPolicies: 0, |
||||
|
LastUpdated: time.Now(), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Ensure policies is never nil
|
||||
|
if policies == nil { |
||||
|
policies = []dash.IAMPolicy{} |
||||
|
} |
||||
|
|
||||
|
return dash.PoliciesData{ |
||||
|
Username: username, |
||||
|
Policies: policies, |
||||
|
TotalPolicies: len(policies), |
||||
|
LastUpdated: time.Now(), |
||||
|
} |
||||
|
} |
87
weed/admin/view/app/cluster_collections_templ.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
148
weed/admin/view/app/cluster_volume_servers_templ.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
96
weed/admin/view/app/file_browser_templ.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
weed/admin/view/app/object_store_users_templ.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,658 @@ |
|||||
|
package app |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/admin/dash" |
||||
|
) |
||||
|
|
||||
|
templ Policies(data dash.PoliciesData) { |
||||
|
<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-shield-alt me-2"></i>IAM Policies |
||||
|
</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="#createPolicyModal"> |
||||
|
<i class="fas fa-plus me-1"></i>Create Policy |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div id="policies-content"> |
||||
|
<!-- Summary Cards --> |
||||
|
<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 Policies |
||||
|
</div> |
||||
|
<div class="h5 mb-0 font-weight-bold text-gray-800"> |
||||
|
{fmt.Sprintf("%d", data.TotalPolicies)} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-auto"> |
||||
|
<i class="fas fa-shield-alt 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-success 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-success text-uppercase mb-1"> |
||||
|
Active Policies |
||||
|
</div> |
||||
|
<div class="h5 mb-0 font-weight-bold text-gray-800"> |
||||
|
{fmt.Sprintf("%d", data.TotalPolicies)} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-auto"> |
||||
|
<i class="fas fa-check-circle 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="h5 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> |
||||
|
|
||||
|
<!-- Policies Table --> |
||||
|
<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-shield-alt me-2"></i>IAM Policies |
||||
|
</h6> |
||||
|
<div class="dropdown no-arrow"> |
||||
|
<a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"> |
||||
|
<i class="fas fa-ellipsis-v fa-sm fa-fw text-gray-400"></i> |
||||
|
</a> |
||||
|
<div class="dropdown-menu dropdown-menu-right shadow animated--fade-in"> |
||||
|
<div class="dropdown-header">Actions:</div> |
||||
|
<a class="dropdown-item" href="#"> |
||||
|
<i class="fas fa-download me-2"></i>Export List |
||||
|
</a> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
<div class="table-responsive"> |
||||
|
<table class="table table-hover" width="100%" cellspacing="0"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>Policy Name</th> |
||||
|
<th>Version</th> |
||||
|
<th>Statements</th> |
||||
|
<th>Created</th> |
||||
|
<th>Updated</th> |
||||
|
<th>Actions</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
for _, policy := range data.Policies { |
||||
|
<tr> |
||||
|
<td> |
||||
|
<strong>{policy.Name}</strong> |
||||
|
</td> |
||||
|
<td> |
||||
|
<span class="badge bg-info">{policy.Document.Version}</span> |
||||
|
</td> |
||||
|
<td> |
||||
|
<span class="badge bg-secondary">{fmt.Sprintf("%d statements", len(policy.Document.Statement))}</span> |
||||
|
</td> |
||||
|
<td> |
||||
|
<small class="text-muted">{policy.CreatedAt.Format("2006-01-02 15:04")}</small> |
||||
|
</td> |
||||
|
<td> |
||||
|
<small class="text-muted">{policy.UpdatedAt.Format("2006-01-02 15:04")}</small> |
||||
|
</td> |
||||
|
<td> |
||||
|
<div class="btn-group btn-group-sm" role="group"> |
||||
|
<button type="button" class="btn btn-outline-info view-policy-btn" title="View Policy" data-policy-name={policy.Name}> |
||||
|
<i class="fas fa-eye"></i> |
||||
|
</button> |
||||
|
<button type="button" class="btn btn-outline-primary edit-policy-btn" title="Edit Policy" data-policy-name={policy.Name}> |
||||
|
<i class="fas fa-edit"></i> |
||||
|
</button> |
||||
|
<button type="button" class="btn btn-outline-danger delete-policy-btn" title="Delete Policy" data-policy-name={policy.Name}> |
||||
|
<i class="fas fa-trash"></i> |
||||
|
</button> |
||||
|
</div> |
||||
|
</td> |
||||
|
</tr> |
||||
|
} |
||||
|
if len(data.Policies) == 0 { |
||||
|
<tr> |
||||
|
<td colspan="6" class="text-center text-muted py-4"> |
||||
|
<i class="fas fa-shield-alt fa-3x mb-3 text-muted"></i> |
||||
|
<div> |
||||
|
<h5>No IAM policies found</h5> |
||||
|
<p>Create your first policy to manage access permissions.</p> |
||||
|
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createPolicyModal"> |
||||
|
<i class="fas fa-plus me-1"></i>Create Policy |
||||
|
</button> |
||||
|
</div> |
||||
|
</td> |
||||
|
</tr> |
||||
|
} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Create Policy Modal --> |
||||
|
<div class="modal fade" id="createPolicyModal" tabindex="-1" aria-labelledby="createPolicyModalLabel" aria-hidden="true"> |
||||
|
<div class="modal-dialog modal-lg"> |
||||
|
<div class="modal-content"> |
||||
|
<div class="modal-header"> |
||||
|
<h5 class="modal-title" id="createPolicyModalLabel"> |
||||
|
<i class="fas fa-shield-alt me-2"></i>Create IAM Policy |
||||
|
</h5> |
||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |
||||
|
</div> |
||||
|
<div class="modal-body"> |
||||
|
<form id="createPolicyForm"> |
||||
|
<div class="mb-3"> |
||||
|
<label for="policyName" class="form-label">Policy Name</label> |
||||
|
<input type="text" class="form-control" id="policyName" name="name" required placeholder="e.g., S3ReadOnlyPolicy"> |
||||
|
<div class="form-text">Enter a unique name for this policy (alphanumeric and underscores only)</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<label for="policyDocument" class="form-label">Policy Document</label> |
||||
|
<textarea class="form-control" id="policyDocument" name="document" rows="15" required placeholder="Enter IAM policy JSON document..."></textarea> |
||||
|
<div class="form-text">Enter the policy document in AWS IAM JSON format</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-6"> |
||||
|
<button type="button" class="btn btn-outline-info btn-sm" onclick="insertSamplePolicy()"> |
||||
|
<i class="fas fa-file-alt me-1"></i>Use Sample Policy |
||||
|
</button> |
||||
|
</div> |
||||
|
<div class="col-md-6 text-end"> |
||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="validatePolicyDocument()"> |
||||
|
<i class="fas fa-check me-1"></i>Validate JSON |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</form> |
||||
|
</div> |
||||
|
<div class="modal-footer"> |
||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> |
||||
|
<button type="button" class="btn btn-primary" onclick="createPolicy()"> |
||||
|
<i class="fas fa-plus me-1"></i>Create Policy |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- View Policy Modal --> |
||||
|
<div class="modal fade" id="viewPolicyModal" tabindex="-1" aria-labelledby="viewPolicyModalLabel" aria-hidden="true"> |
||||
|
<div class="modal-dialog modal-lg"> |
||||
|
<div class="modal-content"> |
||||
|
<div class="modal-header"> |
||||
|
<h5 class="modal-title" id="viewPolicyModalLabel"> |
||||
|
<i class="fas fa-eye me-2"></i>View IAM Policy |
||||
|
</h5> |
||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |
||||
|
</div> |
||||
|
<div class="modal-body"> |
||||
|
<div id="viewPolicyContent"> |
||||
|
<div class="text-center"> |
||||
|
<div class="spinner-border" role="status"> |
||||
|
<span class="visually-hidden">Loading...</span> |
||||
|
</div> |
||||
|
<p class="mt-2">Loading policy...</p> |
||||
|
</div> |
||||
|
</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-primary" id="editFromViewBtn"> |
||||
|
<i class="fas fa-edit me-1"></i>Edit Policy |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Edit Policy Modal --> |
||||
|
<div class="modal fade" id="editPolicyModal" tabindex="-1" aria-labelledby="editPolicyModalLabel" aria-hidden="true"> |
||||
|
<div class="modal-dialog modal-lg"> |
||||
|
<div class="modal-content"> |
||||
|
<div class="modal-header"> |
||||
|
<h5 class="modal-title" id="editPolicyModalLabel"> |
||||
|
<i class="fas fa-edit me-2"></i>Edit IAM Policy |
||||
|
</h5> |
||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |
||||
|
</div> |
||||
|
<div class="modal-body"> |
||||
|
<form id="editPolicyForm"> |
||||
|
<div class="mb-3"> |
||||
|
<label for="editPolicyName" class="form-label">Policy Name</label> |
||||
|
<input type="text" class="form-control" id="editPolicyName" name="name" readonly> |
||||
|
<div class="form-text">Policy name cannot be changed</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<label for="editPolicyDocument" class="form-label">Policy Document</label> |
||||
|
<textarea class="form-control" id="editPolicyDocument" name="document" rows="15" required></textarea> |
||||
|
<div class="form-text">Edit the policy document in AWS IAM JSON format</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-6"> |
||||
|
<button type="button" class="btn btn-outline-info btn-sm" onclick="insertSamplePolicyEdit()"> |
||||
|
<i class="fas fa-file-alt me-1"></i>Reset to Sample |
||||
|
</button> |
||||
|
</div> |
||||
|
<div class="col-md-6 text-end"> |
||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="validateEditPolicyDocument()"> |
||||
|
<i class="fas fa-check me-1"></i>Validate JSON |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</form> |
||||
|
</div> |
||||
|
<div class="modal-footer"> |
||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> |
||||
|
<button type="button" class="btn btn-primary" onclick="updatePolicy()"> |
||||
|
<i class="fas fa-save me-1"></i>Save Changes |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- JavaScript for Policy Management --> |
||||
|
<script> |
||||
|
// Current policy being viewed/edited |
||||
|
let currentPolicy = null; |
||||
|
|
||||
|
// Event listeners for policy actions |
||||
|
document.addEventListener('DOMContentLoaded', function() { |
||||
|
// View policy buttons |
||||
|
document.querySelectorAll('.view-policy-btn').forEach(button => { |
||||
|
button.addEventListener('click', function() { |
||||
|
const policyName = this.getAttribute('data-policy-name'); |
||||
|
viewPolicy(policyName); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
// Edit policy buttons |
||||
|
document.querySelectorAll('.edit-policy-btn').forEach(button => { |
||||
|
button.addEventListener('click', function() { |
||||
|
const policyName = this.getAttribute('data-policy-name'); |
||||
|
editPolicy(policyName); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
// Delete policy buttons |
||||
|
document.querySelectorAll('.delete-policy-btn').forEach(button => { |
||||
|
button.addEventListener('click', function() { |
||||
|
const policyName = this.getAttribute('data-policy-name'); |
||||
|
deletePolicy(policyName); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
// Edit from view button |
||||
|
document.getElementById('editFromViewBtn').addEventListener('click', function() { |
||||
|
if (currentPolicy) { |
||||
|
const viewModal = bootstrap.Modal.getInstance(document.getElementById('viewPolicyModal')); |
||||
|
if (viewModal) viewModal.hide(); |
||||
|
editPolicy(currentPolicy.name); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
function createPolicy() { |
||||
|
const form = document.getElementById('createPolicyForm'); |
||||
|
const formData = new FormData(form); |
||||
|
|
||||
|
const policyName = formData.get('name'); |
||||
|
const policyDocumentText = formData.get('document'); |
||||
|
|
||||
|
if (!policyName || !policyDocumentText) { |
||||
|
alert('Please fill in all required fields'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
let policyDocument; |
||||
|
try { |
||||
|
policyDocument = JSON.parse(policyDocumentText); |
||||
|
} catch (e) { |
||||
|
alert('Invalid JSON in policy document: ' + e.message); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const requestData = { |
||||
|
name: policyName, |
||||
|
document: policyDocument |
||||
|
}; |
||||
|
|
||||
|
fetch('/api/object-store/policies', { |
||||
|
method: 'POST', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
}, |
||||
|
body: JSON.stringify(requestData) |
||||
|
}) |
||||
|
.then(response => response.json()) |
||||
|
.then(data => { |
||||
|
if (data.success) { |
||||
|
alert('Policy created successfully!'); |
||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('createPolicyModal')); |
||||
|
if (modal) modal.hide(); |
||||
|
location.reload(); // Refresh the page to show the new policy |
||||
|
} else { |
||||
|
alert('Error creating policy: ' + (data.error || 'Unknown error')); |
||||
|
} |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
console.error('Error:', error); |
||||
|
alert('Error creating policy: ' + error.message); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function viewPolicy(policyName) { |
||||
|
// Show the modal first |
||||
|
const modal = new bootstrap.Modal(document.getElementById('viewPolicyModal')); |
||||
|
modal.show(); |
||||
|
|
||||
|
// Reset content to loading state |
||||
|
document.getElementById('viewPolicyContent').innerHTML = ` |
||||
|
<div class="text-center"> |
||||
|
<div class="spinner-border" role="status"> |
||||
|
<span class="visually-hidden">Loading...</span> |
||||
|
</div> |
||||
|
<p class="mt-2">Loading policy...</p> |
||||
|
</div> |
||||
|
`; |
||||
|
|
||||
|
// Fetch policy data |
||||
|
fetch('/api/object-store/policies/' + encodeURIComponent(policyName)) |
||||
|
.then(response => { |
||||
|
if (!response.ok) { |
||||
|
throw new Error('Policy not found'); |
||||
|
} |
||||
|
return response.json(); |
||||
|
}) |
||||
|
.then(policy => { |
||||
|
currentPolicy = policy; |
||||
|
displayPolicyDetails(policy); |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
console.error('Error:', error); |
||||
|
document.getElementById('viewPolicyContent').innerHTML = ` |
||||
|
<div class="alert alert-danger" role="alert"> |
||||
|
<i class="fas fa-exclamation-triangle me-2"></i> |
||||
|
Error loading policy: ${error.message} |
||||
|
</div> |
||||
|
`; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function displayPolicyDetails(policy) { |
||||
|
const content = document.getElementById('viewPolicyContent'); |
||||
|
|
||||
|
let statementsHtml = ''; |
||||
|
if (policy.document && policy.document.Statement) { |
||||
|
statementsHtml = policy.document.Statement.map((stmt, index) => ` |
||||
|
<div class="card mb-2"> |
||||
|
<div class="card-header py-2"> |
||||
|
<h6 class="mb-0">Statement ${index + 1}</h6> |
||||
|
</div> |
||||
|
<div class="card-body py-2"> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-6"> |
||||
|
<strong>Effect:</strong> |
||||
|
<span class="badge ${stmt.Effect === 'Allow' ? 'bg-success' : 'bg-danger'}">${stmt.Effect}</span> |
||||
|
</div> |
||||
|
<div class="col-md-6"> |
||||
|
<strong>Actions:</strong> ${Array.isArray(stmt.Action) ? stmt.Action.join(', ') : stmt.Action} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="row mt-2"> |
||||
|
<div class="col-12"> |
||||
|
<strong>Resources:</strong> ${Array.isArray(stmt.Resource) ? stmt.Resource.join(', ') : stmt.Resource} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
`).join(''); |
||||
|
} |
||||
|
|
||||
|
content.innerHTML = ` |
||||
|
<div class="row mb-3"> |
||||
|
<div class="col-md-6"> |
||||
|
<strong>Policy Name:</strong> ${policy.name || 'Unknown'} |
||||
|
</div> |
||||
|
<div class="col-md-6"> |
||||
|
<strong>Version:</strong> <span class="badge bg-info">${policy.document?.Version || 'Unknown'}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<strong>Statements:</strong> |
||||
|
<div class="mt-2"> |
||||
|
${statementsHtml || '<p class="text-muted">No statements found</p>'} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mb-3"> |
||||
|
<strong>Raw Policy Document:</strong> |
||||
|
<pre class="bg-light p-3 border rounded mt-2"><code>${JSON.stringify(policy.document, null, 2)}</code></pre> |
||||
|
</div> |
||||
|
`; |
||||
|
} |
||||
|
|
||||
|
function editPolicy(policyName) { |
||||
|
// Show the modal first |
||||
|
const modal = new bootstrap.Modal(document.getElementById('editPolicyModal')); |
||||
|
modal.show(); |
||||
|
|
||||
|
// Set policy name |
||||
|
document.getElementById('editPolicyName').value = policyName; |
||||
|
document.getElementById('editPolicyDocument').value = 'Loading...'; |
||||
|
|
||||
|
// Fetch policy data |
||||
|
fetch('/api/object-store/policies/' + encodeURIComponent(policyName)) |
||||
|
.then(response => { |
||||
|
if (!response.ok) { |
||||
|
throw new Error('Policy not found'); |
||||
|
} |
||||
|
return response.json(); |
||||
|
}) |
||||
|
.then(policy => { |
||||
|
currentPolicy = policy; |
||||
|
document.getElementById('editPolicyDocument').value = JSON.stringify(policy.document, null, 2); |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
console.error('Error:', error); |
||||
|
alert('Error loading policy for editing: ' + error.message); |
||||
|
const editModal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal')); |
||||
|
if (editModal) editModal.hide(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function updatePolicy() { |
||||
|
const policyName = document.getElementById('editPolicyName').value; |
||||
|
const policyDocumentText = document.getElementById('editPolicyDocument').value; |
||||
|
|
||||
|
if (!policyName || !policyDocumentText) { |
||||
|
alert('Please fill in all required fields'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
let policyDocument; |
||||
|
try { |
||||
|
policyDocument = JSON.parse(policyDocumentText); |
||||
|
} catch (e) { |
||||
|
alert('Invalid JSON in policy document: ' + e.message); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const requestData = { |
||||
|
document: policyDocument |
||||
|
}; |
||||
|
|
||||
|
fetch('/api/object-store/policies/' + encodeURIComponent(policyName), { |
||||
|
method: 'PUT', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
}, |
||||
|
body: JSON.stringify(requestData) |
||||
|
}) |
||||
|
.then(response => response.json()) |
||||
|
.then(data => { |
||||
|
if (data.success) { |
||||
|
alert('Policy updated successfully!'); |
||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal')); |
||||
|
if (modal) modal.hide(); |
||||
|
location.reload(); // Refresh the page to show the updated policy |
||||
|
} else { |
||||
|
alert('Error updating policy: ' + (data.error || 'Unknown error')); |
||||
|
} |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
console.error('Error:', error); |
||||
|
alert('Error updating policy: ' + error.message); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function insertSamplePolicy() { |
||||
|
const samplePolicy = { |
||||
|
"Version": "2012-10-17", |
||||
|
"Statement": [ |
||||
|
{ |
||||
|
"Effect": "Allow", |
||||
|
"Action": [ |
||||
|
"s3:GetObject", |
||||
|
"s3:PutObject" |
||||
|
], |
||||
|
"Resource": [ |
||||
|
"arn:aws:s3:::my-bucket/*" |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
}; |
||||
|
|
||||
|
document.getElementById('policyDocument').value = JSON.stringify(samplePolicy, null, 2); |
||||
|
} |
||||
|
|
||||
|
function insertSamplePolicyEdit() { |
||||
|
const samplePolicy = { |
||||
|
"Version": "2012-10-17", |
||||
|
"Statement": [ |
||||
|
{ |
||||
|
"Effect": "Allow", |
||||
|
"Action": [ |
||||
|
"s3:GetObject", |
||||
|
"s3:PutObject" |
||||
|
], |
||||
|
"Resource": [ |
||||
|
"arn:aws:s3:::my-bucket/*" |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
}; |
||||
|
|
||||
|
document.getElementById('editPolicyDocument').value = JSON.stringify(samplePolicy, null, 2); |
||||
|
} |
||||
|
|
||||
|
function validatePolicyDocument() { |
||||
|
const policyText = document.getElementById('policyDocument').value; |
||||
|
validatePolicyJSON(policyText); |
||||
|
} |
||||
|
|
||||
|
function validateEditPolicyDocument() { |
||||
|
const policyText = document.getElementById('editPolicyDocument').value; |
||||
|
validatePolicyJSON(policyText); |
||||
|
} |
||||
|
|
||||
|
function validatePolicyJSON(policyText) { |
||||
|
if (!policyText) { |
||||
|
alert('Please enter a policy document first'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const policy = JSON.parse(policyText); |
||||
|
|
||||
|
// Basic validation |
||||
|
if (!policy.Version) { |
||||
|
alert('Policy must have a Version field'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (!policy.Statement || !Array.isArray(policy.Statement)) { |
||||
|
alert('Policy must have a Statement array'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
alert('Policy document is valid JSON!'); |
||||
|
} catch (e) { |
||||
|
alert('Invalid JSON: ' + e.message); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function deletePolicy(policyName) { |
||||
|
if (confirm('Are you sure you want to delete policy "' + policyName + '"?')) { |
||||
|
fetch('/api/object-store/policies/' + encodeURIComponent(policyName), { |
||||
|
method: 'DELETE' |
||||
|
}) |
||||
|
.then(response => response.json()) |
||||
|
.then(data => { |
||||
|
if (data.success) { |
||||
|
alert('Policy deleted successfully!'); |
||||
|
location.reload(); // Refresh the page |
||||
|
} else { |
||||
|
alert('Error deleting policy: ' + (data.error || 'Unknown error')); |
||||
|
} |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
console.error('Error:', error); |
||||
|
alert('Error deleting policy: ' + error.message); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
} |
204
weed/admin/view/app/policies_templ.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
22
weed/admin/view/app/s3_buckets_templ.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,188 @@ |
|||||
|
package filer_etc |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/credential" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/filer" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" |
||||
|
) |
||||
|
|
||||
|
func (store *FilerEtcStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) { |
||||
|
s3cfg := &iam_pb.S3ApiConfiguration{} |
||||
|
|
||||
|
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error { |
||||
|
var buf bytes.Buffer |
||||
|
if err := filer.ReadEntry(nil, client, filer.IamConfigDirectory, filer.IamIdentityFile, &buf); err != nil { |
||||
|
if err != filer_pb.ErrNotFound { |
||||
|
return err |
||||
|
} |
||||
|
} |
||||
|
if buf.Len() > 0 { |
||||
|
return filer.ParseS3ConfigurationFromBytes(buf.Bytes(), s3cfg) |
||||
|
} |
||||
|
return nil |
||||
|
}) |
||||
|
|
||||
|
return s3cfg, err |
||||
|
} |
||||
|
|
||||
|
func (store *FilerEtcStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error { |
||||
|
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error { |
||||
|
var buf bytes.Buffer |
||||
|
if err := filer.ProtoToText(&buf, config); err != nil { |
||||
|
return fmt.Errorf("failed to marshal configuration: %v", err) |
||||
|
} |
||||
|
return filer.SaveInsideFiler(client, filer.IamConfigDirectory, filer.IamIdentityFile, buf.Bytes()) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
func (store *FilerEtcStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error { |
||||
|
// Load existing configuration
|
||||
|
config, err := store.LoadConfiguration(ctx) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to load configuration: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Check if user already exists
|
||||
|
for _, existingIdentity := range config.Identities { |
||||
|
if existingIdentity.Name == identity.Name { |
||||
|
return credential.ErrUserAlreadyExists |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Add new identity
|
||||
|
config.Identities = append(config.Identities, identity) |
||||
|
|
||||
|
// Save configuration
|
||||
|
return store.SaveConfiguration(ctx, config) |
||||
|
} |
||||
|
|
||||
|
func (store *FilerEtcStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) { |
||||
|
config, err := store.LoadConfiguration(ctx) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to load configuration: %v", err) |
||||
|
} |
||||
|
|
||||
|
for _, identity := range config.Identities { |
||||
|
if identity.Name == username { |
||||
|
return identity, nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return nil, credential.ErrUserNotFound |
||||
|
} |
||||
|
|
||||
|
func (store *FilerEtcStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error { |
||||
|
config, err := store.LoadConfiguration(ctx) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to load configuration: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Find and update the user
|
||||
|
for i, existingIdentity := range config.Identities { |
||||
|
if existingIdentity.Name == username { |
||||
|
config.Identities[i] = identity |
||||
|
return store.SaveConfiguration(ctx, config) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return credential.ErrUserNotFound |
||||
|
} |
||||
|
|
||||
|
func (store *FilerEtcStore) DeleteUser(ctx context.Context, username string) error { |
||||
|
config, err := store.LoadConfiguration(ctx) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to load configuration: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Find and remove the user
|
||||
|
for i, identity := range config.Identities { |
||||
|
if identity.Name == username { |
||||
|
config.Identities = append(config.Identities[:i], config.Identities[i+1:]...) |
||||
|
return store.SaveConfiguration(ctx, config) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return credential.ErrUserNotFound |
||||
|
} |
||||
|
|
||||
|
func (store *FilerEtcStore) ListUsers(ctx context.Context) ([]string, error) { |
||||
|
config, err := store.LoadConfiguration(ctx) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to load configuration: %v", err) |
||||
|
} |
||||
|
|
||||
|
var usernames []string |
||||
|
for _, identity := range config.Identities { |
||||
|
usernames = append(usernames, identity.Name) |
||||
|
} |
||||
|
|
||||
|
return usernames, nil |
||||
|
} |
||||
|
|
||||
|
func (store *FilerEtcStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) { |
||||
|
config, err := store.LoadConfiguration(ctx) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to load configuration: %v", err) |
||||
|
} |
||||
|
|
||||
|
for _, identity := range config.Identities { |
||||
|
for _, credential := range identity.Credentials { |
||||
|
if credential.AccessKey == accessKey { |
||||
|
return identity, nil |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return nil, credential.ErrAccessKeyNotFound |
||||
|
} |
||||
|
|
||||
|
func (store *FilerEtcStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error { |
||||
|
config, err := store.LoadConfiguration(ctx) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to load configuration: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Find the user and add the credential
|
||||
|
for _, identity := range config.Identities { |
||||
|
if identity.Name == username { |
||||
|
// Check if access key already exists
|
||||
|
for _, existingCred := range identity.Credentials { |
||||
|
if existingCred.AccessKey == cred.AccessKey { |
||||
|
return fmt.Errorf("access key %s already exists", cred.AccessKey) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
identity.Credentials = append(identity.Credentials, cred) |
||||
|
return store.SaveConfiguration(ctx, config) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return credential.ErrUserNotFound |
||||
|
} |
||||
|
|
||||
|
func (store *FilerEtcStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error { |
||||
|
config, err := store.LoadConfiguration(ctx) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to load configuration: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Find the user and remove the credential
|
||||
|
for _, identity := range config.Identities { |
||||
|
if identity.Name == username { |
||||
|
for i, cred := range identity.Credentials { |
||||
|
if cred.AccessKey == accessKey { |
||||
|
identity.Credentials = append(identity.Credentials[:i], identity.Credentials[i+1:]...) |
||||
|
return store.SaveConfiguration(ctx, config) |
||||
|
} |
||||
|
} |
||||
|
return credential.ErrAccessKeyNotFound |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return credential.ErrUserNotFound |
||||
|
} |
@ -0,0 +1,114 @@ |
|||||
|
package filer_etc |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"context" |
||||
|
"encoding/json" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/credential" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/filer" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/glog" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
||||
|
) |
||||
|
|
||||
|
type PoliciesCollection struct { |
||||
|
Policies map[string]credential.PolicyDocument `json:"policies"` |
||||
|
} |
||||
|
|
||||
|
// GetPolicies retrieves all IAM policies from the filer
|
||||
|
func (store *FilerEtcStore) GetPolicies(ctx context.Context) (map[string]credential.PolicyDocument, error) { |
||||
|
policiesCollection := &PoliciesCollection{ |
||||
|
Policies: make(map[string]credential.PolicyDocument), |
||||
|
} |
||||
|
|
||||
|
// Check if filer client is configured
|
||||
|
if store.filerGrpcAddress == "" { |
||||
|
glog.V(1).Infof("Filer client not configured for policy retrieval, returning empty policies") |
||||
|
// Return empty policies if filer client is not configured
|
||||
|
return policiesCollection.Policies, nil |
||||
|
} |
||||
|
|
||||
|
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error { |
||||
|
var buf bytes.Buffer |
||||
|
if err := filer.ReadEntry(nil, client, filer.IamConfigDirectory, filer.IamPoliciesFile, &buf); err != nil { |
||||
|
if err == filer_pb.ErrNotFound { |
||||
|
glog.V(1).Infof("Policies file not found at %s/%s, returning empty policies", filer.IamConfigDirectory, filer.IamPoliciesFile) |
||||
|
// If file doesn't exist, return empty collection
|
||||
|
return nil |
||||
|
} |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
if buf.Len() > 0 { |
||||
|
return json.Unmarshal(buf.Bytes(), policiesCollection) |
||||
|
} |
||||
|
return nil |
||||
|
}) |
||||
|
|
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return policiesCollection.Policies, nil |
||||
|
} |
||||
|
|
||||
|
// CreatePolicy creates a new IAM policy in the filer
|
||||
|
func (store *FilerEtcStore) CreatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error { |
||||
|
return store.updatePolicies(ctx, func(policies map[string]credential.PolicyDocument) { |
||||
|
policies[name] = document |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// UpdatePolicy updates an existing IAM policy in the filer
|
||||
|
func (store *FilerEtcStore) UpdatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error { |
||||
|
return store.updatePolicies(ctx, func(policies map[string]credential.PolicyDocument) { |
||||
|
policies[name] = document |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// DeletePolicy deletes an IAM policy from the filer
|
||||
|
func (store *FilerEtcStore) DeletePolicy(ctx context.Context, name string) error { |
||||
|
return store.updatePolicies(ctx, func(policies map[string]credential.PolicyDocument) { |
||||
|
delete(policies, name) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// updatePolicies is a helper method to update policies atomically
|
||||
|
func (store *FilerEtcStore) updatePolicies(ctx context.Context, updateFunc func(map[string]credential.PolicyDocument)) error { |
||||
|
// Load existing policies
|
||||
|
policies, err := store.GetPolicies(ctx) |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
// Apply update
|
||||
|
updateFunc(policies) |
||||
|
|
||||
|
// Save back to filer
|
||||
|
policiesCollection := &PoliciesCollection{ |
||||
|
Policies: policies, |
||||
|
} |
||||
|
|
||||
|
data, err := json.Marshal(policiesCollection) |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error { |
||||
|
return filer.SaveInsideFiler(client, filer.IamConfigDirectory, filer.IamPoliciesFile, data) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// GetPolicy retrieves a specific IAM policy by name from the filer
|
||||
|
func (store *FilerEtcStore) GetPolicy(ctx context.Context, name string) (*credential.PolicyDocument, error) { |
||||
|
policies, err := store.GetPolicies(ctx) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
if policy, exists := policies[name]; exists { |
||||
|
return &policy, nil |
||||
|
} |
||||
|
|
||||
|
return nil, nil // Policy not found
|
||||
|
} |
@ -0,0 +1,302 @@ |
|||||
|
package memory |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"encoding/json" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/credential" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" |
||||
|
) |
||||
|
|
||||
|
func (store *MemoryStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) { |
||||
|
store.mu.RLock() |
||||
|
defer store.mu.RUnlock() |
||||
|
|
||||
|
if !store.initialized { |
||||
|
return nil, fmt.Errorf("store not initialized") |
||||
|
} |
||||
|
|
||||
|
config := &iam_pb.S3ApiConfiguration{} |
||||
|
|
||||
|
// Convert all users to identities
|
||||
|
for _, user := range store.users { |
||||
|
// Deep copy the identity to avoid mutation issues
|
||||
|
identityCopy := store.deepCopyIdentity(user) |
||||
|
config.Identities = append(config.Identities, identityCopy) |
||||
|
} |
||||
|
|
||||
|
return config, nil |
||||
|
} |
||||
|
|
||||
|
func (store *MemoryStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error { |
||||
|
store.mu.Lock() |
||||
|
defer store.mu.Unlock() |
||||
|
|
||||
|
if !store.initialized { |
||||
|
return fmt.Errorf("store not initialized") |
||||
|
} |
||||
|
|
||||
|
// Clear existing data
|
||||
|
store.users = make(map[string]*iam_pb.Identity) |
||||
|
store.accessKeys = make(map[string]string) |
||||
|
|
||||
|
// Add all identities
|
||||
|
for _, identity := range config.Identities { |
||||
|
// Deep copy to avoid mutation issues
|
||||
|
identityCopy := store.deepCopyIdentity(identity) |
||||
|
store.users[identity.Name] = identityCopy |
||||
|
|
||||
|
// Index access keys
|
||||
|
for _, credential := range identity.Credentials { |
||||
|
store.accessKeys[credential.AccessKey] = identity.Name |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (store *MemoryStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error { |
||||
|
store.mu.Lock() |
||||
|
defer store.mu.Unlock() |
||||
|
|
||||
|
if !store.initialized { |
||||
|
return fmt.Errorf("store not initialized") |
||||
|
} |
||||
|
|
||||
|
if _, exists := store.users[identity.Name]; exists { |
||||
|
return credential.ErrUserAlreadyExists |
||||
|
} |
||||
|
|
||||
|
// Check for duplicate access keys
|
||||
|
for _, cred := range identity.Credentials { |
||||
|
if _, exists := store.accessKeys[cred.AccessKey]; exists { |
||||
|
return fmt.Errorf("access key %s already exists", cred.AccessKey) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Deep copy to avoid mutation issues
|
||||
|
identityCopy := store.deepCopyIdentity(identity) |
||||
|
store.users[identity.Name] = identityCopy |
||||
|
|
||||
|
// Index access keys
|
||||
|
for _, cred := range identity.Credentials { |
||||
|
store.accessKeys[cred.AccessKey] = identity.Name |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (store *MemoryStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) { |
||||
|
store.mu.RLock() |
||||
|
defer store.mu.RUnlock() |
||||
|
|
||||
|
if !store.initialized { |
||||
|
return nil, fmt.Errorf("store not initialized") |
||||
|
} |
||||
|
|
||||
|
user, exists := store.users[username] |
||||
|
if !exists { |
||||
|
return nil, credential.ErrUserNotFound |
||||
|
} |
||||
|
|
||||
|
// Return a deep copy to avoid mutation issues
|
||||
|
return store.deepCopyIdentity(user), nil |
||||
|
} |
||||
|
|
||||
|
func (store *MemoryStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error { |
||||
|
store.mu.Lock() |
||||
|
defer store.mu.Unlock() |
||||
|
|
||||
|
if !store.initialized { |
||||
|
return fmt.Errorf("store not initialized") |
||||
|
} |
||||
|
|
||||
|
existingUser, exists := store.users[username] |
||||
|
if !exists { |
||||
|
return credential.ErrUserNotFound |
||||
|
} |
||||
|
|
||||
|
// Remove old access keys from index
|
||||
|
for _, cred := range existingUser.Credentials { |
||||
|
delete(store.accessKeys, cred.AccessKey) |
||||
|
} |
||||
|
|
||||
|
// Check for duplicate access keys (excluding current user)
|
||||
|
for _, cred := range identity.Credentials { |
||||
|
if existingUsername, exists := store.accessKeys[cred.AccessKey]; exists && existingUsername != username { |
||||
|
return fmt.Errorf("access key %s already exists", cred.AccessKey) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Deep copy to avoid mutation issues
|
||||
|
identityCopy := store.deepCopyIdentity(identity) |
||||
|
store.users[username] = identityCopy |
||||
|
|
||||
|
// Re-index access keys
|
||||
|
for _, cred := range identity.Credentials { |
||||
|
store.accessKeys[cred.AccessKey] = username |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (store *MemoryStore) DeleteUser(ctx context.Context, username string) error { |
||||
|
store.mu.Lock() |
||||
|
defer store.mu.Unlock() |
||||
|
|
||||
|
if !store.initialized { |
||||
|
return fmt.Errorf("store not initialized") |
||||
|
} |
||||
|
|
||||
|
user, exists := store.users[username] |
||||
|
if !exists { |
||||
|
return credential.ErrUserNotFound |
||||
|
} |
||||
|
|
||||
|
// Remove access keys from index
|
||||
|
for _, cred := range user.Credentials { |
||||
|
delete(store.accessKeys, cred.AccessKey) |
||||
|
} |
||||
|
|
||||
|
// Remove user
|
||||
|
delete(store.users, username) |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (store *MemoryStore) ListUsers(ctx context.Context) ([]string, error) { |
||||
|
store.mu.RLock() |
||||
|
defer store.mu.RUnlock() |
||||
|
|
||||
|
if !store.initialized { |
||||
|
return nil, fmt.Errorf("store not initialized") |
||||
|
} |
||||
|
|
||||
|
var usernames []string |
||||
|
for username := range store.users { |
||||
|
usernames = append(usernames, username) |
||||
|
} |
||||
|
|
||||
|
return usernames, nil |
||||
|
} |
||||
|
|
||||
|
func (store *MemoryStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) { |
||||
|
store.mu.RLock() |
||||
|
defer store.mu.RUnlock() |
||||
|
|
||||
|
if !store.initialized { |
||||
|
return nil, fmt.Errorf("store not initialized") |
||||
|
} |
||||
|
|
||||
|
username, exists := store.accessKeys[accessKey] |
||||
|
if !exists { |
||||
|
return nil, credential.ErrAccessKeyNotFound |
||||
|
} |
||||
|
|
||||
|
user, exists := store.users[username] |
||||
|
if !exists { |
||||
|
// This should not happen, but handle it gracefully
|
||||
|
return nil, credential.ErrUserNotFound |
||||
|
} |
||||
|
|
||||
|
// Return a deep copy to avoid mutation issues
|
||||
|
return store.deepCopyIdentity(user), nil |
||||
|
} |
||||
|
|
||||
|
func (store *MemoryStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error { |
||||
|
store.mu.Lock() |
||||
|
defer store.mu.Unlock() |
||||
|
|
||||
|
if !store.initialized { |
||||
|
return fmt.Errorf("store not initialized") |
||||
|
} |
||||
|
|
||||
|
user, exists := store.users[username] |
||||
|
if !exists { |
||||
|
return credential.ErrUserNotFound |
||||
|
} |
||||
|
|
||||
|
// Check if access key already exists
|
||||
|
if _, exists := store.accessKeys[cred.AccessKey]; exists { |
||||
|
return fmt.Errorf("access key %s already exists", cred.AccessKey) |
||||
|
} |
||||
|
|
||||
|
// Add credential to user
|
||||
|
user.Credentials = append(user.Credentials, &iam_pb.Credential{ |
||||
|
AccessKey: cred.AccessKey, |
||||
|
SecretKey: cred.SecretKey, |
||||
|
}) |
||||
|
|
||||
|
// Index the access key
|
||||
|
store.accessKeys[cred.AccessKey] = username |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (store *MemoryStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error { |
||||
|
store.mu.Lock() |
||||
|
defer store.mu.Unlock() |
||||
|
|
||||
|
if !store.initialized { |
||||
|
return fmt.Errorf("store not initialized") |
||||
|
} |
||||
|
|
||||
|
user, exists := store.users[username] |
||||
|
if !exists { |
||||
|
return credential.ErrUserNotFound |
||||
|
} |
||||
|
|
||||
|
// Find and remove the credential
|
||||
|
var newCredentials []*iam_pb.Credential |
||||
|
found := false |
||||
|
for _, cred := range user.Credentials { |
||||
|
if cred.AccessKey == accessKey { |
||||
|
found = true |
||||
|
// Remove from access key index
|
||||
|
delete(store.accessKeys, accessKey) |
||||
|
} else { |
||||
|
newCredentials = append(newCredentials, cred) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if !found { |
||||
|
return credential.ErrAccessKeyNotFound |
||||
|
} |
||||
|
|
||||
|
user.Credentials = newCredentials |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// deepCopyIdentity creates a deep copy of an identity to avoid mutation issues
|
||||
|
func (store *MemoryStore) deepCopyIdentity(identity *iam_pb.Identity) *iam_pb.Identity { |
||||
|
if identity == nil { |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// Use JSON marshaling/unmarshaling for deep copy
|
||||
|
// This is simple and safe for protobuf messages
|
||||
|
data, err := json.Marshal(identity) |
||||
|
if err != nil { |
||||
|
// Fallback to shallow copy if JSON fails
|
||||
|
return &iam_pb.Identity{ |
||||
|
Name: identity.Name, |
||||
|
Account: identity.Account, |
||||
|
Credentials: identity.Credentials, |
||||
|
Actions: identity.Actions, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
var copy iam_pb.Identity |
||||
|
if err := json.Unmarshal(data, ©); err != nil { |
||||
|
// Fallback to shallow copy if JSON fails
|
||||
|
return &iam_pb.Identity{ |
||||
|
Name: identity.Name, |
||||
|
Account: identity.Account, |
||||
|
Credentials: identity.Credentials, |
||||
|
Actions: identity.Actions, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return © |
||||
|
} |
@ -0,0 +1,77 @@ |
|||||
|
package memory |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/credential" |
||||
|
) |
||||
|
|
||||
|
// GetPolicies retrieves all IAM policies from memory
|
||||
|
func (store *MemoryStore) GetPolicies(ctx context.Context) (map[string]credential.PolicyDocument, error) { |
||||
|
store.mu.RLock() |
||||
|
defer store.mu.RUnlock() |
||||
|
|
||||
|
if !store.initialized { |
||||
|
return nil, fmt.Errorf("store not initialized") |
||||
|
} |
||||
|
|
||||
|
// Create a copy of the policies map to avoid mutation issues
|
||||
|
policies := make(map[string]credential.PolicyDocument) |
||||
|
for name, doc := range store.policies { |
||||
|
policies[name] = doc |
||||
|
} |
||||
|
|
||||
|
return policies, nil |
||||
|
} |
||||
|
|
||||
|
// GetPolicy retrieves a specific IAM policy by name from memory
|
||||
|
func (store *MemoryStore) GetPolicy(ctx context.Context, name string) (*credential.PolicyDocument, error) { |
||||
|
store.mu.RLock() |
||||
|
defer store.mu.RUnlock() |
||||
|
|
||||
|
if policy, exists := store.policies[name]; exists { |
||||
|
return &policy, nil |
||||
|
} |
||||
|
|
||||
|
return nil, nil // Policy not found
|
||||
|
} |
||||
|
|
||||
|
// CreatePolicy creates a new IAM policy in memory
|
||||
|
func (store *MemoryStore) CreatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error { |
||||
|
store.mu.Lock() |
||||
|
defer store.mu.Unlock() |
||||
|
|
||||
|
if !store.initialized { |
||||
|
return fmt.Errorf("store not initialized") |
||||
|
} |
||||
|
|
||||
|
store.policies[name] = document |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// UpdatePolicy updates an existing IAM policy in memory
|
||||
|
func (store *MemoryStore) UpdatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error { |
||||
|
store.mu.Lock() |
||||
|
defer store.mu.Unlock() |
||||
|
|
||||
|
if !store.initialized { |
||||
|
return fmt.Errorf("store not initialized") |
||||
|
} |
||||
|
|
||||
|
store.policies[name] = document |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// DeletePolicy deletes an IAM policy from memory
|
||||
|
func (store *MemoryStore) DeletePolicy(ctx context.Context, name string) error { |
||||
|
store.mu.Lock() |
||||
|
defer store.mu.Unlock() |
||||
|
|
||||
|
if !store.initialized { |
||||
|
return fmt.Errorf("store not initialized") |
||||
|
} |
||||
|
|
||||
|
delete(store.policies, name) |
||||
|
return nil |
||||
|
} |
@ -0,0 +1,446 @@ |
|||||
|
package postgres |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"database/sql" |
||||
|
"encoding/json" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/credential" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" |
||||
|
) |
||||
|
|
||||
|
func (store *PostgresStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) { |
||||
|
if !store.configured { |
||||
|
return nil, fmt.Errorf("store not configured") |
||||
|
} |
||||
|
|
||||
|
config := &iam_pb.S3ApiConfiguration{} |
||||
|
|
||||
|
// Query all users
|
||||
|
rows, err := store.db.QueryContext(ctx, "SELECT username, email, account_data, actions FROM users") |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to query users: %v", err) |
||||
|
} |
||||
|
defer rows.Close() |
||||
|
|
||||
|
for rows.Next() { |
||||
|
var username, email string |
||||
|
var accountDataJSON, actionsJSON []byte |
||||
|
|
||||
|
if err := rows.Scan(&username, &email, &accountDataJSON, &actionsJSON); err != nil { |
||||
|
return nil, fmt.Errorf("failed to scan user row: %v", err) |
||||
|
} |
||||
|
|
||||
|
identity := &iam_pb.Identity{ |
||||
|
Name: username, |
||||
|
} |
||||
|
|
||||
|
// Parse account data
|
||||
|
if len(accountDataJSON) > 0 { |
||||
|
if err := json.Unmarshal(accountDataJSON, &identity.Account); err != nil { |
||||
|
return nil, fmt.Errorf("failed to unmarshal account data for user %s: %v", username, err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Parse actions
|
||||
|
if len(actionsJSON) > 0 { |
||||
|
if err := json.Unmarshal(actionsJSON, &identity.Actions); err != nil { |
||||
|
return nil, fmt.Errorf("failed to unmarshal actions for user %s: %v", username, err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Query credentials for this user
|
||||
|
credRows, err := store.db.QueryContext(ctx, "SELECT access_key, secret_key FROM credentials WHERE username = $1", username) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to query credentials for user %s: %v", username, err) |
||||
|
} |
||||
|
|
||||
|
for credRows.Next() { |
||||
|
var accessKey, secretKey string |
||||
|
if err := credRows.Scan(&accessKey, &secretKey); err != nil { |
||||
|
credRows.Close() |
||||
|
return nil, fmt.Errorf("failed to scan credential row for user %s: %v", username, err) |
||||
|
} |
||||
|
|
||||
|
identity.Credentials = append(identity.Credentials, &iam_pb.Credential{ |
||||
|
AccessKey: accessKey, |
||||
|
SecretKey: secretKey, |
||||
|
}) |
||||
|
} |
||||
|
credRows.Close() |
||||
|
|
||||
|
config.Identities = append(config.Identities, identity) |
||||
|
} |
||||
|
|
||||
|
return config, nil |
||||
|
} |
||||
|
|
||||
|
func (store *PostgresStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error { |
||||
|
if !store.configured { |
||||
|
return fmt.Errorf("store not configured") |
||||
|
} |
||||
|
|
||||
|
// Start transaction
|
||||
|
tx, err := store.db.BeginTx(ctx, nil) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to begin transaction: %v", err) |
||||
|
} |
||||
|
defer tx.Rollback() |
||||
|
|
||||
|
// Clear existing data
|
||||
|
if _, err := tx.ExecContext(ctx, "DELETE FROM credentials"); err != nil { |
||||
|
return fmt.Errorf("failed to clear credentials: %v", err) |
||||
|
} |
||||
|
if _, err := tx.ExecContext(ctx, "DELETE FROM users"); err != nil { |
||||
|
return fmt.Errorf("failed to clear users: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Insert all identities
|
||||
|
for _, identity := range config.Identities { |
||||
|
// Marshal account data
|
||||
|
var accountDataJSON []byte |
||||
|
if identity.Account != nil { |
||||
|
accountDataJSON, err = json.Marshal(identity.Account) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to marshal account data for user %s: %v", identity.Name, err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Marshal actions
|
||||
|
var actionsJSON []byte |
||||
|
if identity.Actions != nil { |
||||
|
actionsJSON, err = json.Marshal(identity.Actions) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to marshal actions for user %s: %v", identity.Name, err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Insert user
|
||||
|
_, err := tx.ExecContext(ctx, |
||||
|
"INSERT INTO users (username, email, account_data, actions) VALUES ($1, $2, $3, $4)", |
||||
|
identity.Name, "", accountDataJSON, actionsJSON) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to insert user %s: %v", identity.Name, err) |
||||
|
} |
||||
|
|
||||
|
// Insert credentials
|
||||
|
for _, cred := range identity.Credentials { |
||||
|
_, err := tx.ExecContext(ctx, |
||||
|
"INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)", |
||||
|
identity.Name, cred.AccessKey, cred.SecretKey) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to insert credential for user %s: %v", identity.Name, err) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return tx.Commit() |
||||
|
} |
||||
|
|
||||
|
func (store *PostgresStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error { |
||||
|
if !store.configured { |
||||
|
return fmt.Errorf("store not configured") |
||||
|
} |
||||
|
|
||||
|
// Check if user already exists
|
||||
|
var count int |
||||
|
err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", identity.Name).Scan(&count) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to check user existence: %v", err) |
||||
|
} |
||||
|
if count > 0 { |
||||
|
return credential.ErrUserAlreadyExists |
||||
|
} |
||||
|
|
||||
|
// Start transaction
|
||||
|
tx, err := store.db.BeginTx(ctx, nil) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to begin transaction: %v", err) |
||||
|
} |
||||
|
defer tx.Rollback() |
||||
|
|
||||
|
// Marshal account data
|
||||
|
var accountDataJSON []byte |
||||
|
if identity.Account != nil { |
||||
|
accountDataJSON, err = json.Marshal(identity.Account) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to marshal account data: %v", err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Marshal actions
|
||||
|
var actionsJSON []byte |
||||
|
if identity.Actions != nil { |
||||
|
actionsJSON, err = json.Marshal(identity.Actions) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to marshal actions: %v", err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Insert user
|
||||
|
_, err = tx.ExecContext(ctx, |
||||
|
"INSERT INTO users (username, email, account_data, actions) VALUES ($1, $2, $3, $4)", |
||||
|
identity.Name, "", accountDataJSON, actionsJSON) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to insert user: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Insert credentials
|
||||
|
for _, cred := range identity.Credentials { |
||||
|
_, err = tx.ExecContext(ctx, |
||||
|
"INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)", |
||||
|
identity.Name, cred.AccessKey, cred.SecretKey) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to insert credential: %v", err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return tx.Commit() |
||||
|
} |
||||
|
|
||||
|
func (store *PostgresStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) { |
||||
|
if !store.configured { |
||||
|
return nil, fmt.Errorf("store not configured") |
||||
|
} |
||||
|
|
||||
|
var email string |
||||
|
var accountDataJSON, actionsJSON []byte |
||||
|
|
||||
|
err := store.db.QueryRowContext(ctx, |
||||
|
"SELECT email, account_data, actions FROM users WHERE username = $1", |
||||
|
username).Scan(&email, &accountDataJSON, &actionsJSON) |
||||
|
if err != nil { |
||||
|
if err == sql.ErrNoRows { |
||||
|
return nil, credential.ErrUserNotFound |
||||
|
} |
||||
|
return nil, fmt.Errorf("failed to query user: %v", err) |
||||
|
} |
||||
|
|
||||
|
identity := &iam_pb.Identity{ |
||||
|
Name: username, |
||||
|
} |
||||
|
|
||||
|
// Parse account data
|
||||
|
if len(accountDataJSON) > 0 { |
||||
|
if err := json.Unmarshal(accountDataJSON, &identity.Account); err != nil { |
||||
|
return nil, fmt.Errorf("failed to unmarshal account data: %v", err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Parse actions
|
||||
|
if len(actionsJSON) > 0 { |
||||
|
if err := json.Unmarshal(actionsJSON, &identity.Actions); err != nil { |
||||
|
return nil, fmt.Errorf("failed to unmarshal actions: %v", err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Query credentials
|
||||
|
rows, err := store.db.QueryContext(ctx, "SELECT access_key, secret_key FROM credentials WHERE username = $1", username) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to query credentials: %v", err) |
||||
|
} |
||||
|
defer rows.Close() |
||||
|
|
||||
|
for rows.Next() { |
||||
|
var accessKey, secretKey string |
||||
|
if err := rows.Scan(&accessKey, &secretKey); err != nil { |
||||
|
return nil, fmt.Errorf("failed to scan credential: %v", err) |
||||
|
} |
||||
|
|
||||
|
identity.Credentials = append(identity.Credentials, &iam_pb.Credential{ |
||||
|
AccessKey: accessKey, |
||||
|
SecretKey: secretKey, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
return identity, nil |
||||
|
} |
||||
|
|
||||
|
func (store *PostgresStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error { |
||||
|
if !store.configured { |
||||
|
return fmt.Errorf("store not configured") |
||||
|
} |
||||
|
|
||||
|
// Start transaction
|
||||
|
tx, err := store.db.BeginTx(ctx, nil) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to begin transaction: %v", err) |
||||
|
} |
||||
|
defer tx.Rollback() |
||||
|
|
||||
|
// Check if user exists
|
||||
|
var count int |
||||
|
err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to check user existence: %v", err) |
||||
|
} |
||||
|
if count == 0 { |
||||
|
return credential.ErrUserNotFound |
||||
|
} |
||||
|
|
||||
|
// Marshal account data
|
||||
|
var accountDataJSON []byte |
||||
|
if identity.Account != nil { |
||||
|
accountDataJSON, err = json.Marshal(identity.Account) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to marshal account data: %v", err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Marshal actions
|
||||
|
var actionsJSON []byte |
||||
|
if identity.Actions != nil { |
||||
|
actionsJSON, err = json.Marshal(identity.Actions) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to marshal actions: %v", err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Update user
|
||||
|
_, err = tx.ExecContext(ctx, |
||||
|
"UPDATE users SET email = $2, account_data = $3, actions = $4, updated_at = CURRENT_TIMESTAMP WHERE username = $1", |
||||
|
username, "", accountDataJSON, actionsJSON) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to update user: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Delete existing credentials
|
||||
|
_, err = tx.ExecContext(ctx, "DELETE FROM credentials WHERE username = $1", username) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to delete existing credentials: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Insert new credentials
|
||||
|
for _, cred := range identity.Credentials { |
||||
|
_, err = tx.ExecContext(ctx, |
||||
|
"INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)", |
||||
|
username, cred.AccessKey, cred.SecretKey) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to insert credential: %v", err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return tx.Commit() |
||||
|
} |
||||
|
|
||||
|
func (store *PostgresStore) DeleteUser(ctx context.Context, username string) error { |
||||
|
if !store.configured { |
||||
|
return fmt.Errorf("store not configured") |
||||
|
} |
||||
|
|
||||
|
result, err := store.db.ExecContext(ctx, "DELETE FROM users WHERE username = $1", username) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to delete user: %v", err) |
||||
|
} |
||||
|
|
||||
|
rowsAffected, err := result.RowsAffected() |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to get rows affected: %v", err) |
||||
|
} |
||||
|
|
||||
|
if rowsAffected == 0 { |
||||
|
return credential.ErrUserNotFound |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (store *PostgresStore) ListUsers(ctx context.Context) ([]string, error) { |
||||
|
if !store.configured { |
||||
|
return nil, fmt.Errorf("store not configured") |
||||
|
} |
||||
|
|
||||
|
rows, err := store.db.QueryContext(ctx, "SELECT username FROM users ORDER BY username") |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to query users: %v", err) |
||||
|
} |
||||
|
defer rows.Close() |
||||
|
|
||||
|
var usernames []string |
||||
|
for rows.Next() { |
||||
|
var username string |
||||
|
if err := rows.Scan(&username); err != nil { |
||||
|
return nil, fmt.Errorf("failed to scan username: %v", err) |
||||
|
} |
||||
|
usernames = append(usernames, username) |
||||
|
} |
||||
|
|
||||
|
return usernames, nil |
||||
|
} |
||||
|
|
||||
|
func (store *PostgresStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) { |
||||
|
if !store.configured { |
||||
|
return nil, fmt.Errorf("store not configured") |
||||
|
} |
||||
|
|
||||
|
var username string |
||||
|
err := store.db.QueryRowContext(ctx, "SELECT username FROM credentials WHERE access_key = $1", accessKey).Scan(&username) |
||||
|
if err != nil { |
||||
|
if err == sql.ErrNoRows { |
||||
|
return nil, credential.ErrAccessKeyNotFound |
||||
|
} |
||||
|
return nil, fmt.Errorf("failed to query access key: %v", err) |
||||
|
} |
||||
|
|
||||
|
return store.GetUser(ctx, username) |
||||
|
} |
||||
|
|
||||
|
func (store *PostgresStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error { |
||||
|
if !store.configured { |
||||
|
return fmt.Errorf("store not configured") |
||||
|
} |
||||
|
|
||||
|
// Check if user exists
|
||||
|
var count int |
||||
|
err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to check user existence: %v", err) |
||||
|
} |
||||
|
if count == 0 { |
||||
|
return credential.ErrUserNotFound |
||||
|
} |
||||
|
|
||||
|
// Insert credential
|
||||
|
_, err = store.db.ExecContext(ctx, |
||||
|
"INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)", |
||||
|
username, cred.AccessKey, cred.SecretKey) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to insert credential: %v", err) |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (store *PostgresStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error { |
||||
|
if !store.configured { |
||||
|
return fmt.Errorf("store not configured") |
||||
|
} |
||||
|
|
||||
|
result, err := store.db.ExecContext(ctx, |
||||
|
"DELETE FROM credentials WHERE username = $1 AND access_key = $2", |
||||
|
username, accessKey) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to delete access key: %v", err) |
||||
|
} |
||||
|
|
||||
|
rowsAffected, err := result.RowsAffected() |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to get rows affected: %v", err) |
||||
|
} |
||||
|
|
||||
|
if rowsAffected == 0 { |
||||
|
// Check if user exists
|
||||
|
var count int |
||||
|
err = store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to check user existence: %v", err) |
||||
|
} |
||||
|
if count == 0 { |
||||
|
return credential.ErrUserNotFound |
||||
|
} |
||||
|
return credential.ErrAccessKeyNotFound |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
@ -0,0 +1,130 @@ |
|||||
|
package postgres |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"encoding/json" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/credential" |
||||
|
) |
||||
|
|
||||
|
// GetPolicies retrieves all IAM policies from PostgreSQL
|
||||
|
func (store *PostgresStore) GetPolicies(ctx context.Context) (map[string]credential.PolicyDocument, error) { |
||||
|
if !store.configured { |
||||
|
return nil, fmt.Errorf("store not configured") |
||||
|
} |
||||
|
|
||||
|
policies := make(map[string]credential.PolicyDocument) |
||||
|
|
||||
|
rows, err := store.db.QueryContext(ctx, "SELECT name, document FROM policies") |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to query policies: %v", err) |
||||
|
} |
||||
|
defer rows.Close() |
||||
|
|
||||
|
for rows.Next() { |
||||
|
var name string |
||||
|
var documentJSON []byte |
||||
|
|
||||
|
if err := rows.Scan(&name, &documentJSON); err != nil { |
||||
|
return nil, fmt.Errorf("failed to scan policy row: %v", err) |
||||
|
} |
||||
|
|
||||
|
var document credential.PolicyDocument |
||||
|
if err := json.Unmarshal(documentJSON, &document); err != nil { |
||||
|
return nil, fmt.Errorf("failed to unmarshal policy document for %s: %v", name, err) |
||||
|
} |
||||
|
|
||||
|
policies[name] = document |
||||
|
} |
||||
|
|
||||
|
return policies, nil |
||||
|
} |
||||
|
|
||||
|
// CreatePolicy creates a new IAM policy in PostgreSQL
|
||||
|
func (store *PostgresStore) CreatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error { |
||||
|
if !store.configured { |
||||
|
return fmt.Errorf("store not configured") |
||||
|
} |
||||
|
|
||||
|
documentJSON, err := json.Marshal(document) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to marshal policy document: %v", err) |
||||
|
} |
||||
|
|
||||
|
_, err = store.db.ExecContext(ctx, |
||||
|
"INSERT INTO policies (name, document) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET document = $2, updated_at = CURRENT_TIMESTAMP", |
||||
|
name, documentJSON) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to insert policy: %v", err) |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// UpdatePolicy updates an existing IAM policy in PostgreSQL
|
||||
|
func (store *PostgresStore) UpdatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error { |
||||
|
if !store.configured { |
||||
|
return fmt.Errorf("store not configured") |
||||
|
} |
||||
|
|
||||
|
documentJSON, err := json.Marshal(document) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to marshal policy document: %v", err) |
||||
|
} |
||||
|
|
||||
|
result, err := store.db.ExecContext(ctx, |
||||
|
"UPDATE policies SET document = $2, updated_at = CURRENT_TIMESTAMP WHERE name = $1", |
||||
|
name, documentJSON) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to update policy: %v", err) |
||||
|
} |
||||
|
|
||||
|
rowsAffected, err := result.RowsAffected() |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to get rows affected: %v", err) |
||||
|
} |
||||
|
|
||||
|
if rowsAffected == 0 { |
||||
|
return fmt.Errorf("policy %s not found", name) |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// DeletePolicy deletes an IAM policy from PostgreSQL
|
||||
|
func (store *PostgresStore) DeletePolicy(ctx context.Context, name string) error { |
||||
|
if !store.configured { |
||||
|
return fmt.Errorf("store not configured") |
||||
|
} |
||||
|
|
||||
|
result, err := store.db.ExecContext(ctx, "DELETE FROM policies WHERE name = $1", name) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to delete policy: %v", err) |
||||
|
} |
||||
|
|
||||
|
rowsAffected, err := result.RowsAffected() |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to get rows affected: %v", err) |
||||
|
} |
||||
|
|
||||
|
if rowsAffected == 0 { |
||||
|
return fmt.Errorf("policy %s not found", name) |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// GetPolicy retrieves a specific IAM policy by name from PostgreSQL
|
||||
|
func (store *PostgresStore) GetPolicy(ctx context.Context, name string) (*credential.PolicyDocument, error) { |
||||
|
policies, err := store.GetPolicies(ctx) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
if policy, exists := policies[name]; exists { |
||||
|
return &policy, nil |
||||
|
} |
||||
|
|
||||
|
return nil, nil // Policy not found
|
||||
|
} |
@ -0,0 +1,146 @@ |
|||||
|
package test |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"testing" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/credential" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/credential/memory" |
||||
|
|
||||
|
// Import all store implementations to register them
|
||||
|
_ "github.com/seaweedfs/seaweedfs/weed/credential/filer_etc" |
||||
|
_ "github.com/seaweedfs/seaweedfs/weed/credential/memory" |
||||
|
_ "github.com/seaweedfs/seaweedfs/weed/credential/postgres" |
||||
|
) |
||||
|
|
||||
|
// TestPolicyManagement tests policy management across all credential stores
|
||||
|
func TestPolicyManagement(t *testing.T) { |
||||
|
ctx := context.Background() |
||||
|
|
||||
|
// Test with memory store (easiest to test)
|
||||
|
credentialManager, err := credential.NewCredentialManager(credential.StoreTypeMemory, nil, "") |
||||
|
if err != nil { |
||||
|
t.Fatalf("Failed to create credential manager: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Test policy operations
|
||||
|
testPolicyOperations(t, ctx, credentialManager) |
||||
|
} |
||||
|
|
||||
|
func testPolicyOperations(t *testing.T, ctx context.Context, credentialManager *credential.CredentialManager) { |
||||
|
store := credentialManager.GetStore() |
||||
|
|
||||
|
// Cast to memory store to access policy methods
|
||||
|
memoryStore, ok := store.(*memory.MemoryStore) |
||||
|
if !ok { |
||||
|
t.Skip("Store is not a memory store") |
||||
|
} |
||||
|
|
||||
|
// Test GetPolicies (should be empty initially)
|
||||
|
policies, err := memoryStore.GetPolicies(ctx) |
||||
|
if err != nil { |
||||
|
t.Fatalf("Failed to get policies: %v", err) |
||||
|
} |
||||
|
if len(policies) != 0 { |
||||
|
t.Errorf("Expected 0 policies, got %d", len(policies)) |
||||
|
} |
||||
|
|
||||
|
// Test CreatePolicy
|
||||
|
testPolicy := credential.PolicyDocument{ |
||||
|
Version: "2012-10-17", |
||||
|
Statement: []*credential.PolicyStatement{ |
||||
|
{ |
||||
|
Effect: "Allow", |
||||
|
Action: []string{"s3:GetObject"}, |
||||
|
Resource: []string{"arn:aws:s3:::test-bucket/*"}, |
||||
|
}, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
err = memoryStore.CreatePolicy(ctx, "test-policy", testPolicy) |
||||
|
if err != nil { |
||||
|
t.Fatalf("Failed to create policy: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Test GetPolicies (should have 1 policy now)
|
||||
|
policies, err = memoryStore.GetPolicies(ctx) |
||||
|
if err != nil { |
||||
|
t.Fatalf("Failed to get policies: %v", err) |
||||
|
} |
||||
|
if len(policies) != 1 { |
||||
|
t.Errorf("Expected 1 policy, got %d", len(policies)) |
||||
|
} |
||||
|
|
||||
|
// Verify policy content
|
||||
|
policy, exists := policies["test-policy"] |
||||
|
if !exists { |
||||
|
t.Error("test-policy not found") |
||||
|
} |
||||
|
if policy.Version != "2012-10-17" { |
||||
|
t.Errorf("Expected policy version '2012-10-17', got '%s'", policy.Version) |
||||
|
} |
||||
|
if len(policy.Statement) != 1 { |
||||
|
t.Errorf("Expected 1 statement, got %d", len(policy.Statement)) |
||||
|
} |
||||
|
|
||||
|
// Test UpdatePolicy
|
||||
|
updatedPolicy := credential.PolicyDocument{ |
||||
|
Version: "2012-10-17", |
||||
|
Statement: []*credential.PolicyStatement{ |
||||
|
{ |
||||
|
Effect: "Allow", |
||||
|
Action: []string{"s3:GetObject", "s3:PutObject"}, |
||||
|
Resource: []string{"arn:aws:s3:::test-bucket/*"}, |
||||
|
}, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
err = memoryStore.UpdatePolicy(ctx, "test-policy", updatedPolicy) |
||||
|
if err != nil { |
||||
|
t.Fatalf("Failed to update policy: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Verify the update
|
||||
|
policies, err = memoryStore.GetPolicies(ctx) |
||||
|
if err != nil { |
||||
|
t.Fatalf("Failed to get policies after update: %v", err) |
||||
|
} |
||||
|
|
||||
|
updatedPolicyResult, exists := policies["test-policy"] |
||||
|
if !exists { |
||||
|
t.Error("test-policy not found after update") |
||||
|
} |
||||
|
if len(updatedPolicyResult.Statement) != 1 { |
||||
|
t.Errorf("Expected 1 statement after update, got %d", len(updatedPolicyResult.Statement)) |
||||
|
} |
||||
|
if len(updatedPolicyResult.Statement[0].Action) != 2 { |
||||
|
t.Errorf("Expected 2 actions after update, got %d", len(updatedPolicyResult.Statement[0].Action)) |
||||
|
} |
||||
|
|
||||
|
// Test DeletePolicy
|
||||
|
err = memoryStore.DeletePolicy(ctx, "test-policy") |
||||
|
if err != nil { |
||||
|
t.Fatalf("Failed to delete policy: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Verify deletion
|
||||
|
policies, err = memoryStore.GetPolicies(ctx) |
||||
|
if err != nil { |
||||
|
t.Fatalf("Failed to get policies after deletion: %v", err) |
||||
|
} |
||||
|
if len(policies) != 0 { |
||||
|
t.Errorf("Expected 0 policies after deletion, got %d", len(policies)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// TestPolicyManagementWithFilerEtc tests policy management with filer_etc store
|
||||
|
func TestPolicyManagementWithFilerEtc(t *testing.T) { |
||||
|
// Skip this test if we can't connect to a filer
|
||||
|
t.Skip("Filer connection required for filer_etc store testing") |
||||
|
} |
||||
|
|
||||
|
// TestPolicyManagementWithPostgres tests policy management with postgres store
|
||||
|
func TestPolicyManagementWithPostgres(t *testing.T) { |
||||
|
// Skip this test if we can't connect to PostgreSQL
|
||||
|
t.Skip("PostgreSQL connection required for postgres store testing") |
||||
|
} |
@ -1,369 +0,0 @@ |
|||||
package balance |
|
||||
|
|
||||
import ( |
|
||||
"fmt" |
|
||||
"strconv" |
|
||||
"time" |
|
||||
|
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/view/components" |
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog" |
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types" |
|
||||
) |
|
||||
|
|
||||
// Helper function to format seconds as duration string
|
|
||||
func formatDurationFromSeconds(seconds int) string { |
|
||||
d := time.Duration(seconds) * time.Second |
|
||||
return d.String() |
|
||||
} |
|
||||
|
|
||||
// Helper functions to convert between seconds and value+unit format
|
|
||||
func secondsToValueAndUnit(seconds int) (float64, string) { |
|
||||
if seconds == 0 { |
|
||||
return 0, "minutes" |
|
||||
} |
|
||||
|
|
||||
// Try days first
|
|
||||
if seconds%(24*3600) == 0 && seconds >= 24*3600 { |
|
||||
return float64(seconds / (24 * 3600)), "days" |
|
||||
} |
|
||||
|
|
||||
// Try hours
|
|
||||
if seconds%3600 == 0 && seconds >= 3600 { |
|
||||
return float64(seconds / 3600), "hours" |
|
||||
} |
|
||||
|
|
||||
// Default to minutes
|
|
||||
return float64(seconds / 60), "minutes" |
|
||||
} |
|
||||
|
|
||||
func valueAndUnitToSeconds(value float64, unit string) int { |
|
||||
switch unit { |
|
||||
case "days": |
|
||||
return int(value * 24 * 3600) |
|
||||
case "hours": |
|
||||
return int(value * 3600) |
|
||||
case "minutes": |
|
||||
return int(value * 60) |
|
||||
default: |
|
||||
return int(value * 60) // Default to minutes
|
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// UITemplProvider provides the templ-based UI for balance task configuration
|
|
||||
type UITemplProvider struct { |
|
||||
detector *BalanceDetector |
|
||||
scheduler *BalanceScheduler |
|
||||
} |
|
||||
|
|
||||
// NewUITemplProvider creates a new balance templ UI provider
|
|
||||
func NewUITemplProvider(detector *BalanceDetector, scheduler *BalanceScheduler) *UITemplProvider { |
|
||||
return &UITemplProvider{ |
|
||||
detector: detector, |
|
||||
scheduler: scheduler, |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// GetTaskType returns the task type
|
|
||||
func (ui *UITemplProvider) GetTaskType() types.TaskType { |
|
||||
return types.TaskTypeBalance |
|
||||
} |
|
||||
|
|
||||
// GetDisplayName returns the human-readable name
|
|
||||
func (ui *UITemplProvider) GetDisplayName() string { |
|
||||
return "Volume Balance" |
|
||||
} |
|
||||
|
|
||||
// GetDescription returns a description of what this task does
|
|
||||
func (ui *UITemplProvider) GetDescription() string { |
|
||||
return "Redistributes volumes across volume servers to optimize storage utilization and performance" |
|
||||
} |
|
||||
|
|
||||
// GetIcon returns the icon CSS class for this task type
|
|
||||
func (ui *UITemplProvider) GetIcon() string { |
|
||||
return "fas fa-balance-scale text-secondary" |
|
||||
} |
|
||||
|
|
||||
// RenderConfigSections renders the configuration as templ section data
|
|
||||
func (ui *UITemplProvider) RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) { |
|
||||
config := ui.getCurrentBalanceConfig() |
|
||||
|
|
||||
// Detection settings section
|
|
||||
detectionSection := components.ConfigSectionData{ |
|
||||
Title: "Detection Settings", |
|
||||
Icon: "fas fa-search", |
|
||||
Description: "Configure when balance tasks should be triggered", |
|
||||
Fields: []interface{}{ |
|
||||
components.CheckboxFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "enabled", |
|
||||
Label: "Enable Balance Tasks", |
|
||||
Description: "Whether balance tasks should be automatically created", |
|
||||
}, |
|
||||
Checked: config.Enabled, |
|
||||
}, |
|
||||
components.NumberFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "imbalance_threshold", |
|
||||
Label: "Imbalance Threshold", |
|
||||
Description: "Trigger balance when storage imbalance exceeds this percentage (0.0-1.0)", |
|
||||
Required: true, |
|
||||
}, |
|
||||
Value: config.ImbalanceThreshold, |
|
||||
Step: "0.01", |
|
||||
Min: floatPtr(0.0), |
|
||||
Max: floatPtr(1.0), |
|
||||
}, |
|
||||
components.DurationInputFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "scan_interval", |
|
||||
Label: "Scan Interval", |
|
||||
Description: "How often to scan for imbalanced volumes", |
|
||||
Required: true, |
|
||||
}, |
|
||||
Seconds: config.ScanIntervalSeconds, |
|
||||
}, |
|
||||
}, |
|
||||
} |
|
||||
|
|
||||
// Scheduling settings section
|
|
||||
schedulingSection := components.ConfigSectionData{ |
|
||||
Title: "Scheduling Settings", |
|
||||
Icon: "fas fa-clock", |
|
||||
Description: "Configure task scheduling and concurrency", |
|
||||
Fields: []interface{}{ |
|
||||
components.NumberFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "max_concurrent", |
|
||||
Label: "Max Concurrent Tasks", |
|
||||
Description: "Maximum number of balance tasks that can run simultaneously", |
|
||||
Required: true, |
|
||||
}, |
|
||||
Value: float64(config.MaxConcurrent), |
|
||||
Step: "1", |
|
||||
Min: floatPtr(1), |
|
||||
}, |
|
||||
components.NumberFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "min_server_count", |
|
||||
Label: "Minimum Server Count", |
|
||||
Description: "Only balance when at least this many servers are available", |
|
||||
Required: true, |
|
||||
}, |
|
||||
Value: float64(config.MinServerCount), |
|
||||
Step: "1", |
|
||||
Min: floatPtr(1), |
|
||||
}, |
|
||||
}, |
|
||||
} |
|
||||
|
|
||||
// Timing constraints section
|
|
||||
timingSection := components.ConfigSectionData{ |
|
||||
Title: "Timing Constraints", |
|
||||
Icon: "fas fa-calendar-clock", |
|
||||
Description: "Configure when balance operations are allowed", |
|
||||
Fields: []interface{}{ |
|
||||
components.CheckboxFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "move_during_off_hours", |
|
||||
Label: "Restrict to Off-Hours", |
|
||||
Description: "Only perform balance operations during off-peak hours", |
|
||||
}, |
|
||||
Checked: config.MoveDuringOffHours, |
|
||||
}, |
|
||||
components.TextFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "off_hours_start", |
|
||||
Label: "Off-Hours Start Time", |
|
||||
Description: "Start time for off-hours window (e.g., 23:00)", |
|
||||
}, |
|
||||
Value: config.OffHoursStart, |
|
||||
}, |
|
||||
components.TextFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "off_hours_end", |
|
||||
Label: "Off-Hours End Time", |
|
||||
Description: "End time for off-hours window (e.g., 06:00)", |
|
||||
}, |
|
||||
Value: config.OffHoursEnd, |
|
||||
}, |
|
||||
}, |
|
||||
} |
|
||||
|
|
||||
// Performance impact info section
|
|
||||
performanceSection := components.ConfigSectionData{ |
|
||||
Title: "Performance Considerations", |
|
||||
Icon: "fas fa-exclamation-triangle", |
|
||||
Description: "Important information about balance operations", |
|
||||
Fields: []interface{}{ |
|
||||
components.TextFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "performance_info", |
|
||||
Label: "Performance Impact", |
|
||||
Description: "Volume balancing involves data movement and can impact cluster performance", |
|
||||
}, |
|
||||
Value: "Enable off-hours restriction to minimize impact on production workloads", |
|
||||
}, |
|
||||
components.TextFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "safety_info", |
|
||||
Label: "Safety Requirements", |
|
||||
Description: fmt.Sprintf("Requires at least %d servers to ensure data safety during moves", config.MinServerCount), |
|
||||
}, |
|
||||
Value: "Maintains data safety during volume moves between servers", |
|
||||
}, |
|
||||
}, |
|
||||
} |
|
||||
|
|
||||
return []components.ConfigSectionData{detectionSection, schedulingSection, timingSection, performanceSection}, nil |
|
||||
} |
|
||||
|
|
||||
// ParseConfigForm parses form data into configuration
|
|
||||
func (ui *UITemplProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) { |
|
||||
config := &BalanceConfig{} |
|
||||
|
|
||||
// Parse enabled checkbox
|
|
||||
config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on" |
|
||||
|
|
||||
// Parse imbalance threshold
|
|
||||
if thresholdStr := formData["imbalance_threshold"]; len(thresholdStr) > 0 { |
|
||||
if threshold, err := strconv.ParseFloat(thresholdStr[0], 64); err != nil { |
|
||||
return nil, fmt.Errorf("invalid imbalance threshold: %v", err) |
|
||||
} else if threshold < 0 || threshold > 1 { |
|
||||
return nil, fmt.Errorf("imbalance threshold must be between 0.0 and 1.0") |
|
||||
} else { |
|
||||
config.ImbalanceThreshold = threshold |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Parse scan interval
|
|
||||
if valueStr := formData["scan_interval"]; len(valueStr) > 0 { |
|
||||
if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil { |
|
||||
return nil, fmt.Errorf("invalid scan interval value: %v", err) |
|
||||
} else { |
|
||||
unit := "minutes" // default
|
|
||||
if unitStr := formData["scan_interval_unit"]; len(unitStr) > 0 { |
|
||||
unit = unitStr[0] |
|
||||
} |
|
||||
config.ScanIntervalSeconds = valueAndUnitToSeconds(value, unit) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Parse max concurrent
|
|
||||
if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 { |
|
||||
if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil { |
|
||||
return nil, fmt.Errorf("invalid max concurrent: %v", err) |
|
||||
} else if concurrent < 1 { |
|
||||
return nil, fmt.Errorf("max concurrent must be at least 1") |
|
||||
} else { |
|
||||
config.MaxConcurrent = concurrent |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Parse min server count
|
|
||||
if serverCountStr := formData["min_server_count"]; len(serverCountStr) > 0 { |
|
||||
if serverCount, err := strconv.Atoi(serverCountStr[0]); err != nil { |
|
||||
return nil, fmt.Errorf("invalid min server count: %v", err) |
|
||||
} else if serverCount < 1 { |
|
||||
return nil, fmt.Errorf("min server count must be at least 1") |
|
||||
} else { |
|
||||
config.MinServerCount = serverCount |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Parse move during off hours
|
|
||||
config.MoveDuringOffHours = len(formData["move_during_off_hours"]) > 0 && formData["move_during_off_hours"][0] == "on" |
|
||||
|
|
||||
// Parse off hours start time
|
|
||||
if startStr := formData["off_hours_start"]; len(startStr) > 0 { |
|
||||
config.OffHoursStart = startStr[0] |
|
||||
} |
|
||||
|
|
||||
// Parse off hours end time
|
|
||||
if endStr := formData["off_hours_end"]; len(endStr) > 0 { |
|
||||
config.OffHoursEnd = endStr[0] |
|
||||
} |
|
||||
|
|
||||
return config, nil |
|
||||
} |
|
||||
|
|
||||
// GetCurrentConfig returns the current configuration
|
|
||||
func (ui *UITemplProvider) GetCurrentConfig() interface{} { |
|
||||
return ui.getCurrentBalanceConfig() |
|
||||
} |
|
||||
|
|
||||
// ApplyConfig applies the new configuration
|
|
||||
func (ui *UITemplProvider) ApplyConfig(config interface{}) error { |
|
||||
balanceConfig, ok := config.(*BalanceConfig) |
|
||||
if !ok { |
|
||||
return fmt.Errorf("invalid config type, expected *BalanceConfig") |
|
||||
} |
|
||||
|
|
||||
// Apply to detector
|
|
||||
if ui.detector != nil { |
|
||||
ui.detector.SetEnabled(balanceConfig.Enabled) |
|
||||
ui.detector.SetThreshold(balanceConfig.ImbalanceThreshold) |
|
||||
ui.detector.SetMinCheckInterval(time.Duration(balanceConfig.ScanIntervalSeconds) * time.Second) |
|
||||
} |
|
||||
|
|
||||
// Apply to scheduler
|
|
||||
if ui.scheduler != nil { |
|
||||
ui.scheduler.SetEnabled(balanceConfig.Enabled) |
|
||||
ui.scheduler.SetMaxConcurrent(balanceConfig.MaxConcurrent) |
|
||||
ui.scheduler.SetMinServerCount(balanceConfig.MinServerCount) |
|
||||
ui.scheduler.SetMoveDuringOffHours(balanceConfig.MoveDuringOffHours) |
|
||||
ui.scheduler.SetOffHoursStart(balanceConfig.OffHoursStart) |
|
||||
ui.scheduler.SetOffHoursEnd(balanceConfig.OffHoursEnd) |
|
||||
} |
|
||||
|
|
||||
glog.V(1).Infof("Applied balance configuration: enabled=%v, threshold=%.1f%%, max_concurrent=%d, min_servers=%d, off_hours=%v", |
|
||||
balanceConfig.Enabled, balanceConfig.ImbalanceThreshold*100, balanceConfig.MaxConcurrent, |
|
||||
balanceConfig.MinServerCount, balanceConfig.MoveDuringOffHours) |
|
||||
|
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
// getCurrentBalanceConfig gets the current configuration from detector and scheduler
|
|
||||
func (ui *UITemplProvider) getCurrentBalanceConfig() *BalanceConfig { |
|
||||
config := &BalanceConfig{ |
|
||||
// Default values (fallback if detectors/schedulers are nil)
|
|
||||
Enabled: true, |
|
||||
ImbalanceThreshold: 0.1, // 10% imbalance
|
|
||||
ScanIntervalSeconds: int((4 * time.Hour).Seconds()), |
|
||||
MaxConcurrent: 1, |
|
||||
MinServerCount: 3, |
|
||||
MoveDuringOffHours: true, |
|
||||
OffHoursStart: "23:00", |
|
||||
OffHoursEnd: "06:00", |
|
||||
} |
|
||||
|
|
||||
// Get current values from detector
|
|
||||
if ui.detector != nil { |
|
||||
config.Enabled = ui.detector.IsEnabled() |
|
||||
config.ImbalanceThreshold = ui.detector.GetThreshold() |
|
||||
config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds()) |
|
||||
} |
|
||||
|
|
||||
// Get current values from scheduler
|
|
||||
if ui.scheduler != nil { |
|
||||
config.MaxConcurrent = ui.scheduler.GetMaxConcurrent() |
|
||||
config.MinServerCount = ui.scheduler.GetMinServerCount() |
|
||||
config.MoveDuringOffHours = ui.scheduler.GetMoveDuringOffHours() |
|
||||
config.OffHoursStart = ui.scheduler.GetOffHoursStart() |
|
||||
config.OffHoursEnd = ui.scheduler.GetOffHoursEnd() |
|
||||
} |
|
||||
|
|
||||
return config |
|
||||
} |
|
||||
|
|
||||
// floatPtr is a helper function to create float64 pointers
|
|
||||
func floatPtr(f float64) *float64 { |
|
||||
return &f |
|
||||
} |
|
||||
|
|
||||
// RegisterUITempl registers the balance templ UI provider with the UI registry
|
|
||||
func RegisterUITempl(uiRegistry *types.UITemplRegistry, detector *BalanceDetector, scheduler *BalanceScheduler) { |
|
||||
uiProvider := NewUITemplProvider(detector, scheduler) |
|
||||
uiRegistry.RegisterUI(uiProvider) |
|
||||
|
|
||||
glog.V(1).Infof("✅ Registered balance task templ UI provider") |
|
||||
} |
|
@ -1,319 +0,0 @@ |
|||||
package erasure_coding |
|
||||
|
|
||||
import ( |
|
||||
"fmt" |
|
||||
"strconv" |
|
||||
"time" |
|
||||
|
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/view/components" |
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog" |
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types" |
|
||||
) |
|
||||
|
|
||||
// Helper function to format seconds as duration string
|
|
||||
func formatDurationFromSeconds(seconds int) string { |
|
||||
d := time.Duration(seconds) * time.Second |
|
||||
return d.String() |
|
||||
} |
|
||||
|
|
||||
// Helper function to convert value and unit to seconds
|
|
||||
func valueAndUnitToSeconds(value float64, unit string) int { |
|
||||
switch unit { |
|
||||
case "days": |
|
||||
return int(value * 24 * 60 * 60) |
|
||||
case "hours": |
|
||||
return int(value * 60 * 60) |
|
||||
case "minutes": |
|
||||
return int(value * 60) |
|
||||
default: |
|
||||
return int(value * 60) // Default to minutes
|
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// UITemplProvider provides the templ-based UI for erasure coding task configuration
|
|
||||
type UITemplProvider struct { |
|
||||
detector *EcDetector |
|
||||
scheduler *Scheduler |
|
||||
} |
|
||||
|
|
||||
// NewUITemplProvider creates a new erasure coding templ UI provider
|
|
||||
func NewUITemplProvider(detector *EcDetector, scheduler *Scheduler) *UITemplProvider { |
|
||||
return &UITemplProvider{ |
|
||||
detector: detector, |
|
||||
scheduler: scheduler, |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// ErasureCodingConfig is defined in ui.go - we reuse it
|
|
||||
|
|
||||
// GetTaskType returns the task type
|
|
||||
func (ui *UITemplProvider) GetTaskType() types.TaskType { |
|
||||
return types.TaskTypeErasureCoding |
|
||||
} |
|
||||
|
|
||||
// GetDisplayName returns the human-readable name
|
|
||||
func (ui *UITemplProvider) GetDisplayName() string { |
|
||||
return "Erasure Coding" |
|
||||
} |
|
||||
|
|
||||
// GetDescription returns a description of what this task does
|
|
||||
func (ui *UITemplProvider) GetDescription() string { |
|
||||
return "Converts replicated volumes to erasure-coded format for efficient storage" |
|
||||
} |
|
||||
|
|
||||
// GetIcon returns the icon CSS class for this task type
|
|
||||
func (ui *UITemplProvider) GetIcon() string { |
|
||||
return "fas fa-shield-alt text-info" |
|
||||
} |
|
||||
|
|
||||
// RenderConfigSections renders the configuration as templ section data
|
|
||||
func (ui *UITemplProvider) RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) { |
|
||||
config := ui.getCurrentECConfig() |
|
||||
|
|
||||
// Detection settings section
|
|
||||
detectionSection := components.ConfigSectionData{ |
|
||||
Title: "Detection Settings", |
|
||||
Icon: "fas fa-search", |
|
||||
Description: "Configure when erasure coding tasks should be triggered", |
|
||||
Fields: []interface{}{ |
|
||||
components.CheckboxFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "enabled", |
|
||||
Label: "Enable Erasure Coding Tasks", |
|
||||
Description: "Whether erasure coding tasks should be automatically created", |
|
||||
}, |
|
||||
Checked: config.Enabled, |
|
||||
}, |
|
||||
components.DurationInputFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "scan_interval", |
|
||||
Label: "Scan Interval", |
|
||||
Description: "How often to scan for volumes needing erasure coding", |
|
||||
Required: true, |
|
||||
}, |
|
||||
Seconds: config.ScanIntervalSeconds, |
|
||||
}, |
|
||||
components.DurationInputFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "volume_age_threshold", |
|
||||
Label: "Volume Age Threshold", |
|
||||
Description: "Only apply erasure coding to volumes older than this age", |
|
||||
Required: true, |
|
||||
}, |
|
||||
Seconds: config.VolumeAgeHoursSeconds, |
|
||||
}, |
|
||||
}, |
|
||||
} |
|
||||
|
|
||||
// Erasure coding parameters section
|
|
||||
paramsSection := components.ConfigSectionData{ |
|
||||
Title: "Erasure Coding Parameters", |
|
||||
Icon: "fas fa-cogs", |
|
||||
Description: "Configure erasure coding scheme and performance", |
|
||||
Fields: []interface{}{ |
|
||||
components.NumberFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "data_shards", |
|
||||
Label: "Data Shards", |
|
||||
Description: "Number of data shards in the erasure coding scheme", |
|
||||
Required: true, |
|
||||
}, |
|
||||
Value: float64(config.ShardCount), |
|
||||
Step: "1", |
|
||||
Min: floatPtr(1), |
|
||||
Max: floatPtr(16), |
|
||||
}, |
|
||||
components.NumberFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "parity_shards", |
|
||||
Label: "Parity Shards", |
|
||||
Description: "Number of parity shards (determines fault tolerance)", |
|
||||
Required: true, |
|
||||
}, |
|
||||
Value: float64(config.ParityCount), |
|
||||
Step: "1", |
|
||||
Min: floatPtr(1), |
|
||||
Max: floatPtr(16), |
|
||||
}, |
|
||||
components.NumberFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "max_concurrent", |
|
||||
Label: "Max Concurrent Tasks", |
|
||||
Description: "Maximum number of erasure coding tasks that can run simultaneously", |
|
||||
Required: true, |
|
||||
}, |
|
||||
Value: float64(config.MaxConcurrent), |
|
||||
Step: "1", |
|
||||
Min: floatPtr(1), |
|
||||
}, |
|
||||
}, |
|
||||
} |
|
||||
|
|
||||
// Performance impact info section
|
|
||||
infoSection := components.ConfigSectionData{ |
|
||||
Title: "Performance Impact", |
|
||||
Icon: "fas fa-info-circle", |
|
||||
Description: "Important information about erasure coding operations", |
|
||||
Fields: []interface{}{ |
|
||||
components.TextFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "durability_info", |
|
||||
Label: "Durability", |
|
||||
Description: fmt.Sprintf("With %d+%d configuration, can tolerate up to %d shard failures", |
|
||||
config.ShardCount, config.ParityCount, config.ParityCount), |
|
||||
}, |
|
||||
Value: "High durability with space efficiency", |
|
||||
}, |
|
||||
components.TextFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "performance_info", |
|
||||
Label: "Performance Note", |
|
||||
Description: "Erasure coding is CPU and I/O intensive. Consider running during off-peak hours", |
|
||||
}, |
|
||||
Value: "Schedule during low-traffic periods", |
|
||||
}, |
|
||||
}, |
|
||||
} |
|
||||
|
|
||||
return []components.ConfigSectionData{detectionSection, paramsSection, infoSection}, nil |
|
||||
} |
|
||||
|
|
||||
// ParseConfigForm parses form data into configuration
|
|
||||
func (ui *UITemplProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) { |
|
||||
config := &ErasureCodingConfig{} |
|
||||
|
|
||||
// Parse enabled checkbox
|
|
||||
config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on" |
|
||||
|
|
||||
// Parse volume age threshold
|
|
||||
if valueStr := formData["volume_age_threshold"]; len(valueStr) > 0 { |
|
||||
if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil { |
|
||||
return nil, fmt.Errorf("invalid volume age threshold value: %v", err) |
|
||||
} else { |
|
||||
unit := "hours" // default
|
|
||||
if unitStr := formData["volume_age_threshold_unit"]; len(unitStr) > 0 { |
|
||||
unit = unitStr[0] |
|
||||
} |
|
||||
config.VolumeAgeHoursSeconds = valueAndUnitToSeconds(value, unit) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Parse scan interval
|
|
||||
if valueStr := formData["scan_interval"]; len(valueStr) > 0 { |
|
||||
if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil { |
|
||||
return nil, fmt.Errorf("invalid scan interval value: %v", err) |
|
||||
} else { |
|
||||
unit := "hours" // default
|
|
||||
if unitStr := formData["scan_interval_unit"]; len(unitStr) > 0 { |
|
||||
unit = unitStr[0] |
|
||||
} |
|
||||
config.ScanIntervalSeconds = valueAndUnitToSeconds(value, unit) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Parse data shards
|
|
||||
if shardsStr := formData["data_shards"]; len(shardsStr) > 0 { |
|
||||
if shards, err := strconv.Atoi(shardsStr[0]); err != nil { |
|
||||
return nil, fmt.Errorf("invalid data shards: %v", err) |
|
||||
} else if shards < 1 || shards > 16 { |
|
||||
return nil, fmt.Errorf("data shards must be between 1 and 16") |
|
||||
} else { |
|
||||
config.ShardCount = shards |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Parse parity shards
|
|
||||
if shardsStr := formData["parity_shards"]; len(shardsStr) > 0 { |
|
||||
if shards, err := strconv.Atoi(shardsStr[0]); err != nil { |
|
||||
return nil, fmt.Errorf("invalid parity shards: %v", err) |
|
||||
} else if shards < 1 || shards > 16 { |
|
||||
return nil, fmt.Errorf("parity shards must be between 1 and 16") |
|
||||
} else { |
|
||||
config.ParityCount = shards |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Parse max concurrent
|
|
||||
if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 { |
|
||||
if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil { |
|
||||
return nil, fmt.Errorf("invalid max concurrent: %v", err) |
|
||||
} else if concurrent < 1 { |
|
||||
return nil, fmt.Errorf("max concurrent must be at least 1") |
|
||||
} else { |
|
||||
config.MaxConcurrent = concurrent |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return config, nil |
|
||||
} |
|
||||
|
|
||||
// GetCurrentConfig returns the current configuration
|
|
||||
func (ui *UITemplProvider) GetCurrentConfig() interface{} { |
|
||||
return ui.getCurrentECConfig() |
|
||||
} |
|
||||
|
|
||||
// ApplyConfig applies the new configuration
|
|
||||
func (ui *UITemplProvider) ApplyConfig(config interface{}) error { |
|
||||
ecConfig, ok := config.(*ErasureCodingConfig) |
|
||||
if !ok { |
|
||||
return fmt.Errorf("invalid config type, expected *ErasureCodingConfig") |
|
||||
} |
|
||||
|
|
||||
// Apply to detector
|
|
||||
if ui.detector != nil { |
|
||||
ui.detector.SetEnabled(ecConfig.Enabled) |
|
||||
ui.detector.SetVolumeAgeHours(ecConfig.VolumeAgeHoursSeconds) |
|
||||
ui.detector.SetScanInterval(time.Duration(ecConfig.ScanIntervalSeconds) * time.Second) |
|
||||
} |
|
||||
|
|
||||
// Apply to scheduler
|
|
||||
if ui.scheduler != nil { |
|
||||
ui.scheduler.SetMaxConcurrent(ecConfig.MaxConcurrent) |
|
||||
ui.scheduler.SetEnabled(ecConfig.Enabled) |
|
||||
} |
|
||||
|
|
||||
glog.V(1).Infof("Applied erasure coding configuration: enabled=%v, age_threshold=%ds, max_concurrent=%d", |
|
||||
ecConfig.Enabled, ecConfig.VolumeAgeHoursSeconds, ecConfig.MaxConcurrent) |
|
||||
|
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
// getCurrentECConfig gets the current configuration from detector and scheduler
|
|
||||
func (ui *UITemplProvider) getCurrentECConfig() *ErasureCodingConfig { |
|
||||
config := &ErasureCodingConfig{ |
|
||||
// Default values (fallback if detectors/schedulers are nil)
|
|
||||
Enabled: true, |
|
||||
VolumeAgeHoursSeconds: int((24 * time.Hour).Seconds()), |
|
||||
ScanIntervalSeconds: int((2 * time.Hour).Seconds()), |
|
||||
MaxConcurrent: 1, |
|
||||
ShardCount: 10, |
|
||||
ParityCount: 4, |
|
||||
} |
|
||||
|
|
||||
// Get current values from detector
|
|
||||
if ui.detector != nil { |
|
||||
config.Enabled = ui.detector.IsEnabled() |
|
||||
config.VolumeAgeHoursSeconds = ui.detector.GetVolumeAgeHours() |
|
||||
config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds()) |
|
||||
} |
|
||||
|
|
||||
// Get current values from scheduler
|
|
||||
if ui.scheduler != nil { |
|
||||
config.MaxConcurrent = ui.scheduler.GetMaxConcurrent() |
|
||||
} |
|
||||
|
|
||||
return config |
|
||||
} |
|
||||
|
|
||||
// floatPtr is a helper function to create float64 pointers
|
|
||||
func floatPtr(f float64) *float64 { |
|
||||
return &f |
|
||||
} |
|
||||
|
|
||||
// RegisterUITempl registers the erasure coding templ UI provider with the UI registry
|
|
||||
func RegisterUITempl(uiRegistry *types.UITemplRegistry, detector *EcDetector, scheduler *Scheduler) { |
|
||||
uiProvider := NewUITemplProvider(detector, scheduler) |
|
||||
uiRegistry.RegisterUI(uiProvider) |
|
||||
|
|
||||
glog.V(1).Infof("✅ Registered erasure coding task templ UI provider") |
|
||||
} |
|
@ -1,330 +0,0 @@ |
|||||
package vacuum |
|
||||
|
|
||||
import ( |
|
||||
"fmt" |
|
||||
"strconv" |
|
||||
"time" |
|
||||
|
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/view/components" |
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog" |
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types" |
|
||||
) |
|
||||
|
|
||||
// Helper function to format seconds as duration string
|
|
||||
func formatDurationFromSeconds(seconds int) string { |
|
||||
d := time.Duration(seconds) * time.Second |
|
||||
return d.String() |
|
||||
} |
|
||||
|
|
||||
// Helper functions to convert between seconds and value+unit format
|
|
||||
func secondsToValueAndUnit(seconds int) (float64, string) { |
|
||||
if seconds == 0 { |
|
||||
return 0, "minutes" |
|
||||
} |
|
||||
|
|
||||
// Try days first
|
|
||||
if seconds%(24*3600) == 0 && seconds >= 24*3600 { |
|
||||
return float64(seconds / (24 * 3600)), "days" |
|
||||
} |
|
||||
|
|
||||
// Try hours
|
|
||||
if seconds%3600 == 0 && seconds >= 3600 { |
|
||||
return float64(seconds / 3600), "hours" |
|
||||
} |
|
||||
|
|
||||
// Default to minutes
|
|
||||
return float64(seconds / 60), "minutes" |
|
||||
} |
|
||||
|
|
||||
func valueAndUnitToSeconds(value float64, unit string) int { |
|
||||
switch unit { |
|
||||
case "days": |
|
||||
return int(value * 24 * 3600) |
|
||||
case "hours": |
|
||||
return int(value * 3600) |
|
||||
case "minutes": |
|
||||
return int(value * 60) |
|
||||
default: |
|
||||
return int(value * 60) // Default to minutes
|
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// UITemplProvider provides the templ-based UI for vacuum task configuration
|
|
||||
type UITemplProvider struct { |
|
||||
detector *VacuumDetector |
|
||||
scheduler *VacuumScheduler |
|
||||
} |
|
||||
|
|
||||
// NewUITemplProvider creates a new vacuum templ UI provider
|
|
||||
func NewUITemplProvider(detector *VacuumDetector, scheduler *VacuumScheduler) *UITemplProvider { |
|
||||
return &UITemplProvider{ |
|
||||
detector: detector, |
|
||||
scheduler: scheduler, |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// GetTaskType returns the task type
|
|
||||
func (ui *UITemplProvider) GetTaskType() types.TaskType { |
|
||||
return types.TaskTypeVacuum |
|
||||
} |
|
||||
|
|
||||
// GetDisplayName returns the human-readable name
|
|
||||
func (ui *UITemplProvider) GetDisplayName() string { |
|
||||
return "Volume Vacuum" |
|
||||
} |
|
||||
|
|
||||
// GetDescription returns a description of what this task does
|
|
||||
func (ui *UITemplProvider) GetDescription() string { |
|
||||
return "Reclaims disk space by removing deleted files from volumes" |
|
||||
} |
|
||||
|
|
||||
// GetIcon returns the icon CSS class for this task type
|
|
||||
func (ui *UITemplProvider) GetIcon() string { |
|
||||
return "fas fa-broom text-primary" |
|
||||
} |
|
||||
|
|
||||
// RenderConfigSections renders the configuration as templ section data
|
|
||||
func (ui *UITemplProvider) RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) { |
|
||||
config := ui.getCurrentVacuumConfig() |
|
||||
|
|
||||
// Detection settings section
|
|
||||
detectionSection := components.ConfigSectionData{ |
|
||||
Title: "Detection Settings", |
|
||||
Icon: "fas fa-search", |
|
||||
Description: "Configure when vacuum tasks should be triggered", |
|
||||
Fields: []interface{}{ |
|
||||
components.CheckboxFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "enabled", |
|
||||
Label: "Enable Vacuum Tasks", |
|
||||
Description: "Whether vacuum tasks should be automatically created", |
|
||||
}, |
|
||||
Checked: config.Enabled, |
|
||||
}, |
|
||||
components.NumberFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "garbage_threshold", |
|
||||
Label: "Garbage Threshold", |
|
||||
Description: "Trigger vacuum when garbage ratio exceeds this percentage (0.0-1.0)", |
|
||||
Required: true, |
|
||||
}, |
|
||||
Value: config.GarbageThreshold, |
|
||||
Step: "0.01", |
|
||||
Min: floatPtr(0.0), |
|
||||
Max: floatPtr(1.0), |
|
||||
}, |
|
||||
components.DurationInputFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "scan_interval", |
|
||||
Label: "Scan Interval", |
|
||||
Description: "How often to scan for volumes needing vacuum", |
|
||||
Required: true, |
|
||||
}, |
|
||||
Seconds: config.ScanIntervalSeconds, |
|
||||
}, |
|
||||
components.DurationInputFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "min_volume_age", |
|
||||
Label: "Minimum Volume Age", |
|
||||
Description: "Only vacuum volumes older than this duration", |
|
||||
Required: true, |
|
||||
}, |
|
||||
Seconds: config.MinVolumeAgeSeconds, |
|
||||
}, |
|
||||
}, |
|
||||
} |
|
||||
|
|
||||
// Scheduling settings section
|
|
||||
schedulingSection := components.ConfigSectionData{ |
|
||||
Title: "Scheduling Settings", |
|
||||
Icon: "fas fa-clock", |
|
||||
Description: "Configure task scheduling and concurrency", |
|
||||
Fields: []interface{}{ |
|
||||
components.NumberFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "max_concurrent", |
|
||||
Label: "Max Concurrent Tasks", |
|
||||
Description: "Maximum number of vacuum tasks that can run simultaneously", |
|
||||
Required: true, |
|
||||
}, |
|
||||
Value: float64(config.MaxConcurrent), |
|
||||
Step: "1", |
|
||||
Min: floatPtr(1), |
|
||||
}, |
|
||||
components.DurationInputFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "min_interval", |
|
||||
Label: "Minimum Interval", |
|
||||
Description: "Minimum time between vacuum operations on the same volume", |
|
||||
Required: true, |
|
||||
}, |
|
||||
Seconds: config.MinIntervalSeconds, |
|
||||
}, |
|
||||
}, |
|
||||
} |
|
||||
|
|
||||
// Performance impact info section
|
|
||||
performanceSection := components.ConfigSectionData{ |
|
||||
Title: "Performance Impact", |
|
||||
Icon: "fas fa-exclamation-triangle", |
|
||||
Description: "Important information about vacuum operations", |
|
||||
Fields: []interface{}{ |
|
||||
components.TextFieldData{ |
|
||||
FormFieldData: components.FormFieldData{ |
|
||||
Name: "info_impact", |
|
||||
Label: "Impact", |
|
||||
Description: "Volume vacuum operations are I/O intensive and should be scheduled appropriately", |
|
||||
}, |
|
||||
Value: "Configure thresholds and intervals based on your storage usage patterns", |
|
||||
}, |
|
||||
}, |
|
||||
} |
|
||||
|
|
||||
return []components.ConfigSectionData{detectionSection, schedulingSection, performanceSection}, nil |
|
||||
} |
|
||||
|
|
||||
// ParseConfigForm parses form data into configuration
|
|
||||
func (ui *UITemplProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) { |
|
||||
config := &VacuumConfig{} |
|
||||
|
|
||||
// Parse enabled checkbox
|
|
||||
config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on" |
|
||||
|
|
||||
// Parse garbage threshold
|
|
||||
if thresholdStr := formData["garbage_threshold"]; len(thresholdStr) > 0 { |
|
||||
if threshold, err := strconv.ParseFloat(thresholdStr[0], 64); err != nil { |
|
||||
return nil, fmt.Errorf("invalid garbage threshold: %v", err) |
|
||||
} else if threshold < 0 || threshold > 1 { |
|
||||
return nil, fmt.Errorf("garbage threshold must be between 0.0 and 1.0") |
|
||||
} else { |
|
||||
config.GarbageThreshold = threshold |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Parse scan interval
|
|
||||
if valueStr := formData["scan_interval"]; len(valueStr) > 0 { |
|
||||
if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil { |
|
||||
return nil, fmt.Errorf("invalid scan interval value: %v", err) |
|
||||
} else { |
|
||||
unit := "minutes" // default
|
|
||||
if unitStr := formData["scan_interval_unit"]; len(unitStr) > 0 { |
|
||||
unit = unitStr[0] |
|
||||
} |
|
||||
config.ScanIntervalSeconds = valueAndUnitToSeconds(value, unit) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Parse min volume age
|
|
||||
if valueStr := formData["min_volume_age"]; len(valueStr) > 0 { |
|
||||
if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil { |
|
||||
return nil, fmt.Errorf("invalid min volume age value: %v", err) |
|
||||
} else { |
|
||||
unit := "minutes" // default
|
|
||||
if unitStr := formData["min_volume_age_unit"]; len(unitStr) > 0 { |
|
||||
unit = unitStr[0] |
|
||||
} |
|
||||
config.MinVolumeAgeSeconds = valueAndUnitToSeconds(value, unit) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Parse max concurrent
|
|
||||
if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 { |
|
||||
if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil { |
|
||||
return nil, fmt.Errorf("invalid max concurrent: %v", err) |
|
||||
} else if concurrent < 1 { |
|
||||
return nil, fmt.Errorf("max concurrent must be at least 1") |
|
||||
} else { |
|
||||
config.MaxConcurrent = concurrent |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Parse min interval
|
|
||||
if valueStr := formData["min_interval"]; len(valueStr) > 0 { |
|
||||
if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil { |
|
||||
return nil, fmt.Errorf("invalid min interval value: %v", err) |
|
||||
} else { |
|
||||
unit := "minutes" // default
|
|
||||
if unitStr := formData["min_interval_unit"]; len(unitStr) > 0 { |
|
||||
unit = unitStr[0] |
|
||||
} |
|
||||
config.MinIntervalSeconds = valueAndUnitToSeconds(value, unit) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return config, nil |
|
||||
} |
|
||||
|
|
||||
// GetCurrentConfig returns the current configuration
|
|
||||
func (ui *UITemplProvider) GetCurrentConfig() interface{} { |
|
||||
return ui.getCurrentVacuumConfig() |
|
||||
} |
|
||||
|
|
||||
// ApplyConfig applies the new configuration
|
|
||||
func (ui *UITemplProvider) ApplyConfig(config interface{}) error { |
|
||||
vacuumConfig, ok := config.(*VacuumConfig) |
|
||||
if !ok { |
|
||||
return fmt.Errorf("invalid config type, expected *VacuumConfig") |
|
||||
} |
|
||||
|
|
||||
// Apply to detector
|
|
||||
if ui.detector != nil { |
|
||||
ui.detector.SetEnabled(vacuumConfig.Enabled) |
|
||||
ui.detector.SetGarbageThreshold(vacuumConfig.GarbageThreshold) |
|
||||
ui.detector.SetScanInterval(time.Duration(vacuumConfig.ScanIntervalSeconds) * time.Second) |
|
||||
ui.detector.SetMinVolumeAge(time.Duration(vacuumConfig.MinVolumeAgeSeconds) * time.Second) |
|
||||
} |
|
||||
|
|
||||
// Apply to scheduler
|
|
||||
if ui.scheduler != nil { |
|
||||
ui.scheduler.SetEnabled(vacuumConfig.Enabled) |
|
||||
ui.scheduler.SetMaxConcurrent(vacuumConfig.MaxConcurrent) |
|
||||
ui.scheduler.SetMinInterval(time.Duration(vacuumConfig.MinIntervalSeconds) * time.Second) |
|
||||
} |
|
||||
|
|
||||
glog.V(1).Infof("Applied vacuum configuration: enabled=%v, threshold=%.1f%%, scan_interval=%s, max_concurrent=%d", |
|
||||
vacuumConfig.Enabled, vacuumConfig.GarbageThreshold*100, formatDurationFromSeconds(vacuumConfig.ScanIntervalSeconds), vacuumConfig.MaxConcurrent) |
|
||||
|
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
// getCurrentVacuumConfig gets the current configuration from detector and scheduler
|
|
||||
func (ui *UITemplProvider) getCurrentVacuumConfig() *VacuumConfig { |
|
||||
config := &VacuumConfig{ |
|
||||
// Default values (fallback if detectors/schedulers are nil)
|
|
||||
Enabled: true, |
|
||||
GarbageThreshold: 0.3, |
|
||||
ScanIntervalSeconds: int((30 * time.Minute).Seconds()), |
|
||||
MinVolumeAgeSeconds: int((1 * time.Hour).Seconds()), |
|
||||
MaxConcurrent: 2, |
|
||||
MinIntervalSeconds: int((6 * time.Hour).Seconds()), |
|
||||
} |
|
||||
|
|
||||
// Get current values from detector
|
|
||||
if ui.detector != nil { |
|
||||
config.Enabled = ui.detector.IsEnabled() |
|
||||
config.GarbageThreshold = ui.detector.GetGarbageThreshold() |
|
||||
config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds()) |
|
||||
config.MinVolumeAgeSeconds = int(ui.detector.GetMinVolumeAge().Seconds()) |
|
||||
} |
|
||||
|
|
||||
// Get current values from scheduler
|
|
||||
if ui.scheduler != nil { |
|
||||
config.MaxConcurrent = ui.scheduler.GetMaxConcurrent() |
|
||||
config.MinIntervalSeconds = int(ui.scheduler.GetMinInterval().Seconds()) |
|
||||
} |
|
||||
|
|
||||
return config |
|
||||
} |
|
||||
|
|
||||
// floatPtr is a helper function to create float64 pointers
|
|
||||
func floatPtr(f float64) *float64 { |
|
||||
return &f |
|
||||
} |
|
||||
|
|
||||
// RegisterUITempl registers the vacuum templ UI provider with the UI registry
|
|
||||
func RegisterUITempl(uiRegistry *types.UITemplRegistry, detector *VacuumDetector, scheduler *VacuumScheduler) { |
|
||||
uiProvider := NewUITemplProvider(detector, scheduler) |
|
||||
uiRegistry.RegisterUI(uiProvider) |
|
||||
|
|
||||
glog.V(1).Infof("✅ Registered vacuum task templ UI provider") |
|
||||
} |
|
@ -1,63 +0,0 @@ |
|||||
package types |
|
||||
|
|
||||
import ( |
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/view/components" |
|
||||
) |
|
||||
|
|
||||
// TaskUITemplProvider defines how tasks provide their configuration UI using templ components
|
|
||||
type TaskUITemplProvider interface { |
|
||||
// GetTaskType returns the task type
|
|
||||
GetTaskType() TaskType |
|
||||
|
|
||||
// GetDisplayName returns the human-readable name
|
|
||||
GetDisplayName() string |
|
||||
|
|
||||
// GetDescription returns a description of what this task does
|
|
||||
GetDescription() string |
|
||||
|
|
||||
// GetIcon returns the icon CSS class or HTML for this task type
|
|
||||
GetIcon() string |
|
||||
|
|
||||
// RenderConfigSections renders the configuration as templ section data
|
|
||||
RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) |
|
||||
|
|
||||
// ParseConfigForm parses form data into configuration
|
|
||||
ParseConfigForm(formData map[string][]string) (interface{}, error) |
|
||||
|
|
||||
// GetCurrentConfig returns the current configuration
|
|
||||
GetCurrentConfig() interface{} |
|
||||
|
|
||||
// ApplyConfig applies the new configuration
|
|
||||
ApplyConfig(config interface{}) error |
|
||||
} |
|
||||
|
|
||||
// UITemplRegistry manages task UI providers that use templ components
|
|
||||
type UITemplRegistry struct { |
|
||||
providers map[TaskType]TaskUITemplProvider |
|
||||
} |
|
||||
|
|
||||
// NewUITemplRegistry creates a new templ-based UI registry
|
|
||||
func NewUITemplRegistry() *UITemplRegistry { |
|
||||
return &UITemplRegistry{ |
|
||||
providers: make(map[TaskType]TaskUITemplProvider), |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// RegisterUI registers a task UI provider
|
|
||||
func (r *UITemplRegistry) RegisterUI(provider TaskUITemplProvider) { |
|
||||
r.providers[provider.GetTaskType()] = provider |
|
||||
} |
|
||||
|
|
||||
// GetProvider returns the UI provider for a task type
|
|
||||
func (r *UITemplRegistry) GetProvider(taskType TaskType) TaskUITemplProvider { |
|
||||
return r.providers[taskType] |
|
||||
} |
|
||||
|
|
||||
// GetAllProviders returns all registered UI providers
|
|
||||
func (r *UITemplRegistry) GetAllProviders() map[TaskType]TaskUITemplProvider { |
|
||||
result := make(map[TaskType]TaskUITemplProvider) |
|
||||
for k, v := range r.providers { |
|
||||
result[k] = v |
|
||||
} |
|
||||
return result |
|
||||
} |
|
Write
Preview
Loading…
Cancel
Save
Reference in new issue