diff --git a/test/s3/retention/s3_retention_test.go b/test/s3/retention/s3_retention_test.go index 54eb12848..fd85921b7 100644 --- a/test/s3/retention/s3_retention_test.go +++ b/test/s3/retention/s3_retention_test.go @@ -437,8 +437,10 @@ func TestObjectLockConfiguration(t *testing.T) { }) require.NoError(t, err) assert.Equal(t, types.ObjectLockEnabledEnabled, configResp.ObjectLockConfiguration.ObjectLockEnabled) + require.NotNil(t, configResp.ObjectLockConfiguration.Rule.DefaultRetention, "DefaultRetention should not be nil") + require.NotNil(t, configResp.ObjectLockConfiguration.Rule.DefaultRetention.Days, "Days should not be nil") assert.Equal(t, types.ObjectLockRetentionModeGovernance, configResp.ObjectLockConfiguration.Rule.DefaultRetention.Mode) - assert.Equal(t, int32(30), configResp.ObjectLockConfiguration.Rule.DefaultRetention.Days) + assert.Equal(t, int32(30), *configResp.ObjectLockConfiguration.Rule.DefaultRetention.Days) } // TestRetentionWithVersions tests retention with specific object versions diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go index 9f97677e3..75b038a26 100644 --- a/weed/admin/dash/admin_server.go +++ b/weed/admin/dash/admin_server.go @@ -5,7 +5,6 @@ import ( "context" "fmt" "net/http" - "strconv" "time" "github.com/gin-gonic/gin" @@ -24,6 +23,8 @@ import ( "github.com/seaweedfs/seaweedfs/weed/util" "github.com/seaweedfs/seaweedfs/weed/wdclient" "google.golang.org/grpc" + + "github.com/seaweedfs/seaweedfs/weed/s3api" ) type AdminServer struct { @@ -293,20 +294,11 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) { var objectLockDuration int32 = 0 if resp.Entry.Extended != nil { - if versioningBytes, exists := resp.Entry.Extended["s3.versioning"]; exists { - versioningEnabled = string(versioningBytes) == "Enabled" - } - if objectLockBytes, exists := resp.Entry.Extended["s3.objectlock"]; exists { - objectLockEnabled = string(objectLockBytes) == "Enabled" - } - if objectLockModeBytes, exists := resp.Entry.Extended["s3.objectlock.mode"]; exists { - objectLockMode = string(objectLockModeBytes) - } - if objectLockDurationBytes, exists := resp.Entry.Extended["s3.objectlock.duration"]; exists { - if duration, err := strconv.ParseInt(string(objectLockDurationBytes), 10, 32); err == nil { - objectLockDuration = int32(duration) - } - } + // Use shared utility to extract versioning information + versioningEnabled = extractVersioningFromEntry(resp.Entry) + + // Use shared utility to extract Object Lock information + objectLockEnabled, objectLockMode, objectLockDuration = extractObjectLockInfoFromEntry(resp.Entry) } bucket := S3Bucket{ @@ -379,20 +371,11 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error var objectLockDuration int32 = 0 if bucketResp.Entry.Extended != nil { - if versioningBytes, exists := bucketResp.Entry.Extended["s3.versioning"]; exists { - versioningEnabled = string(versioningBytes) == "Enabled" - } - if objectLockBytes, exists := bucketResp.Entry.Extended["s3.objectlock"]; exists { - objectLockEnabled = string(objectLockBytes) == "Enabled" - } - if objectLockModeBytes, exists := bucketResp.Entry.Extended["s3.objectlock.mode"]; exists { - objectLockMode = string(objectLockModeBytes) - } - if objectLockDurationBytes, exists := bucketResp.Entry.Extended["s3.objectlock.duration"]; exists { - if duration, err := strconv.ParseInt(string(objectLockDurationBytes), 10, 32); err == nil { - objectLockDuration = int32(duration) - } - } + // Use shared utility to extract versioning information + versioningEnabled = extractVersioningFromEntry(bucketResp.Entry) + + // Use shared utility to extract Object Lock information + objectLockEnabled, objectLockMode, objectLockDuration = extractObjectLockInfoFromEntry(bucketResp.Entry) } details.Bucket.VersioningEnabled = versioningEnabled @@ -1502,3 +1485,19 @@ func (s *AdminServer) Shutdown() { glog.V(1).Infof("Admin server shutdown complete") } + +// Function to extract Object Lock information from bucket entry using shared utilities +func extractObjectLockInfoFromEntry(entry *filer_pb.Entry) (bool, string, int32) { + // Try to load Object Lock configuration using shared utility + if config, found := s3api.LoadObjectLockConfigurationFromExtended(entry); found { + return s3api.ExtractObjectLockInfoFromConfig(config) + } + + return false, "", 0 +} + +// Function to extract versioning information from bucket entry using shared utilities +func extractVersioningFromEntry(entry *filer_pb.Entry) bool { + enabled, _ := s3api.LoadVersioningFromExtended(entry) + return enabled +} diff --git a/weed/admin/dash/bucket_management.go b/weed/admin/dash/bucket_management.go index faa19ec99..bd488dc90 100644 --- a/weed/admin/dash/bucket_management.go +++ b/weed/admin/dash/bucket_management.go @@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api" ) // S3 Bucket management data structures for templates @@ -340,32 +341,43 @@ func (s *AdminServer) CreateS3BucketWithObjectLock(bucketName string, quotaBytes TtlSec: 0, } - // Create extended attributes map for versioning and object lock + // Create extended attributes map for versioning extended := make(map[string][]byte) - if versioningEnabled { - extended["s3.versioning"] = []byte("Enabled") - } else { - extended["s3.versioning"] = []byte("Suspended") + + // Create bucket entry + bucketEntry := &filer_pb.Entry{ + Name: bucketName, + IsDirectory: true, + Attributes: attributes, + Extended: extended, + Quota: quota, } + // Handle versioning using shared utilities + if err := s3api.StoreVersioningInExtended(bucketEntry, versioningEnabled); err != nil { + return fmt.Errorf("failed to store versioning configuration: %w", err) + } + + // Handle Object Lock configuration using shared utilities if objectLockEnabled { - extended["s3.objectlock"] = []byte("Enabled") - extended["s3.objectlock.mode"] = []byte(objectLockMode) - extended["s3.objectlock.duration"] = []byte(fmt.Sprintf("%d", objectLockDuration)) - } else { - extended["s3.objectlock"] = []byte("Disabled") + // Validate Object Lock parameters + if err := s3api.ValidateObjectLockParameters(objectLockEnabled, objectLockMode, objectLockDuration); err != nil { + return fmt.Errorf("invalid Object Lock parameters: %w", err) + } + + // Create Object Lock configuration using shared utility + objectLockConfig := s3api.CreateObjectLockConfigurationFromParams(objectLockEnabled, objectLockMode, objectLockDuration) + + // Store Object Lock configuration in extended attributes using shared utility + if err := s3api.StoreObjectLockConfigurationInExtended(bucketEntry, objectLockConfig); err != nil { + return fmt.Errorf("failed to store Object Lock configuration: %w", err) + } } // Create bucket directory under /buckets _, err = client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{ Directory: "/buckets", - Entry: &filer_pb.Entry{ - Name: bucketName, - IsDirectory: true, - Attributes: attributes, - Extended: extended, - Quota: quota, - }, + Entry: bucketEntry, }) if err != nil { return fmt.Errorf("failed to create bucket directory: %w", err) diff --git a/weed/s3api/auth_credentials_subscribe.go b/weed/s3api/auth_credentials_subscribe.go index 1f6b30312..4d6b0fd19 100644 --- a/weed/s3api/auth_credentials_subscribe.go +++ b/weed/s3api/auth_credentials_subscribe.go @@ -1,6 +1,8 @@ package s3api import ( + "time" + "github.com/seaweedfs/seaweedfs/weed/filer" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb" @@ -80,12 +82,74 @@ func (s3a *S3ApiServer) onCircuitBreakerConfigUpdate(dir, filename string, conte func (s3a *S3ApiServer) onBucketMetadataChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error { if dir == s3a.option.BucketsPath { if newEntry != nil { + // Update bucket registry (existing functionality) s3a.bucketRegistry.LoadBucketMetadata(newEntry) - glog.V(0).Infof("updated bucketMetadata %s/%s", dir, newEntry) - } else { + glog.V(0).Infof("updated bucketMetadata %s/%s", dir, newEntry.Name) + + // Update bucket configuration cache with new entry + s3a.updateBucketConfigCacheFromEntry(newEntry) + } else if oldEntry != nil { + // Remove from bucket registry (existing functionality) s3a.bucketRegistry.RemoveBucketMetadata(oldEntry) - glog.V(0).Infof("remove bucketMetadata %s/%s", dir, newEntry) + glog.V(0).Infof("remove bucketMetadata %s/%s", dir, oldEntry.Name) + + // Remove from bucket configuration cache + s3a.invalidateBucketConfigCache(oldEntry.Name) } } return nil } + +// updateBucketConfigCacheFromEntry updates the bucket config cache when a bucket entry changes +func (s3a *S3ApiServer) updateBucketConfigCacheFromEntry(entry *filer_pb.Entry) { + if s3a.bucketConfigCache == nil { + return + } + + bucket := entry.Name + glog.V(2).Infof("updateBucketConfigCacheFromEntry: updating cache for bucket %s", bucket) + + // Create new bucket config from the entry + config := &BucketConfig{ + Name: bucket, + Entry: entry, + } + + // Extract configuration from extended attributes + if entry.Extended != nil { + if versioning, exists := entry.Extended[s3_constants.ExtVersioningKey]; exists { + config.Versioning = string(versioning) + } + if ownership, exists := entry.Extended[s3_constants.ExtOwnershipKey]; exists { + config.Ownership = string(ownership) + } + if acl, exists := entry.Extended[s3_constants.ExtAmzAclKey]; exists { + config.ACL = acl + } + if owner, exists := entry.Extended[s3_constants.ExtAmzOwnerKey]; exists { + config.Owner = string(owner) + } + // Parse Object Lock configuration if present + if objectLockConfig, found := LoadObjectLockConfigurationFromExtended(entry); found { + config.ObjectLockConfig = objectLockConfig + glog.V(2).Infof("updateBucketConfigCacheFromEntry: cached Object Lock configuration for bucket %s", bucket) + } + } + + // Update timestamp + config.LastModified = time.Now() + + // Update cache + s3a.bucketConfigCache.Set(bucket, config) + glog.V(2).Infof("updateBucketConfigCacheFromEntry: updated bucket config cache for %s", bucket) +} + +// invalidateBucketConfigCache removes a bucket from the configuration cache +func (s3a *S3ApiServer) invalidateBucketConfigCache(bucket string) { + if s3a.bucketConfigCache == nil { + return + } + + s3a.bucketConfigCache.Remove(bucket) + glog.V(2).Infof("invalidateBucketConfigCache: removed bucket %s from cache", bucket) +} diff --git a/weed/s3api/object_lock_utils.go b/weed/s3api/object_lock_utils.go new file mode 100644 index 000000000..ffde5bd36 --- /dev/null +++ b/weed/s3api/object_lock_utils.go @@ -0,0 +1,232 @@ +package s3api + +import ( + "encoding/xml" + "fmt" + "strconv" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" +) + +// ObjectLockUtils provides shared utilities for Object Lock configuration +// These functions are used by both Admin UI and S3 API handlers to ensure consistency + +// VersioningUtils provides shared utilities for bucket versioning configuration +// These functions ensure Admin UI and S3 API use the same versioning keys + +// StoreVersioningInExtended stores versioning configuration in entry extended attributes +func StoreVersioningInExtended(entry *filer_pb.Entry, enabled bool) error { + if entry.Extended == nil { + entry.Extended = make(map[string][]byte) + } + + if enabled { + entry.Extended[s3_constants.ExtVersioningKey] = []byte(s3_constants.VersioningEnabled) + } else { + entry.Extended[s3_constants.ExtVersioningKey] = []byte(s3_constants.VersioningSuspended) + } + + return nil +} + +// LoadVersioningFromExtended loads versioning configuration from entry extended attributes +func LoadVersioningFromExtended(entry *filer_pb.Entry) (bool, bool) { + if entry == nil || entry.Extended == nil { + return false, false // not found, default to suspended + } + + // Check for S3 API compatible key + if versioningBytes, exists := entry.Extended[s3_constants.ExtVersioningKey]; exists { + enabled := string(versioningBytes) == s3_constants.VersioningEnabled + return enabled, true + } + + return false, false // not found +} + +// CreateObjectLockConfiguration creates a new ObjectLockConfiguration with the specified parameters +func CreateObjectLockConfiguration(enabled bool, mode string, days int, years int) *ObjectLockConfiguration { + if !enabled { + return nil + } + + config := &ObjectLockConfiguration{ + ObjectLockEnabled: s3_constants.ObjectLockEnabled, + } + + // Add default retention rule if mode and period are specified + if mode != "" && (days > 0 || years > 0) { + config.Rule = &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: mode, + Days: days, + Years: years, + }, + } + } + + return config +} + +// ObjectLockConfigurationToXML converts ObjectLockConfiguration to XML bytes +func ObjectLockConfigurationToXML(config *ObjectLockConfiguration) ([]byte, error) { + if config == nil { + return nil, fmt.Errorf("object lock configuration is nil") + } + + return xml.Marshal(config) +} + +// StoreObjectLockConfigurationInExtended stores Object Lock configuration in entry extended attributes +func StoreObjectLockConfigurationInExtended(entry *filer_pb.Entry, config *ObjectLockConfiguration) error { + if entry.Extended == nil { + entry.Extended = make(map[string][]byte) + } + + if config == nil { + // Remove Object Lock configuration + delete(entry.Extended, s3_constants.ExtObjectLockEnabledKey) + delete(entry.Extended, s3_constants.ExtObjectLockDefaultModeKey) + delete(entry.Extended, s3_constants.ExtObjectLockDefaultDaysKey) + delete(entry.Extended, s3_constants.ExtObjectLockDefaultYearsKey) + return nil + } + + // Store the enabled flag + entry.Extended[s3_constants.ExtObjectLockEnabledKey] = []byte(config.ObjectLockEnabled) + + // Store default retention configuration if present + if config.Rule != nil && config.Rule.DefaultRetention != nil { + defaultRetention := config.Rule.DefaultRetention + + // Store mode + if defaultRetention.Mode != "" { + entry.Extended[s3_constants.ExtObjectLockDefaultModeKey] = []byte(defaultRetention.Mode) + } + + // Store days + if defaultRetention.Days > 0 { + entry.Extended[s3_constants.ExtObjectLockDefaultDaysKey] = []byte(strconv.Itoa(defaultRetention.Days)) + } + + // Store years + if defaultRetention.Years > 0 { + entry.Extended[s3_constants.ExtObjectLockDefaultYearsKey] = []byte(strconv.Itoa(defaultRetention.Years)) + } + } else { + // Remove default retention if not present + delete(entry.Extended, s3_constants.ExtObjectLockDefaultModeKey) + delete(entry.Extended, s3_constants.ExtObjectLockDefaultDaysKey) + delete(entry.Extended, s3_constants.ExtObjectLockDefaultYearsKey) + } + + return nil +} + +// LoadObjectLockConfigurationFromExtended loads Object Lock configuration from entry extended attributes +func LoadObjectLockConfigurationFromExtended(entry *filer_pb.Entry) (*ObjectLockConfiguration, bool) { + if entry == nil || entry.Extended == nil { + return nil, false + } + + // Check if Object Lock is enabled + enabledBytes, exists := entry.Extended[s3_constants.ExtObjectLockEnabledKey] + if !exists { + return nil, false + } + + enabled := string(enabledBytes) + if enabled != s3_constants.ObjectLockEnabled && enabled != "true" { + return nil, false + } + + // Create basic configuration + config := &ObjectLockConfiguration{ + ObjectLockEnabled: s3_constants.ObjectLockEnabled, + } + + // Load default retention configuration if present + if modeBytes, exists := entry.Extended[s3_constants.ExtObjectLockDefaultModeKey]; exists { + mode := string(modeBytes) + + // Parse days and years + var days, years int + if daysBytes, exists := entry.Extended[s3_constants.ExtObjectLockDefaultDaysKey]; exists { + if parsed, err := strconv.Atoi(string(daysBytes)); err == nil { + days = parsed + } + } + if yearsBytes, exists := entry.Extended[s3_constants.ExtObjectLockDefaultYearsKey]; exists { + if parsed, err := strconv.Atoi(string(yearsBytes)); err == nil { + years = parsed + } + } + + // Create rule if we have a mode and at least days or years + if mode != "" && (days > 0 || years > 0) { + config.Rule = &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: mode, + Days: days, + Years: years, + }, + } + } + } + + return config, true +} + +// ExtractObjectLockInfoFromConfig extracts basic Object Lock information from configuration +// Returns: enabled, mode, duration (for UI display) +func ExtractObjectLockInfoFromConfig(config *ObjectLockConfiguration) (bool, string, int32) { + if config == nil || config.ObjectLockEnabled != s3_constants.ObjectLockEnabled { + return false, "", 0 + } + + if config.Rule == nil || config.Rule.DefaultRetention == nil { + return true, "", 0 + } + + defaultRetention := config.Rule.DefaultRetention + + // Convert years to days for consistent representation + days := defaultRetention.Days + if defaultRetention.Years > 0 { + days += defaultRetention.Years * 365 + } + + return true, defaultRetention.Mode, int32(days) +} + +// CreateObjectLockConfigurationFromParams creates ObjectLockConfiguration from individual parameters +// This is a convenience function for Admin UI usage +func CreateObjectLockConfigurationFromParams(enabled bool, mode string, duration int32) *ObjectLockConfiguration { + if !enabled { + return nil + } + + return CreateObjectLockConfiguration(enabled, mode, int(duration), 0) +} + +// ValidateObjectLockParameters validates Object Lock parameters before creating configuration +func ValidateObjectLockParameters(enabled bool, mode string, duration int32) error { + if !enabled { + return nil + } + + if mode != s3_constants.RetentionModeGovernance && mode != s3_constants.RetentionModeCompliance { + return ErrInvalidObjectLockMode + } + + if duration <= 0 { + return ErrInvalidObjectLockDuration + } + + if duration > MaxRetentionDays { + return ErrObjectLockDurationExceeded + } + + return nil +} diff --git a/weed/s3api/s3_constants/extend_key.go b/weed/s3api/s3_constants/extend_key.go index edfa4fe1d..f0f223a45 100644 --- a/weed/s3api/s3_constants/extend_key.go +++ b/weed/s3api/s3_constants/extend_key.go @@ -20,7 +20,11 @@ const ( ExtRetentionUntilDateKey = "Seaweed-X-Amz-Retention-Until-Date" ExtLegalHoldKey = "Seaweed-X-Amz-Legal-Hold" ExtObjectLockEnabledKey = "Seaweed-X-Amz-Object-Lock-Enabled" - ExtObjectLockConfigKey = "Seaweed-X-Amz-Object-Lock-Config" + + // Object Lock Bucket Configuration (individual components, not XML) + ExtObjectLockDefaultModeKey = "Lock-Default-Mode" + ExtObjectLockDefaultDaysKey = "Lock-Default-Days" + ExtObjectLockDefaultYearsKey = "Lock-Default-Years" ) // Object Lock and Retention Constants diff --git a/weed/s3api/s3api_bucket_config.go b/weed/s3api/s3api_bucket_config.go index 43c056973..725ee3596 100644 --- a/weed/s3api/s3api_bucket_config.go +++ b/weed/s3api/s3api_bucket_config.go @@ -17,24 +17,29 @@ import ( // BucketConfig represents cached bucket configuration type BucketConfig struct { - Name string - Versioning string // "Enabled", "Suspended", or "" - Ownership string - ACL []byte - Owner string - CORS *cors.CORSConfiguration - LastModified time.Time - Entry *filer_pb.Entry + Name string + Versioning string // "Enabled", "Suspended", or "" + Ownership string + ACL []byte + Owner string + CORS *cors.CORSConfiguration + ObjectLockConfig *ObjectLockConfiguration // Cached parsed Object Lock configuration + LastModified time.Time + Entry *filer_pb.Entry } // BucketConfigCache provides caching for bucket configurations +// Cache entries are automatically updated/invalidated through metadata subscription events, +// so TTL serves as a safety fallback rather than the primary consistency mechanism type BucketConfigCache struct { cache map[string]*BucketConfig mutex sync.RWMutex - ttl time.Duration + ttl time.Duration // Safety fallback TTL; real-time consistency maintained via events } // NewBucketConfigCache creates a new bucket configuration cache +// TTL can be set to a longer duration since cache consistency is maintained +// through real-time metadata subscription events rather than TTL expiration func NewBucketConfigCache(ttl time.Duration) *BucketConfigCache { return &BucketConfigCache{ cache: make(map[string]*BucketConfig), @@ -52,7 +57,7 @@ func (bcc *BucketConfigCache) Get(bucket string) (*BucketConfig, bool) { return nil, false } - // Check if cache entry is expired + // Check if cache entry is expired (safety fallback; entries are normally updated via events) if time.Since(config.LastModified) > bcc.ttl { return nil, false } @@ -121,6 +126,11 @@ func (s3a *S3ApiServer) getBucketConfig(bucket string) (*BucketConfig, s3err.Err if owner, exists := bucketEntry.Extended[s3_constants.ExtAmzOwnerKey]; exists { config.Owner = string(owner) } + // Parse Object Lock configuration if present + if objectLockConfig, found := LoadObjectLockConfigurationFromExtended(bucketEntry); found { + config.ObjectLockConfig = objectLockConfig + glog.V(2).Infof("getBucketConfig: cached Object Lock configuration for bucket %s", bucket) + } } // Load CORS configuration from .s3metadata @@ -173,6 +183,13 @@ func (s3a *S3ApiServer) updateBucketConfig(bucket string, updateFn func(*BucketC if config.Owner != "" { config.Entry.Extended[s3_constants.ExtAmzOwnerKey] = []byte(config.Owner) } + // Update Object Lock configuration + if config.ObjectLockConfig != nil { + if err := StoreObjectLockConfigurationInExtended(config.Entry, config.ObjectLockConfig); err != nil { + glog.Errorf("updateBucketConfig: failed to store Object Lock configuration for bucket %s: %v", bucket, err) + return s3err.ErrInternalError + } + } // Save to filer err := s3a.updateEntry(s3a.option.BucketsPath, config.Entry) diff --git a/weed/s3api/s3api_bucket_handlers.go b/weed/s3api/s3api_bucket_handlers.go index 0bc4a7b10..e30f172a7 100644 --- a/weed/s3api/s3api_bucket_handlers.go +++ b/weed/s3api/s3api_bucket_handlers.go @@ -147,25 +147,13 @@ func (s3a *S3ApiServer) PutBucketHandler(w http.ResponseWriter, r *http.Request) // Enable versioning (required for Object Lock) bucketConfig.Versioning = s3_constants.VersioningEnabled - // Enable Object Lock configuration - if bucketConfig.Entry.Extended == nil { - bucketConfig.Entry.Extended = make(map[string][]byte) - } - // Create basic Object Lock configuration (enabled without default retention) - // The ObjectLockConfiguration struct is defined below in this file. objectLockConfig := &ObjectLockConfiguration{ ObjectLockEnabled: s3_constants.ObjectLockEnabled, } - // Store the configuration as XML in extended attributes - configXML, err := xml.Marshal(objectLockConfig) - if err != nil { - return fmt.Errorf("failed to marshal Object Lock configuration to XML: %w", err) - } - - bucketConfig.Entry.Extended[s3_constants.ExtObjectLockConfigKey] = configXML - bucketConfig.Entry.Extended[s3_constants.ExtObjectLockEnabledKey] = []byte(s3_constants.ObjectLockEnabled) + // Set the cached Object Lock configuration + bucketConfig.ObjectLockConfig = objectLockConfig return nil }) diff --git a/weed/s3api/s3api_bucket_handlers_object_lock_config.go b/weed/s3api/s3api_bucket_handlers_object_lock_config.go new file mode 100644 index 000000000..494f203a4 --- /dev/null +++ b/weed/s3api/s3api_bucket_handlers_object_lock_config.go @@ -0,0 +1,139 @@ +package s3api + +import ( + "encoding/xml" + "net/http" + + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" + stats_collect "github.com/seaweedfs/seaweedfs/weed/stats" +) + +// PutObjectLockConfigurationHandler Put object Lock configuration +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLockConfiguration.html +func (s3a *S3ApiServer) PutObjectLockConfigurationHandler(w http.ResponseWriter, r *http.Request) { + bucket, _ := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("PutObjectLockConfigurationHandler %s", bucket) + + // Check if Object Lock is available for this bucket (requires versioning) + if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "PutObjectLockConfigurationHandler") { + return + } + + // Parse object lock configuration from request body + config, err := parseObjectLockConfiguration(r) + if err != nil { + glog.Errorf("PutObjectLockConfigurationHandler: failed to parse object lock config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML) + return + } + + // Validate object lock configuration + if err := validateObjectLockConfiguration(config); err != nil { + glog.Errorf("PutObjectLockConfigurationHandler: invalid object lock config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + return + } + + // Set object lock configuration on the bucket + errCode := s3a.updateBucketConfig(bucket, func(bucketConfig *BucketConfig) error { + // Set the cached Object Lock configuration + bucketConfig.ObjectLockConfig = config + return nil + }) + + if errCode != s3err.ErrNone { + glog.Errorf("PutObjectLockConfigurationHandler: failed to set object lock config: %v", errCode) + s3err.WriteErrorResponse(w, r, errCode) + return + } + + // Record metrics + stats_collect.RecordBucketActiveTime(bucket) + + // Return success (HTTP 200 with no body) + w.WriteHeader(http.StatusOK) + glog.V(3).Infof("PutObjectLockConfigurationHandler: successfully set object lock config for %s", bucket) +} + +// GetObjectLockConfigurationHandler Get object Lock configuration +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectLockConfiguration.html +func (s3a *S3ApiServer) GetObjectLockConfigurationHandler(w http.ResponseWriter, r *http.Request) { + bucket, _ := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("GetObjectLockConfigurationHandler %s", bucket) + + // Get bucket configuration + bucketConfig, errCode := s3a.getBucketConfig(bucket) + if errCode != s3err.ErrNone { + glog.Errorf("GetObjectLockConfigurationHandler: failed to get bucket config: %v", errCode) + s3err.WriteErrorResponse(w, r, errCode) + return + } + + var configXML []byte + + // Check if we have cached Object Lock configuration + if bucketConfig.ObjectLockConfig != nil { + // Use cached configuration and marshal it to XML for response + marshaledXML, err := xml.Marshal(bucketConfig.ObjectLockConfig) + if err != nil { + glog.Errorf("GetObjectLockConfigurationHandler: failed to marshal cached Object Lock config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Write XML response + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(xml.Header)); err != nil { + glog.Errorf("GetObjectLockConfigurationHandler: failed to write XML header: %v", err) + return + } + if _, err := w.Write(marshaledXML); err != nil { + glog.Errorf("GetObjectLockConfigurationHandler: failed to write config XML: %v", err) + return + } + glog.V(3).Infof("GetObjectLockConfigurationHandler: successfully retrieved cached object lock config for %s", bucket) + return + } + + // Fallback: check for legacy storage in extended attributes + if bucketConfig.Entry.Extended != nil { + // Check if Object Lock is enabled via boolean flag + if enabledBytes, exists := bucketConfig.Entry.Extended[s3_constants.ExtObjectLockEnabledKey]; exists { + enabled := string(enabledBytes) + if enabled == s3_constants.ObjectLockEnabled || enabled == "true" { + // Generate minimal XML configuration for enabled Object Lock without retention policies + minimalConfig := `Enabled` + configXML = []byte(minimalConfig) + } + } + } + + // If no Object Lock configuration found, return error + if len(configXML) == 0 { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLockConfiguration) + return + } + + // Set response headers + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + + // Write XML response + if _, err := w.Write([]byte(xml.Header)); err != nil { + glog.Errorf("GetObjectLockConfigurationHandler: failed to write XML header: %v", err) + return + } + + if _, err := w.Write(configXML); err != nil { + glog.Errorf("GetObjectLockConfigurationHandler: failed to write config XML: %v", err) + return + } + + // Record metrics + stats_collect.RecordBucketActiveTime(bucket) + + glog.V(3).Infof("GetObjectLockConfigurationHandler: successfully retrieved object lock config for %s", bucket) +} diff --git a/weed/s3api/s3api_object_handlers_legal_hold.go b/weed/s3api/s3api_object_handlers_legal_hold.go new file mode 100644 index 000000000..9cf523477 --- /dev/null +++ b/weed/s3api/s3api_object_handlers_legal_hold.go @@ -0,0 +1,126 @@ +package s3api + +import ( + "encoding/xml" + "errors" + "net/http" + + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" + stats_collect "github.com/seaweedfs/seaweedfs/weed/stats" +) + +// PutObjectLegalHoldHandler Put object Legal Hold +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLegalHold.html +func (s3a *S3ApiServer) PutObjectLegalHoldHandler(w http.ResponseWriter, r *http.Request) { + bucket, object := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("PutObjectLegalHoldHandler %s %s", bucket, object) + + // Check if Object Lock is available for this bucket (requires versioning) + if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "PutObjectLegalHoldHandler") { + return + } + + // Get version ID from query parameters + versionId := r.URL.Query().Get("versionId") + + // Parse legal hold configuration from request body + legalHold, err := parseObjectLegalHold(r) + if err != nil { + glog.Errorf("PutObjectLegalHoldHandler: failed to parse legal hold config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML) + return + } + + // Validate legal hold configuration + if err := validateLegalHold(legalHold); err != nil { + glog.Errorf("PutObjectLegalHoldHandler: invalid legal hold config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + return + } + + // Set legal hold on the object + if err := s3a.setObjectLegalHold(bucket, object, versionId, legalHold); err != nil { + glog.Errorf("PutObjectLegalHoldHandler: failed to set legal hold: %v", err) + + // Handle specific error cases + if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Record metrics + stats_collect.RecordBucketActiveTime(bucket) + + // Return success (HTTP 200 with no body) + w.WriteHeader(http.StatusOK) + glog.V(3).Infof("PutObjectLegalHoldHandler: successfully set legal hold for %s/%s", bucket, object) +} + +// GetObjectLegalHoldHandler Get object Legal Hold +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectLegalHold.html +func (s3a *S3ApiServer) GetObjectLegalHoldHandler(w http.ResponseWriter, r *http.Request) { + bucket, object := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("GetObjectLegalHoldHandler %s %s", bucket, object) + + // Check if Object Lock is available for this bucket (requires versioning) + if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "GetObjectLegalHoldHandler") { + return + } + + // Get version ID from query parameters + versionId := r.URL.Query().Get("versionId") + + // Get legal hold configuration for the object + legalHold, err := s3a.getObjectLegalHold(bucket, object, versionId) + if err != nil { + glog.Errorf("GetObjectLegalHoldHandler: failed to get legal hold: %v", err) + + // Handle specific error cases + if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + + if errors.Is(err, ErrNoLegalHoldConfiguration) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLegalHold) + return + } + + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Marshal legal hold configuration to XML + legalHoldXML, err := xml.Marshal(legalHold) + if err != nil { + glog.Errorf("GetObjectLegalHoldHandler: failed to marshal legal hold: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Set response headers + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + + // Write XML response + if _, err := w.Write([]byte(xml.Header)); err != nil { + glog.Errorf("GetObjectLegalHoldHandler: failed to write XML header: %v", err) + return + } + + if _, err := w.Write(legalHoldXML); err != nil { + glog.Errorf("GetObjectLegalHoldHandler: failed to write legal hold XML: %v", err) + return + } + + // Record metrics + stats_collect.RecordBucketActiveTime(bucket) + + glog.V(3).Infof("GetObjectLegalHoldHandler: successfully retrieved legal hold for %s/%s", bucket, object) +} diff --git a/weed/s3api/s3api_object_handlers_put.go b/weed/s3api/s3api_object_handlers_put.go index 1d9fe9f92..50d308566 100644 --- a/weed/s3api/s3api_object_handlers_put.go +++ b/weed/s3api/s3api_object_handlers_put.go @@ -12,12 +12,11 @@ import ( "time" "github.com/pquerna/cachecontrol/cacheobject" + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" "github.com/seaweedfs/seaweedfs/weed/security" - - "github.com/seaweedfs/seaweedfs/weed/glog" - "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" weed_server "github.com/seaweedfs/seaweedfs/weed/server" stats_collect "github.com/seaweedfs/seaweedfs/weed/stats" ) @@ -32,6 +31,8 @@ var ( ErrObjectLockModeRequiresDate = errors.New("object lock mode requires retention until date") ErrRetentionDateRequiresMode = errors.New("retention until date requires object lock mode") ErrGovernanceBypassVersioningRequired = errors.New("governance bypass header can only be used on versioned buckets") + ErrInvalidObjectLockDuration = errors.New("object lock duration must be greater than 0 days") + ErrObjectLockDurationExceeded = errors.New("object lock duration exceeds maximum allowed days") ) func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request) { @@ -374,28 +375,30 @@ func (s3a *S3ApiServer) updateLatestVersionInDirectory(bucket, object, versionId } // extractObjectLockMetadataFromRequest extracts object lock headers from PUT requests -// and stores them in the entry's Extended attributes +// and applies bucket default retention if no explicit retention is provided func (s3a *S3ApiServer) extractObjectLockMetadataFromRequest(r *http.Request, entry *filer_pb.Entry) error { if entry.Extended == nil { entry.Extended = make(map[string][]byte) } - // Extract object lock mode (GOVERNANCE or COMPLIANCE) - if mode := r.Header.Get(s3_constants.AmzObjectLockMode); mode != "" { - entry.Extended[s3_constants.ExtObjectLockModeKey] = []byte(mode) - glog.V(2).Infof("extractObjectLockMetadataFromRequest: storing object lock mode: %s", mode) + // Extract explicit object lock mode (GOVERNANCE or COMPLIANCE) + explicitMode := r.Header.Get(s3_constants.AmzObjectLockMode) + if explicitMode != "" { + entry.Extended[s3_constants.ExtObjectLockModeKey] = []byte(explicitMode) + glog.V(2).Infof("extractObjectLockMetadataFromRequest: storing explicit object lock mode: %s", explicitMode) } - // Extract retention until date - if retainUntilDate := r.Header.Get(s3_constants.AmzObjectLockRetainUntilDate); retainUntilDate != "" { + // Extract explicit retention until date + explicitRetainUntilDate := r.Header.Get(s3_constants.AmzObjectLockRetainUntilDate) + if explicitRetainUntilDate != "" { // Parse the ISO8601 date and convert to Unix timestamp for storage - parsedTime, err := time.Parse(time.RFC3339, retainUntilDate) + parsedTime, err := time.Parse(time.RFC3339, explicitRetainUntilDate) if err != nil { glog.Errorf("extractObjectLockMetadataFromRequest: failed to parse retention until date, expected format: %s, error: %v", time.RFC3339, err) return ErrInvalidRetentionDateFormat } entry.Extended[s3_constants.ExtRetentionUntilDateKey] = []byte(strconv.FormatInt(parsedTime.Unix(), 10)) - glog.V(2).Infof("extractObjectLockMetadataFromRequest: storing retention until date (timestamp: %d)", parsedTime.Unix()) + glog.V(2).Infof("extractObjectLockMetadataFromRequest: storing explicit retention until date (timestamp: %d)", parsedTime.Unix()) } // Extract legal hold status @@ -410,6 +413,78 @@ func (s3a *S3ApiServer) extractObjectLockMetadataFromRequest(r *http.Request, en } } + // Apply bucket default retention if no explicit retention was provided + // This implements AWS S3 behavior where bucket default retention automatically applies to new objects + if explicitMode == "" && explicitRetainUntilDate == "" { + bucket, _ := s3_constants.GetBucketAndObject(r) + if err := s3a.applyBucketDefaultRetention(bucket, entry); err != nil { + glog.V(2).Infof("extractObjectLockMetadataFromRequest: skipping bucket default retention for %s: %v", bucket, err) + // Don't fail the upload if default retention can't be applied - this matches AWS behavior + } + } + + return nil +} + +// applyBucketDefaultRetention applies bucket default retention settings to a new object +// This implements AWS S3 behavior where bucket default retention automatically applies to new objects +// when no explicit retention headers are provided in the upload request +func (s3a *S3ApiServer) applyBucketDefaultRetention(bucket string, entry *filer_pb.Entry) error { + // Safety check - if bucket config cache is not available, skip default retention + if s3a.bucketConfigCache == nil { + return nil + } + + // Get bucket configuration (getBucketConfig handles caching internally) + bucketConfig, errCode := s3a.getBucketConfig(bucket) + if errCode != s3err.ErrNone { + return fmt.Errorf("failed to get bucket config: %v", errCode) + } + + // Check if bucket has cached Object Lock configuration + if bucketConfig.ObjectLockConfig == nil { + return nil // No Object Lock configuration + } + + objectLockConfig := bucketConfig.ObjectLockConfig + + // Check if there's a default retention rule + if objectLockConfig.Rule == nil || objectLockConfig.Rule.DefaultRetention == nil { + return nil // No default retention configured + } + + defaultRetention := objectLockConfig.Rule.DefaultRetention + + // Validate default retention has required fields + if defaultRetention.Mode == "" { + return fmt.Errorf("default retention missing mode") + } + + if defaultRetention.Days == 0 && defaultRetention.Years == 0 { + return fmt.Errorf("default retention missing period") + } + + // Calculate retention until date based on default retention period + var retainUntilDate time.Time + now := time.Now() + + if defaultRetention.Days > 0 { + retainUntilDate = now.AddDate(0, 0, defaultRetention.Days) + } else if defaultRetention.Years > 0 { + retainUntilDate = now.AddDate(defaultRetention.Years, 0, 0) + } + + // Apply default retention to the object + if entry.Extended == nil { + entry.Extended = make(map[string][]byte) + } + + entry.Extended[s3_constants.ExtObjectLockModeKey] = []byte(defaultRetention.Mode) + entry.Extended[s3_constants.ExtRetentionUntilDateKey] = []byte(strconv.FormatInt(retainUntilDate.Unix(), 10)) + + glog.V(2).Infof("applyBucketDefaultRetention: applied default retention %s until %s for bucket %s", + defaultRetention.Mode, retainUntilDate.Format(time.RFC3339), bucket) + return nil } @@ -493,6 +568,10 @@ func mapValidationErrorToS3Error(err error) s3err.ErrorCode { return s3err.ErrInvalidRequest case errors.Is(err, ErrGovernanceBypassVersioningRequired): return s3err.ErrInvalidRequest + case errors.Is(err, ErrInvalidObjectLockDuration): + return s3err.ErrInvalidRequest + case errors.Is(err, ErrObjectLockDurationExceeded): + return s3err.ErrInvalidRequest default: return s3err.ErrInvalidRequest } diff --git a/weed/s3api/s3api_object_handlers_retention.go b/weed/s3api/s3api_object_handlers_retention.go index e92e821c8..a419b469e 100644 --- a/weed/s3api/s3api_object_handlers_retention.go +++ b/weed/s3api/s3api_object_handlers_retention.go @@ -132,225 +132,3 @@ func (s3a *S3ApiServer) GetObjectRetentionHandler(w http.ResponseWriter, r *http glog.V(3).Infof("GetObjectRetentionHandler: successfully retrieved retention for %s/%s", bucket, object) } - -// PutObjectLegalHoldHandler Put object Legal Hold -// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLegalHold.html -func (s3a *S3ApiServer) PutObjectLegalHoldHandler(w http.ResponseWriter, r *http.Request) { - bucket, object := s3_constants.GetBucketAndObject(r) - glog.V(3).Infof("PutObjectLegalHoldHandler %s %s", bucket, object) - - // Check if Object Lock is available for this bucket (requires versioning) - if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "PutObjectLegalHoldHandler") { - return - } - - // Get version ID from query parameters - versionId := r.URL.Query().Get("versionId") - - // Parse legal hold configuration from request body - legalHold, err := parseObjectLegalHold(r) - if err != nil { - glog.Errorf("PutObjectLegalHoldHandler: failed to parse legal hold config: %v", err) - s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML) - return - } - - // Validate legal hold configuration - if err := validateLegalHold(legalHold); err != nil { - glog.Errorf("PutObjectLegalHoldHandler: invalid legal hold config: %v", err) - s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) - return - } - - // Set legal hold on the object - if err := s3a.setObjectLegalHold(bucket, object, versionId, legalHold); err != nil { - glog.Errorf("PutObjectLegalHoldHandler: failed to set legal hold: %v", err) - - // Handle specific error cases - if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) { - s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) - return - } - - s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) - return - } - - // Record metrics - stats_collect.RecordBucketActiveTime(bucket) - - // Return success (HTTP 200 with no body) - w.WriteHeader(http.StatusOK) - glog.V(3).Infof("PutObjectLegalHoldHandler: successfully set legal hold for %s/%s", bucket, object) -} - -// GetObjectLegalHoldHandler Get object Legal Hold -// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectLegalHold.html -func (s3a *S3ApiServer) GetObjectLegalHoldHandler(w http.ResponseWriter, r *http.Request) { - bucket, object := s3_constants.GetBucketAndObject(r) - glog.V(3).Infof("GetObjectLegalHoldHandler %s %s", bucket, object) - - // Check if Object Lock is available for this bucket (requires versioning) - if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "GetObjectLegalHoldHandler") { - return - } - - // Get version ID from query parameters - versionId := r.URL.Query().Get("versionId") - - // Get legal hold configuration for the object - legalHold, err := s3a.getObjectLegalHold(bucket, object, versionId) - if err != nil { - glog.Errorf("GetObjectLegalHoldHandler: failed to get legal hold: %v", err) - - // Handle specific error cases - if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) { - s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) - return - } - - if errors.Is(err, ErrNoLegalHoldConfiguration) { - s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLegalHold) - return - } - - s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) - return - } - - // Marshal legal hold configuration to XML - legalHoldXML, err := xml.Marshal(legalHold) - if err != nil { - glog.Errorf("GetObjectLegalHoldHandler: failed to marshal legal hold: %v", err) - s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) - return - } - - // Set response headers - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusOK) - - // Write XML response - if _, err := w.Write([]byte(xml.Header)); err != nil { - glog.Errorf("GetObjectLegalHoldHandler: failed to write XML header: %v", err) - return - } - - if _, err := w.Write(legalHoldXML); err != nil { - glog.Errorf("GetObjectLegalHoldHandler: failed to write legal hold XML: %v", err) - return - } - - // Record metrics - stats_collect.RecordBucketActiveTime(bucket) - - glog.V(3).Infof("GetObjectLegalHoldHandler: successfully retrieved legal hold for %s/%s", bucket, object) -} - -// PutObjectLockConfigurationHandler Put object Lock configuration -// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLockConfiguration.html -func (s3a *S3ApiServer) PutObjectLockConfigurationHandler(w http.ResponseWriter, r *http.Request) { - bucket, _ := s3_constants.GetBucketAndObject(r) - glog.V(3).Infof("PutObjectLockConfigurationHandler %s", bucket) - - // Check if Object Lock is available for this bucket (requires versioning) - if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "PutObjectLockConfigurationHandler") { - return - } - - // Parse object lock configuration from request body - config, err := parseObjectLockConfiguration(r) - if err != nil { - glog.Errorf("PutObjectLockConfigurationHandler: failed to parse object lock config: %v", err) - s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML) - return - } - - // Validate object lock configuration - if err := validateObjectLockConfiguration(config); err != nil { - glog.Errorf("PutObjectLockConfigurationHandler: invalid object lock config: %v", err) - s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) - return - } - - // Set object lock configuration on the bucket - errCode := s3a.updateBucketConfig(bucket, func(bucketConfig *BucketConfig) error { - if bucketConfig.Entry.Extended == nil { - bucketConfig.Entry.Extended = make(map[string][]byte) - } - - // Store the configuration as JSON in extended attributes - configXML, err := xml.Marshal(config) - if err != nil { - return err - } - - bucketConfig.Entry.Extended[s3_constants.ExtObjectLockConfigKey] = configXML - - if config.ObjectLockEnabled != "" { - bucketConfig.Entry.Extended[s3_constants.ExtObjectLockEnabledKey] = []byte(config.ObjectLockEnabled) - } - - return nil - }) - - if errCode != s3err.ErrNone { - glog.Errorf("PutObjectLockConfigurationHandler: failed to set object lock config: %v", errCode) - s3err.WriteErrorResponse(w, r, errCode) - return - } - - // Record metrics - stats_collect.RecordBucketActiveTime(bucket) - - // Return success (HTTP 200 with no body) - w.WriteHeader(http.StatusOK) - glog.V(3).Infof("PutObjectLockConfigurationHandler: successfully set object lock config for %s", bucket) -} - -// GetObjectLockConfigurationHandler Get object Lock configuration -// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectLockConfiguration.html -func (s3a *S3ApiServer) GetObjectLockConfigurationHandler(w http.ResponseWriter, r *http.Request) { - bucket, _ := s3_constants.GetBucketAndObject(r) - glog.V(3).Infof("GetObjectLockConfigurationHandler %s", bucket) - - // Get bucket configuration - bucketConfig, errCode := s3a.getBucketConfig(bucket) - if errCode != s3err.ErrNone { - glog.Errorf("GetObjectLockConfigurationHandler: failed to get bucket config: %v", errCode) - s3err.WriteErrorResponse(w, r, errCode) - return - } - - // Check if object lock configuration exists - if bucketConfig.Entry.Extended == nil { - s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLockConfiguration) - return - } - - configXML, exists := bucketConfig.Entry.Extended[s3_constants.ExtObjectLockConfigKey] - if !exists { - s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLockConfiguration) - return - } - - // Set response headers - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusOK) - - // Write XML response - if _, err := w.Write([]byte(xml.Header)); err != nil { - glog.Errorf("GetObjectLockConfigurationHandler: failed to write XML header: %v", err) - return - } - - if _, err := w.Write(configXML); err != nil { - glog.Errorf("GetObjectLockConfigurationHandler: failed to write config XML: %v", err) - return - } - - // Record metrics - stats_collect.RecordBucketActiveTime(bucket) - - glog.V(3).Infof("GetObjectLockConfigurationHandler: successfully retrieved object lock config for %s", bucket) -} diff --git a/weed/s3api/s3api_object_lock_fix_test.go b/weed/s3api/s3api_object_lock_fix_test.go new file mode 100644 index 000000000..e8a3cf6ba --- /dev/null +++ b/weed/s3api/s3api_object_lock_fix_test.go @@ -0,0 +1,90 @@ +package s3api + +import ( + "testing" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/stretchr/testify/assert" +) + +// TestVeeamObjectLockBugFix tests the fix for the bug where GetObjectLockConfigurationHandler +// would return NoSuchObjectLockConfiguration for buckets with no extended attributes, +// even when Object Lock was enabled. This caused Veeam to think Object Lock wasn't supported. +func TestVeeamObjectLockBugFix(t *testing.T) { + + t.Run("Bug case: bucket with no extended attributes", func(t *testing.T) { + // This simulates the bug case where a bucket has no extended attributes at all + // The old code would immediately return NoSuchObjectLockConfiguration + // The new code correctly checks if Object Lock is enabled before returning an error + + bucketConfig := &BucketConfig{ + Name: "test-bucket", + Entry: &filer_pb.Entry{ + Name: "test-bucket", + Extended: nil, // This is the key - no extended attributes + }, + } + + // Simulate the isObjectLockEnabledForBucket logic + enabled := false + if bucketConfig.Entry.Extended != nil { + if enabledBytes, exists := bucketConfig.Entry.Extended[s3_constants.ExtObjectLockEnabledKey]; exists { + enabled = string(enabledBytes) == s3_constants.ObjectLockEnabled || string(enabledBytes) == "true" + } + } + + // Should correctly return false (not enabled) - this would trigger 404 correctly + assert.False(t, enabled, "Object Lock should not be enabled when no extended attributes exist") + }) + + t.Run("Fix verification: bucket with Object Lock enabled via boolean flag", func(t *testing.T) { + // This verifies the fix works when Object Lock is enabled via boolean flag + + bucketConfig := &BucketConfig{ + Name: "test-bucket", + Entry: &filer_pb.Entry{ + Name: "test-bucket", + Extended: map[string][]byte{ + s3_constants.ExtObjectLockEnabledKey: []byte("true"), + }, + }, + } + + // Simulate the isObjectLockEnabledForBucket logic + enabled := false + if bucketConfig.Entry.Extended != nil { + if enabledBytes, exists := bucketConfig.Entry.Extended[s3_constants.ExtObjectLockEnabledKey]; exists { + enabled = string(enabledBytes) == s3_constants.ObjectLockEnabled || string(enabledBytes) == "true" + } + } + + // Should correctly return true (enabled) - this would generate minimal XML response + assert.True(t, enabled, "Object Lock should be enabled when boolean flag is set") + }) + + t.Run("Fix verification: bucket with Object Lock enabled via Enabled constant", func(t *testing.T) { + // Test using the s3_constants.ObjectLockEnabled constant + + bucketConfig := &BucketConfig{ + Name: "test-bucket", + Entry: &filer_pb.Entry{ + Name: "test-bucket", + Extended: map[string][]byte{ + s3_constants.ExtObjectLockEnabledKey: []byte(s3_constants.ObjectLockEnabled), + }, + }, + } + + // Simulate the isObjectLockEnabledForBucket logic + enabled := false + if bucketConfig.Entry.Extended != nil { + if enabledBytes, exists := bucketConfig.Entry.Extended[s3_constants.ExtObjectLockEnabledKey]; exists { + enabled = string(enabledBytes) == s3_constants.ObjectLockEnabled || string(enabledBytes) == "true" + } + } + + // Should correctly return true (enabled) + assert.True(t, enabled, "Object Lock should be enabled when constant is used") + }) +} diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index 5d113c645..0d1ff98b3 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -88,7 +88,7 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl filerGuard: security.NewGuard([]string{}, signingKey, expiresAfterSec, readSigningKey, readExpiresAfterSec), cb: NewCircuitBreaker(option), credentialManager: iam.credentialManager, - bucketConfigCache: NewBucketConfigCache(5 * time.Minute), + bucketConfigCache: NewBucketConfigCache(60 * time.Minute), // Increased TTL since cache is now event-driven } if option.Config != "" { @@ -286,8 +286,8 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) { bucket.Methods(http.MethodPut).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutBucketVersioningHandler, ACTION_WRITE)), "PUT")).Queries("versioning", "") // GetObjectLockConfiguration / PutObjectLockConfiguration (bucket-level operations) - bucket.Methods(http.MethodGet).Path("/").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectLockConfigurationHandler, ACTION_READ)), "GET")).Queries("object-lock", "") - bucket.Methods(http.MethodPut).Path("/").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectLockConfigurationHandler, ACTION_WRITE)), "PUT")).Queries("object-lock", "") + bucket.Methods(http.MethodGet).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectLockConfigurationHandler, ACTION_READ)), "GET")).Queries("object-lock", "") + bucket.Methods(http.MethodPut).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectLockConfigurationHandler, ACTION_WRITE)), "PUT")).Queries("object-lock", "") // GetBucketTagging bucket.Methods(http.MethodGet).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetBucketTaggingHandler, ACTION_TAGGING)), "GET")).Queries("tagging", "")