Fix SeaweedFS S3 bucket extended attributes handling (#7854)
* refactor: Convert versioning to three-state string model matching AWS S3 - Change VersioningEnabled bool to VersioningStatus string in S3Bucket struct - Add GetVersioningStatus() function returning empty string (never enabled), 'Enabled', or 'Suspended' - Update StoreVersioningInExtended() to delete key instead of setting 'Suspended' - Ensures Admin UI and S3 API use consistent versioning state representation * fix: Add validation for bucket quota and Object Lock configuration - Prevent buckets with quota enabled but size=0 (validation check) - Fix Object Lock mode handling to only pass mode when setDefaultRetention is true - Ensures proper extended attribute storage for Object Lock configuration - Matches AWS S3 behavior for Object Lock setup * feat: Handle versioned objects in bucket details view - Recognize .versions directories as versioned objects in listBucketObjects() - Extract size and mtime from extended attribute metadata (ExtLatestVersionSizeKey, ExtLatestVersionMtimeKey) - Add length validation (8 bytes) before parsing extended attribute byte arrays - Update GetBucketDetails() and GetS3Buckets() to use new GetVersioningStatus() - Properly display versioned objects without .versions suffix in bucket details * ui: Update bucket management UI to show three-state versioning and Object Lock - Change versioning display from binary (Enabled/Disabled) to three-state (Not configured/Enabled/Suspended) - Update Object Lock display to show 'Not configured' instead of 'Disabled' - Fix bucket details modal to use bucket.versioning_status instead of bucket.versioning_enabled - Update displayBucketDetails() JavaScript to handle three versioning states * chore: Regenerate template code for bucket UI changes - Generated from updated s3_buckets.templ - Reflects three-state versioning and Object Lock UI improvements
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -340,7 +341,7 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get versioning, object lock, and owner information from extended attributes
|
// Get versioning, object lock, and owner information from extended attributes
|
||||||
versioningEnabled := false
|
versioningStatus := ""
|
||||||
objectLockEnabled := false
|
objectLockEnabled := false
|
||||||
objectLockMode := ""
|
objectLockMode := ""
|
||||||
var objectLockDuration int32 = 0
|
var objectLockDuration int32 = 0
|
||||||
@@ -348,7 +349,7 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
|
|||||||
|
|
||||||
if resp.Entry.Extended != nil {
|
if resp.Entry.Extended != nil {
|
||||||
// Use shared utility to extract versioning information
|
// Use shared utility to extract versioning information
|
||||||
versioningEnabled = extractVersioningFromEntry(resp.Entry)
|
versioningStatus = extractVersioningFromEntry(resp.Entry)
|
||||||
|
|
||||||
// Use shared utility to extract Object Lock information
|
// Use shared utility to extract Object Lock information
|
||||||
objectLockEnabled, objectLockMode, objectLockDuration = extractObjectLockInfoFromEntry(resp.Entry)
|
objectLockEnabled, objectLockMode, objectLockDuration = extractObjectLockInfoFromEntry(resp.Entry)
|
||||||
@@ -367,7 +368,7 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
|
|||||||
LastModified: time.Unix(resp.Entry.Attributes.Mtime, 0),
|
LastModified: time.Unix(resp.Entry.Attributes.Mtime, 0),
|
||||||
Quota: quota,
|
Quota: quota,
|
||||||
QuotaEnabled: quotaEnabled,
|
QuotaEnabled: quotaEnabled,
|
||||||
VersioningEnabled: versioningEnabled,
|
VersioningStatus: versioningStatus,
|
||||||
ObjectLockEnabled: objectLockEnabled,
|
ObjectLockEnabled: objectLockEnabled,
|
||||||
ObjectLockMode: objectLockMode,
|
ObjectLockMode: objectLockMode,
|
||||||
ObjectLockDuration: objectLockDuration,
|
ObjectLockDuration: objectLockDuration,
|
||||||
@@ -430,7 +431,7 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error
|
|||||||
details.Bucket.QuotaEnabled = quotaEnabled
|
details.Bucket.QuotaEnabled = quotaEnabled
|
||||||
|
|
||||||
// Get versioning, object lock, and owner information from extended attributes
|
// Get versioning, object lock, and owner information from extended attributes
|
||||||
versioningEnabled := false
|
versioningStatus := ""
|
||||||
objectLockEnabled := false
|
objectLockEnabled := false
|
||||||
objectLockMode := ""
|
objectLockMode := ""
|
||||||
var objectLockDuration int32 = 0
|
var objectLockDuration int32 = 0
|
||||||
@@ -438,7 +439,7 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error
|
|||||||
|
|
||||||
if bucketResp.Entry.Extended != nil {
|
if bucketResp.Entry.Extended != nil {
|
||||||
// Use shared utility to extract versioning information
|
// Use shared utility to extract versioning information
|
||||||
versioningEnabled = extractVersioningFromEntry(bucketResp.Entry)
|
versioningStatus = extractVersioningFromEntry(bucketResp.Entry)
|
||||||
|
|
||||||
// Use shared utility to extract Object Lock information
|
// Use shared utility to extract Object Lock information
|
||||||
objectLockEnabled, objectLockMode, objectLockDuration = extractObjectLockInfoFromEntry(bucketResp.Entry)
|
objectLockEnabled, objectLockMode, objectLockDuration = extractObjectLockInfoFromEntry(bucketResp.Entry)
|
||||||
@@ -449,7 +450,7 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
details.Bucket.VersioningEnabled = versioningEnabled
|
details.Bucket.VersioningStatus = versioningStatus
|
||||||
details.Bucket.ObjectLockEnabled = objectLockEnabled
|
details.Bucket.ObjectLockEnabled = objectLockEnabled
|
||||||
details.Bucket.ObjectLockMode = objectLockMode
|
details.Bucket.ObjectLockMode = objectLockMode
|
||||||
details.Bucket.ObjectLockDuration = objectLockDuration
|
details.Bucket.ObjectLockDuration = objectLockDuration
|
||||||
@@ -491,6 +492,45 @@ func (s *AdminServer) listBucketObjects(client filer_pb.SeaweedFilerClient, buck
|
|||||||
|
|
||||||
entry := resp.Entry
|
entry := resp.Entry
|
||||||
if entry.IsDirectory {
|
if entry.IsDirectory {
|
||||||
|
// Check if this is a .versions directory (represents a versioned object)
|
||||||
|
if strings.HasSuffix(entry.Name, ".versions") {
|
||||||
|
// This directory represents an object, add it as an object without the .versions suffix
|
||||||
|
objectName := strings.TrimSuffix(entry.Name, ".versions")
|
||||||
|
objectKey := objectName
|
||||||
|
if directory != bucketBasePath {
|
||||||
|
relativePath := directory[len(bucketBasePath)+1:]
|
||||||
|
objectKey = fmt.Sprintf("%s/%s", relativePath, objectName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract latest version metadata from extended attributes
|
||||||
|
var size int64 = 0
|
||||||
|
var mtime int64 = entry.Attributes.Mtime
|
||||||
|
if entry.Extended != nil {
|
||||||
|
// Get size of latest version
|
||||||
|
if sizeBytes, ok := entry.Extended[s3_constants.ExtLatestVersionSizeKey]; ok && len(sizeBytes) == 8 {
|
||||||
|
size = int64(util.BytesToUint64(sizeBytes))
|
||||||
|
}
|
||||||
|
// Get mtime of latest version
|
||||||
|
if mtimeBytes, ok := entry.Extended[s3_constants.ExtLatestVersionMtimeKey]; ok && len(mtimeBytes) == 8 {
|
||||||
|
mtime = int64(util.BytesToUint64(mtimeBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := S3Object{
|
||||||
|
Key: objectKey,
|
||||||
|
Size: size,
|
||||||
|
LastModified: time.Unix(mtime, 0),
|
||||||
|
ETag: "",
|
||||||
|
StorageClass: "STANDARD",
|
||||||
|
}
|
||||||
|
|
||||||
|
details.Objects = append(details.Objects, obj)
|
||||||
|
details.TotalCount++
|
||||||
|
details.TotalSize += size
|
||||||
|
// Don't recurse into .versions directories
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Recursively list subdirectories
|
// Recursively list subdirectories
|
||||||
subDir := fmt.Sprintf("%s/%s", directory, entry.Name)
|
subDir := fmt.Sprintf("%s/%s", directory, entry.Name)
|
||||||
err := s.listBucketObjects(client, bucketBasePath, subDir, "", details)
|
err := s.listBucketObjects(client, bucketBasePath, subDir, "", details)
|
||||||
@@ -1902,9 +1942,8 @@ func extractObjectLockInfoFromEntry(entry *filer_pb.Entry) (bool, string, int32)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Function to extract versioning information from bucket entry using shared utilities
|
// Function to extract versioning information from bucket entry using shared utilities
|
||||||
func extractVersioningFromEntry(entry *filer_pb.Entry) bool {
|
func extractVersioningFromEntry(entry *filer_pb.Entry) string {
|
||||||
enabled, _ := s3api.LoadVersioningFromExtended(entry)
|
return s3api.GetVersioningStatus(entry)
|
||||||
return enabled
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConfigPersistence returns the config persistence manager
|
// GetConfigPersistence returns the config persistence manager
|
||||||
|
|||||||
@@ -125,6 +125,12 @@ func (s *AdminServer) CreateBucket(c *gin.Context) {
|
|||||||
// Convert quota to bytes
|
// Convert quota to bytes
|
||||||
quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit)
|
quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit)
|
||||||
|
|
||||||
|
// Validate quota: if enabled, size must be greater than 0
|
||||||
|
if req.QuotaEnabled && quotaBytes <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Quota size must be greater than 0 when quota is enabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Sanitize owner: trim whitespace and enforce max length
|
// Sanitize owner: trim whitespace and enforce max length
|
||||||
owner := strings.TrimSpace(req.Owner)
|
owner := strings.TrimSpace(req.Owner)
|
||||||
if len(owner) > MaxOwnerNameLength {
|
if len(owner) > MaxOwnerNameLength {
|
||||||
@@ -466,16 +472,19 @@ func (s *AdminServer) CreateS3BucketWithObjectLock(bucketName string, quotaBytes
|
|||||||
// Handle Object Lock configuration using shared utilities
|
// Handle Object Lock configuration using shared utilities
|
||||||
if objectLockEnabled {
|
if objectLockEnabled {
|
||||||
var duration int32 = 0
|
var duration int32 = 0
|
||||||
|
var mode string = ""
|
||||||
|
|
||||||
if setDefaultRetention {
|
if setDefaultRetention {
|
||||||
// Validate Object Lock parameters only when setting default retention
|
// Validate Object Lock parameters only when setting default retention
|
||||||
if err := s3api.ValidateObjectLockParameters(objectLockEnabled, objectLockMode, objectLockDuration); err != nil {
|
if err := s3api.ValidateObjectLockParameters(objectLockEnabled, objectLockMode, objectLockDuration); err != nil {
|
||||||
return fmt.Errorf("invalid Object Lock parameters: %w", err)
|
return fmt.Errorf("invalid Object Lock parameters: %w", err)
|
||||||
}
|
}
|
||||||
duration = objectLockDuration
|
duration = objectLockDuration
|
||||||
|
mode = objectLockMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Object Lock configuration using shared utility
|
// Create Object Lock configuration using shared utility
|
||||||
objectLockConfig := s3api.CreateObjectLockConfigurationFromParams(objectLockEnabled, objectLockMode, duration)
|
objectLockConfig := s3api.CreateObjectLockConfigurationFromParams(objectLockEnabled, mode, duration)
|
||||||
|
|
||||||
// Store Object Lock configuration in extended attributes using shared utility
|
// Store Object Lock configuration in extended attributes using shared utility
|
||||||
if err := s3api.StoreObjectLockConfigurationInExtended(bucketEntry, objectLockConfig); err != nil {
|
if err := s3api.StoreObjectLockConfigurationInExtended(bucketEntry, objectLockConfig); err != nil {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ type S3Bucket struct {
|
|||||||
LastModified time.Time `json:"last_modified"`
|
LastModified time.Time `json:"last_modified"`
|
||||||
Quota int64 `json:"quota"` // Quota in bytes, 0 means no quota
|
Quota int64 `json:"quota"` // Quota in bytes, 0 means no quota
|
||||||
QuotaEnabled bool `json:"quota_enabled"` // Whether quota is enabled
|
QuotaEnabled bool `json:"quota_enabled"` // Whether quota is enabled
|
||||||
VersioningEnabled bool `json:"versioning_enabled"` // Whether versioning is enabled
|
VersioningStatus string `json:"versioning_status"` // Versioning status: "" (never enabled), "Enabled", or "Suspended"
|
||||||
ObjectLockEnabled bool `json:"object_lock_enabled"` // Whether object lock is enabled
|
ObjectLockEnabled bool `json:"object_lock_enabled"` // Whether object lock is enabled
|
||||||
ObjectLockMode string `json:"object_lock_mode"` // Object lock mode: "GOVERNANCE" or "COMPLIANCE"
|
ObjectLockMode string `json:"object_lock_mode"` // Object lock mode: "GOVERNANCE" or "COMPLIANCE"
|
||||||
ObjectLockDuration int32 `json:"object_lock_duration"` // Default retention duration in days
|
ObjectLockDuration int32 `json:"object_lock_duration"` // Default retention duration in days
|
||||||
|
|||||||
@@ -164,14 +164,16 @@ templ S3Buckets(data dash.S3BucketsData) {
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
if bucket.VersioningEnabled {
|
if bucket.VersioningStatus == "Enabled" {
|
||||||
<span class="badge bg-success">
|
<span class="badge bg-success">
|
||||||
<i class="fas fa-check me-1"></i>Enabled
|
<i class="fas fa-check me-1"></i>Enabled
|
||||||
</span>
|
</span>
|
||||||
} else {
|
} else if bucket.VersioningStatus == "Suspended" {
|
||||||
<span class="badge bg-secondary">
|
<span class="badge bg-warning">
|
||||||
<i class="fas fa-times me-1"></i>Disabled
|
<i class="fas fa-pause me-1"></i>Suspended
|
||||||
</span>
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-muted">Not configured</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -185,9 +187,7 @@ templ S3Buckets(data dash.S3BucketsData) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<span class="badge bg-secondary">
|
<span class="text-muted">Not configured</span>
|
||||||
<i class="fas fa-unlock me-1"></i>Disabled
|
|
||||||
</span>
|
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -1044,9 +1044,11 @@ templ S3Buckets(data dash.S3BucketsData) {
|
|||||||
'<tr>' +
|
'<tr>' +
|
||||||
'<td><strong>Versioning:</strong></td>' +
|
'<td><strong>Versioning:</strong></td>' +
|
||||||
'<td>' +
|
'<td>' +
|
||||||
(bucket.versioning_enabled ?
|
(bucket.versioning_status === 'Enabled' ?
|
||||||
'<span class="badge bg-success"><i class="fas fa-check me-1"></i>Enabled</span>' :
|
'<span class="badge bg-success"><i class="fas fa-check me-1"></i>Enabled</span>' :
|
||||||
'<span class="badge bg-secondary"><i class="fas fa-times me-1"></i>Disabled</span>'
|
bucket.versioning_status === 'Suspended' ?
|
||||||
|
'<span class="badge bg-warning"><i class="fas fa-pause me-1"></i>Suspended</span>' :
|
||||||
|
'<span class="text-muted">Not configured</span>'
|
||||||
) +
|
) +
|
||||||
'</td>' +
|
'</td>' +
|
||||||
'</tr>' +
|
'</tr>' +
|
||||||
@@ -1055,8 +1057,10 @@ templ S3Buckets(data dash.S3BucketsData) {
|
|||||||
'<td>' +
|
'<td>' +
|
||||||
(bucket.object_lock_enabled ?
|
(bucket.object_lock_enabled ?
|
||||||
'<span class="badge bg-warning"><i class="fas fa-lock me-1"></i>Enabled</span>' +
|
'<span class="badge bg-warning"><i class="fas fa-lock me-1"></i>Enabled</span>' +
|
||||||
|
(bucket.object_lock_mode && bucket.object_lock_duration > 0 ?
|
||||||
'<br><small class="text-muted">' + escapeHtml(bucket.object_lock_mode) + ' • ' + bucket.object_lock_duration + ' days</small>' :
|
'<br><small class="text-muted">' + escapeHtml(bucket.object_lock_mode) + ' • ' + bucket.object_lock_duration + ' days</small>' :
|
||||||
'<span class="badge bg-secondary"><i class="fas fa-unlock me-1"></i>Disabled</span>'
|
'') :
|
||||||
|
'<span class="text-muted">Not configured</span>'
|
||||||
) +
|
) +
|
||||||
'</td>' +
|
'</td>' +
|
||||||
'</tr>' +
|
'</tr>' +
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -28,7 +28,8 @@ func StoreVersioningInExtended(entry *filer_pb.Entry, enabled bool) error {
|
|||||||
if enabled {
|
if enabled {
|
||||||
entry.Extended[s3_constants.ExtVersioningKey] = []byte(s3_constants.VersioningEnabled)
|
entry.Extended[s3_constants.ExtVersioningKey] = []byte(s3_constants.VersioningEnabled)
|
||||||
} else {
|
} else {
|
||||||
entry.Extended[s3_constants.ExtVersioningKey] = []byte(s3_constants.VersioningSuspended)
|
// Don't set the header when versioning is not enabled
|
||||||
|
delete(entry.Extended, s3_constants.ExtVersioningKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -49,6 +50,20 @@ func LoadVersioningFromExtended(entry *filer_pb.Entry) (bool, bool) {
|
|||||||
return false, false // not found
|
return false, false // not found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetVersioningStatus returns the versioning status as a string: "", "Enabled", or "Suspended"
|
||||||
|
// Empty string means versioning was never enabled
|
||||||
|
func GetVersioningStatus(entry *filer_pb.Entry) string {
|
||||||
|
if entry == nil || entry.Extended == nil {
|
||||||
|
return "" // Never enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
if versioningBytes, exists := entry.Extended[s3_constants.ExtVersioningKey]; exists {
|
||||||
|
return string(versioningBytes) // "Enabled" or "Suspended"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "" // Never enabled
|
||||||
|
}
|
||||||
|
|
||||||
// CreateObjectLockConfiguration creates a new ObjectLockConfiguration with the specified parameters
|
// CreateObjectLockConfiguration creates a new ObjectLockConfiguration with the specified parameters
|
||||||
func CreateObjectLockConfiguration(enabled bool, mode string, days int, years int) *ObjectLockConfiguration {
|
func CreateObjectLockConfiguration(enabled bool, mode string, days int, years int) *ObjectLockConfiguration {
|
||||||
if !enabled {
|
if !enabled {
|
||||||
|
|||||||
Reference in New Issue
Block a user