feat: add ACL and Lifecycle support
Implements ACL and Lifecycle management capabilities (v0.16.0) including: - Bucket APIs: PutBucketAcl, GetBucketAcl, PutBucketLifecycle, GetBucketLifecycle, DeleteBucketLifecycle - Object APIs: PutObjectAcl, GetObjectAcl - Integration: Support for Canned ACLs (private, public-read, etc.) and Full Access Control Policies - Lifecycle Configuration: Rules, Filters, Expiration, Transitions Verified integration with MinIO: - Note: MinIO returns 501 Not Implemented for Canned ACLs via headers, so tests for those are skipped while Custom ACLs (XML) are fully verified. Includes comprehensive integration tests covering all operations. Updated ROADMAP.md to mark v0.16.0 as completed.
This commit is contained in:
+9
-8
@@ -146,20 +146,21 @@ Features Implemented:
|
||||
|
||||
---
|
||||
|
||||
## v0.16.0 - ACLs & Lifecycle
|
||||
## v0.16.0 - ACLs & Lifecycle ✅ COMPLETED
|
||||
**Scope**: Advanced management
|
||||
**Size**: Medium (9 APIs, ~500 LOC)
|
||||
**Released**: Nov 27, 2025
|
||||
|
||||
ACLs:
|
||||
- [ ] PutObjectAcl
|
||||
- [ ] GetObjectAcl
|
||||
- [ ] PutBucketAcl
|
||||
- [ ] GetBucketAcl
|
||||
- [x] PutObjectAcl
|
||||
- [x] GetObjectAcl
|
||||
- [x] PutBucketAcl
|
||||
- [x] GetBucketAcl
|
||||
|
||||
Lifecycle:
|
||||
- [ ] PutBucketLifecycle
|
||||
- [ ] GetBucketLifecycle
|
||||
- [ ] DeleteBucketLifecycle
|
||||
- [x] PutBucketLifecycle
|
||||
- [x] GetBucketLifecycle
|
||||
- [x] DeleteBucketLifecycle
|
||||
|
||||
**Why**: Grouped advanced features. Completes library API surface.
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package simples3
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBucketAcl(t *testing.T) {
|
||||
s3 := setupTestS3(t)
|
||||
bucketName := fmt.Sprintf("simples3-test-acl-%d", time.Now().UnixNano())
|
||||
|
||||
// Create bucket
|
||||
_, err := s3.CreateBucket(CreateBucketInput{Bucket: bucketName})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create bucket: %v", err)
|
||||
}
|
||||
defer s3.DeleteBucket(DeleteBucketInput{Bucket: bucketName})
|
||||
|
||||
// 1. Test Canned ACL
|
||||
t.Run("CannedACL", func(t *testing.T) {
|
||||
err := s3.PutBucketAcl(PutBucketAclInput{
|
||||
Bucket: bucketName,
|
||||
CannedACL: "public-read",
|
||||
})
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "status code: 501 Not Implemented") {
|
||||
t.Skipf("Skipping CannedACL test: backend returned 501 Not Implemented")
|
||||
}
|
||||
t.Fatalf("PutBucketAcl (canned) failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify
|
||||
acl, err := s3.GetBucketAcl(bucketName)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBucketAcl failed: %v", err)
|
||||
}
|
||||
|
||||
if len(acl.AccessControlList) == 0 {
|
||||
t.Errorf("Expected ACL grants, got none")
|
||||
}
|
||||
t.Logf("Bucket ACL Owner: %s", acl.Owner.DisplayName)
|
||||
})
|
||||
|
||||
// 2. Test Custom ACL (AccessControlPolicy)
|
||||
// We need the owner ID first
|
||||
acl, err := s3.GetBucketAcl(bucketName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get initial ACL: %v", err)
|
||||
}
|
||||
|
||||
t.Run("CustomACL", func(t *testing.T) {
|
||||
// Create a policy that gives FULL_CONTROL to the owner
|
||||
policy := &AccessControlPolicy{
|
||||
Owner: acl.Owner,
|
||||
AccessControlList: []Grant{
|
||||
{
|
||||
Grantee: Grantee{
|
||||
Type: "CanonicalUser",
|
||||
ID: acl.Owner.ID,
|
||||
DisplayName: acl.Owner.DisplayName,
|
||||
},
|
||||
Permission: "FULL_CONTROL",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := s3.PutBucketAcl(PutBucketAclInput{
|
||||
Bucket: bucketName,
|
||||
AccessControlPolicy: policy,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PutBucketAcl (custom) failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify
|
||||
newAcl, err := s3.GetBucketAcl(bucketName)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBucketAcl failed: %v", err)
|
||||
}
|
||||
|
||||
if len(newAcl.AccessControlList) != 1 {
|
||||
t.Errorf("Expected 1 grant, got %d", len(newAcl.AccessControlList))
|
||||
}
|
||||
if newAcl.AccessControlList[0].Permission != "FULL_CONTROL" {
|
||||
t.Errorf("Expected FULL_CONTROL, got %s", newAcl.AccessControlList[0].Permission)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestObjectAcl(t *testing.T) {
|
||||
s3 := setupTestS3(t)
|
||||
bucketName := fmt.Sprintf("simples3-test-obj-acl-%d", time.Now().UnixNano())
|
||||
objectKey := "test-object.txt"
|
||||
|
||||
// Create bucket
|
||||
_, err := s3.CreateBucket(CreateBucketInput{Bucket: bucketName})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create bucket: %v", err)
|
||||
}
|
||||
defer s3.DeleteBucket(DeleteBucketInput{Bucket: bucketName})
|
||||
|
||||
// Upload object
|
||||
_, err = s3.FileUpload(UploadInput{
|
||||
Bucket: bucketName,
|
||||
ObjectKey: objectKey,
|
||||
Body: bytes.NewReader([]byte("test content")),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to upload object: %v", err)
|
||||
}
|
||||
defer s3.FileDelete(DeleteInput{Bucket: bucketName, ObjectKey: objectKey})
|
||||
|
||||
// 1. Test Canned ACL
|
||||
t.Run("CannedACL", func(t *testing.T) {
|
||||
err := s3.PutObjectAcl(PutObjectAclInput{
|
||||
Bucket: bucketName,
|
||||
ObjectKey: objectKey,
|
||||
CannedACL: "public-read",
|
||||
})
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "status code: 501 Not Implemented") {
|
||||
t.Skipf("Skipping CannedACL test: backend returned 501 Not Implemented")
|
||||
}
|
||||
t.Fatalf("PutObjectAcl (canned) failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify
|
||||
acl, err := s3.GetObjectAcl(GetObjectAclInput{
|
||||
Bucket: bucketName,
|
||||
ObjectKey: objectKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetObjectAcl failed: %v", err)
|
||||
}
|
||||
|
||||
if len(acl.AccessControlList) == 0 {
|
||||
t.Errorf("Expected ACL grants, got none")
|
||||
}
|
||||
})
|
||||
|
||||
// 2. Test Custom ACL (AccessControlPolicy)
|
||||
// We need the owner ID first
|
||||
acl, err := s3.GetObjectAcl(GetObjectAclInput{
|
||||
Bucket: bucketName,
|
||||
ObjectKey: objectKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get initial Object ACL: %v", err)
|
||||
}
|
||||
|
||||
t.Run("CustomACL", func(t *testing.T) {
|
||||
// Create a policy that gives FULL_CONTROL to the owner
|
||||
policy := &AccessControlPolicy{
|
||||
Owner: acl.Owner,
|
||||
AccessControlList: []Grant{
|
||||
{
|
||||
Grantee: Grantee{
|
||||
Type: "CanonicalUser",
|
||||
ID: acl.Owner.ID,
|
||||
DisplayName: acl.Owner.DisplayName,
|
||||
},
|
||||
Permission: "FULL_CONTROL",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := s3.PutObjectAcl(PutObjectAclInput{
|
||||
Bucket: bucketName,
|
||||
ObjectKey: objectKey,
|
||||
AccessControlPolicy: policy,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PutObjectAcl (custom) failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify
|
||||
newAcl, err := s3.GetObjectAcl(GetObjectAclInput{
|
||||
Bucket: bucketName,
|
||||
ObjectKey: objectKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetObjectAcl failed: %v", err)
|
||||
}
|
||||
|
||||
if len(newAcl.AccessControlList) != 1 {
|
||||
t.Errorf("Expected 1 grant, got %d", len(newAcl.AccessControlList))
|
||||
}
|
||||
if newAcl.AccessControlList[0].Permission != "FULL_CONTROL" {
|
||||
t.Errorf("Expected FULL_CONTROL, got %s", newAcl.AccessControlList[0].Permission)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBucketLifecycle(t *testing.T) {
|
||||
s3 := setupTestS3(t)
|
||||
bucketName := fmt.Sprintf("simples3-test-lifecycle-%d", time.Now().UnixNano())
|
||||
|
||||
// Create bucket
|
||||
_, err := s3.CreateBucket(CreateBucketInput{Bucket: bucketName})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create bucket: %v", err)
|
||||
}
|
||||
defer s3.DeleteBucket(DeleteBucketInput{Bucket: bucketName})
|
||||
|
||||
// 1. Test PutLifecycle
|
||||
t.Run("PutLifecycle", func(t *testing.T) {
|
||||
config := &LifecycleConfiguration{
|
||||
Rules: []LifecycleRule{
|
||||
{
|
||||
ID: "Rule1",
|
||||
Status: "Enabled",
|
||||
Filter: &LifecycleFilter{
|
||||
Prefix: "logs/",
|
||||
},
|
||||
Expiration: &LifecycleExpiration{
|
||||
Days: 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := s3.PutBucketLifecycle(PutBucketLifecycleInput{
|
||||
Bucket: bucketName,
|
||||
Configuration: config,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PutBucketLifecycle failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify
|
||||
fetchedConfig, err := s3.GetBucketLifecycle(bucketName)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBucketLifecycle failed: %v", err)
|
||||
}
|
||||
|
||||
if len(fetchedConfig.Rules) != 1 {
|
||||
t.Errorf("Expected 1 rule, got %d", len(fetchedConfig.Rules))
|
||||
}
|
||||
if fetchedConfig.Rules[0].ID != "Rule1" {
|
||||
t.Errorf("Expected Rule1, got %s", fetchedConfig.Rules[0].ID)
|
||||
}
|
||||
if fetchedConfig.Rules[0].Filter == nil || fetchedConfig.Rules[0].Filter.Prefix != "logs/" {
|
||||
t.Errorf("Expected prefix 'logs/', got %v", fetchedConfig.Rules[0].Filter)
|
||||
}
|
||||
})
|
||||
|
||||
// 2. Test DeleteLifecycle
|
||||
t.Run("DeleteLifecycle", func(t *testing.T) {
|
||||
err := s3.DeleteBucketLifecycle(DeleteBucketInput{
|
||||
Bucket: bucketName,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteBucketLifecycle failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify
|
||||
_, err = s3.GetBucketLifecycle(bucketName)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error after deleting lifecycle, got nil")
|
||||
} else {
|
||||
// Should verify it's a 404/NoSuchLifecycleConfiguration error
|
||||
// The exact error message depends on implementation, but we expect an error
|
||||
t.Logf("Got expected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -435,3 +435,506 @@ func (s3 *S3) GetBucketVersioning(bucket string) (GetBucketVersioningOutput, err
|
||||
MfaDelete: config.MfaDelete,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- ACL Operations ---
|
||||
|
||||
// AccessControlPolicy represents the Access Control List (ACL) for a bucket or object.
|
||||
type AccessControlPolicy struct {
|
||||
XMLName xml.Name `xml:"AccessControlPolicy"`
|
||||
XMLNS string `xml:"xmlns,attr,omitempty"`
|
||||
Owner Owner `xml:"Owner"`
|
||||
AccessControlList []Grant `xml:"AccessControlList>Grant"`
|
||||
}
|
||||
|
||||
// Owner represents the owner of the bucket or object.
|
||||
type Owner struct {
|
||||
ID string `xml:"ID,omitempty"`
|
||||
DisplayName string `xml:"DisplayName,omitempty"`
|
||||
}
|
||||
|
||||
// Grant represents a permission grant.
|
||||
type Grant struct {
|
||||
Grantee Grantee `xml:"Grantee"`
|
||||
Permission string `xml:"Permission"`
|
||||
}
|
||||
|
||||
// Grantee represents the recipient of the permission grant.
|
||||
type Grantee struct {
|
||||
XMLNS string `xml:"xmlns:xsi,attr,omitempty"`
|
||||
Type string `xml:"xsi:type,attr"` // CanonicalUser, AmazonCustomerByEmail, Group
|
||||
ID string `xml:"ID,omitempty"`
|
||||
DisplayName string `xml:"DisplayName,omitempty"`
|
||||
URI string `xml:"URI,omitempty"` // For Group
|
||||
EmailAddress string `xml:"EmailAddress,omitempty"` // For AmazonCustomerByEmail
|
||||
}
|
||||
|
||||
// PutBucketAclInput is passed to PutBucketAcl.
|
||||
type PutBucketAclInput struct {
|
||||
// Required: The name of the bucket
|
||||
Bucket string
|
||||
|
||||
// Optional: Canned ACL (private, public-read, public-read-write, authenticated-read)
|
||||
// If CannedACL is set, AccessControlPolicy is ignored.
|
||||
CannedACL string
|
||||
|
||||
// Optional: Full Access Control Policy
|
||||
AccessControlPolicy *AccessControlPolicy
|
||||
}
|
||||
|
||||
// PutBucketAcl sets the Access Control List (ACL) for a bucket.
|
||||
// You can either use a CannedACL OR provide a full AccessControlPolicy.
|
||||
func (s3 *S3) PutBucketAcl(input PutBucketAclInput) error {
|
||||
// Validate input
|
||||
if input.Bucket == "" {
|
||||
return fmt.Errorf("bucket name is required")
|
||||
}
|
||||
if input.CannedACL == "" && input.AccessControlPolicy == nil {
|
||||
return fmt.Errorf("either CannedACL or AccessControlPolicy must be provided")
|
||||
}
|
||||
|
||||
// Renew IAM token if needed
|
||||
if err := s3.renewIAMToken(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build URL with ?acl query parameter
|
||||
baseURL := s3.getURL(input.Bucket)
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parsedURL.RawQuery = "acl"
|
||||
|
||||
var req *http.Request
|
||||
var bodyReader io.Reader
|
||||
var contentMD5 string
|
||||
var sha256Hash string
|
||||
|
||||
if input.AccessControlPolicy != nil {
|
||||
// Use custom ACL body
|
||||
input.AccessControlPolicy.XMLNS = "http://s3.amazonaws.com/doc/2006-03-01/"
|
||||
// Ensure namespace is set on Grantees if needed, though usually top level is enough.
|
||||
// Some S3 implementations require xsi namespace.
|
||||
for i := range input.AccessControlPolicy.AccessControlList {
|
||||
input.AccessControlPolicy.AccessControlList[i].Grantee.XMLNS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
}
|
||||
|
||||
xmlBody, err := xml.Marshal(input.AccessControlPolicy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bodyReader = bytes.NewReader(xmlBody)
|
||||
|
||||
// Calculate MD5
|
||||
md5Sum := md5.Sum(xmlBody)
|
||||
contentMD5 = base64.StdEncoding.EncodeToString(md5Sum[:])
|
||||
|
||||
// Calculate SHA256
|
||||
h := sha256.New()
|
||||
h.Write(xmlBody)
|
||||
sha256Hash = fmt.Sprintf("%x", h.Sum(nil))
|
||||
|
||||
req, err = http.NewRequest(http.MethodPut, parsedURL.String(), bodyReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.ContentLength = int64(len(xmlBody))
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
req.Header.Set("Content-MD5", contentMD5)
|
||||
req.Header.Set("x-amz-content-sha256", sha256Hash)
|
||||
|
||||
} else {
|
||||
// Use Canned ACL via header
|
||||
req, err = http.NewRequest(http.MethodPut, parsedURL.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("x-amz-acl", input.CannedACL)
|
||||
// Empty body SHA256
|
||||
h := sha256.New()
|
||||
sha256Hash = fmt.Sprintf("%x", h.Sum(nil))
|
||||
req.Header.Set("x-amz-content-sha256", sha256Hash)
|
||||
}
|
||||
|
||||
req.Header.Set("Host", req.URL.Host)
|
||||
|
||||
// Sign the request
|
||||
if err := s3.signRequest(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Execute request
|
||||
client := s3.getClient()
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
res.Body.Close()
|
||||
io.Copy(io.Discard, res.Body)
|
||||
}()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle non-OK status codes
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("status code: %s: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBucketAcl gets the Access Control List (ACL) for a bucket.
|
||||
func (s3 *S3) GetBucketAcl(bucket string) (AccessControlPolicy, error) {
|
||||
// Validate input
|
||||
if bucket == "" {
|
||||
return AccessControlPolicy{}, fmt.Errorf("bucket name is required")
|
||||
}
|
||||
|
||||
// Renew IAM token if needed
|
||||
if err := s3.renewIAMToken(); err != nil {
|
||||
return AccessControlPolicy{}, err
|
||||
}
|
||||
|
||||
// Build URL with ?acl query parameter
|
||||
baseURL := s3.getURL(bucket)
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return AccessControlPolicy{}, err
|
||||
}
|
||||
parsedURL.RawQuery = "acl"
|
||||
|
||||
// Create GET request
|
||||
req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil)
|
||||
if err != nil {
|
||||
return AccessControlPolicy{}, err
|
||||
}
|
||||
|
||||
// Sign the request
|
||||
if err := s3.signRequest(req); err != nil {
|
||||
return AccessControlPolicy{}, err
|
||||
}
|
||||
|
||||
// Execute request
|
||||
client := s3.getClient()
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return AccessControlPolicy{}, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
res.Body.Close()
|
||||
io.Copy(io.Discard, res.Body)
|
||||
}()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return AccessControlPolicy{}, err
|
||||
}
|
||||
|
||||
// Handle non-OK status codes
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return AccessControlPolicy{}, fmt.Errorf("status code: %s: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
// Parse XML response
|
||||
var policy AccessControlPolicy
|
||||
if err := xml.Unmarshal(body, &policy); err != nil {
|
||||
return AccessControlPolicy{}, err
|
||||
}
|
||||
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// --- Lifecycle Operations ---
|
||||
|
||||
// LifecycleConfiguration represents the lifecycle configuration for a bucket.
|
||||
type LifecycleConfiguration struct {
|
||||
XMLName xml.Name `xml:"LifecycleConfiguration"`
|
||||
XMLNS string `xml:"xmlns,attr,omitempty"`
|
||||
Rules []LifecycleRule `xml:"Rule"`
|
||||
}
|
||||
|
||||
// LifecycleRule represents a single lifecycle rule.
|
||||
type LifecycleRule struct {
|
||||
ID string `xml:"ID,omitempty"`
|
||||
Status string `xml:"Status"` // Enabled or Disabled
|
||||
Filter *LifecycleFilter `xml:"Filter,omitempty"`
|
||||
// Legacy Prefix support (S3 supports both, but prefer Filter for V2)
|
||||
// If Prefix is provided here, do not use Filter.
|
||||
Prefix *string `xml:"Prefix,omitempty"`
|
||||
Expiration *LifecycleExpiration `xml:"Expiration,omitempty"`
|
||||
Transitions []LifecycleTransition `xml:"Transition,omitempty"`
|
||||
NoncurrentVersionExpiration *LifecycleNoncurrentVersionExpiration `xml:"NoncurrentVersionExpiration,omitempty"`
|
||||
NoncurrentVersionTransitions []LifecycleNoncurrentVersionTransition `xml:"NoncurrentVersionTransition,omitempty"`
|
||||
AbortIncompleteMultipartUpload *LifecycleAbortIncompleteMultipartUpload `xml:"AbortIncompleteMultipartUpload,omitempty"`
|
||||
}
|
||||
|
||||
// LifecycleFilter represents the filter for a lifecycle rule.
|
||||
type LifecycleFilter struct {
|
||||
Prefix string `xml:"Prefix,omitempty"`
|
||||
Tag *Tag `xml:"Tag,omitempty"`
|
||||
And *struct {
|
||||
Prefix string `xml:"Prefix,omitempty"`
|
||||
Tags []Tag `xml:"Tag,omitempty"`
|
||||
} `xml:"And,omitempty"`
|
||||
}
|
||||
|
||||
// LifecycleExpiration represents the expiration action.
|
||||
type LifecycleExpiration struct {
|
||||
Date string `xml:"Date,omitempty"` // ISO 8601 format
|
||||
Days int `xml:"Days,omitempty"`
|
||||
ExpiredObjectDeleteMarker bool `xml:"ExpiredObjectDeleteMarker,omitempty"`
|
||||
}
|
||||
|
||||
// LifecycleTransition represents a transition action.
|
||||
type LifecycleTransition struct {
|
||||
Date string `xml:"Date,omitempty"`
|
||||
Days int `xml:"Days,omitempty"`
|
||||
StorageClass string `xml:"StorageClass"`
|
||||
}
|
||||
|
||||
// LifecycleNoncurrentVersionExpiration represents expiration for noncurrent versions.
|
||||
type LifecycleNoncurrentVersionExpiration struct {
|
||||
NoncurrentDays int `xml:"NoncurrentDays"`
|
||||
}
|
||||
|
||||
// LifecycleNoncurrentVersionTransition represents transition for noncurrent versions.
|
||||
type LifecycleNoncurrentVersionTransition struct {
|
||||
NoncurrentDays int `xml:"NoncurrentDays"`
|
||||
StorageClass string `xml:"StorageClass"`
|
||||
}
|
||||
|
||||
// LifecycleAbortIncompleteMultipartUpload represents abort action for incomplete uploads.
|
||||
type LifecycleAbortIncompleteMultipartUpload struct {
|
||||
DaysAfterInitiation int `xml:"DaysAfterInitiation"`
|
||||
}
|
||||
|
||||
// PutBucketLifecycleInput is passed to PutBucketLifecycle.
|
||||
type PutBucketLifecycleInput struct {
|
||||
// Required: The name of the bucket
|
||||
Bucket string
|
||||
|
||||
// Required: The lifecycle configuration
|
||||
Configuration *LifecycleConfiguration
|
||||
}
|
||||
|
||||
// PutBucketLifecycle sets the lifecycle configuration for a bucket.
|
||||
func (s3 *S3) PutBucketLifecycle(input PutBucketLifecycleInput) error {
|
||||
// Validate input
|
||||
if input.Bucket == "" {
|
||||
return fmt.Errorf("bucket name is required")
|
||||
}
|
||||
if input.Configuration == nil || len(input.Configuration.Rules) == 0 {
|
||||
return fmt.Errorf("lifecycle configuration with at least one rule is required")
|
||||
}
|
||||
|
||||
// Renew IAM token if needed
|
||||
if err := s3.renewIAMToken(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build URL with ?lifecycle query parameter
|
||||
baseURL := s3.getURL(input.Bucket)
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parsedURL.RawQuery = "lifecycle"
|
||||
|
||||
// Set XML Namespace
|
||||
input.Configuration.XMLNS = "http://s3.amazonaws.com/doc/2006-03-01/"
|
||||
|
||||
xmlBody, err := xml.Marshal(input.Configuration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate MD5
|
||||
md5Hash := md5.Sum(xmlBody)
|
||||
contentMD5 := base64.StdEncoding.EncodeToString(md5Hash[:])
|
||||
|
||||
// Calculate SHA256
|
||||
h := sha256.New()
|
||||
h.Write(xmlBody)
|
||||
sha256Hash := fmt.Sprintf("%x", h.Sum(nil))
|
||||
|
||||
// Create PUT request
|
||||
req, err := http.NewRequest(http.MethodPut, parsedURL.String(), bytes.NewReader(xmlBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.ContentLength = int64(len(xmlBody))
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
req.Header.Set("Content-MD5", contentMD5)
|
||||
req.Header.Set("x-amz-content-sha256", sha256Hash)
|
||||
req.Header.Set("Host", req.URL.Host)
|
||||
|
||||
// Sign the request
|
||||
if err := s3.signRequest(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Execute request
|
||||
client := s3.getClient()
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
res.Body.Close()
|
||||
io.Copy(io.Discard, res.Body)
|
||||
}()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle non-OK status codes
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("status code: %s: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBucketLifecycle gets the lifecycle configuration for a bucket.
|
||||
func (s3 *S3) GetBucketLifecycle(bucket string) (LifecycleConfiguration, error) {
|
||||
// Validate input
|
||||
if bucket == "" {
|
||||
return LifecycleConfiguration{}, fmt.Errorf("bucket name is required")
|
||||
}
|
||||
|
||||
// Renew IAM token if needed
|
||||
if err := s3.renewIAMToken(); err != nil {
|
||||
return LifecycleConfiguration{}, err
|
||||
}
|
||||
|
||||
// Build URL with ?lifecycle query parameter
|
||||
baseURL := s3.getURL(bucket)
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return LifecycleConfiguration{}, err
|
||||
}
|
||||
parsedURL.RawQuery = "lifecycle"
|
||||
|
||||
// Create GET request
|
||||
req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil)
|
||||
if err != nil {
|
||||
return LifecycleConfiguration{}, err
|
||||
}
|
||||
|
||||
// Sign the request
|
||||
if err := s3.signRequest(req); err != nil {
|
||||
return LifecycleConfiguration{}, err
|
||||
}
|
||||
|
||||
// Execute request
|
||||
client := s3.getClient()
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return LifecycleConfiguration{}, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
res.Body.Close()
|
||||
io.Copy(io.Discard, res.Body)
|
||||
}()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return LifecycleConfiguration{}, err
|
||||
}
|
||||
|
||||
// Handle non-OK status codes
|
||||
if res.StatusCode != http.StatusOK {
|
||||
// S3 returns 404 if no lifecycle configuration exists
|
||||
if res.StatusCode == http.StatusNotFound {
|
||||
// Check if it's actually "NoSuchLifecycleConfiguration"
|
||||
if strings.Contains(string(body), "NoSuchLifecycleConfiguration") {
|
||||
return LifecycleConfiguration{}, fmt.Errorf("no lifecycle configuration found")
|
||||
}
|
||||
}
|
||||
return LifecycleConfiguration{}, fmt.Errorf("status code: %s: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
// Parse XML response
|
||||
var config LifecycleConfiguration
|
||||
if err := xml.Unmarshal(body, &config); err != nil {
|
||||
return LifecycleConfiguration{}, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// DeleteBucketLifecycle deletes the lifecycle configuration for a bucket.
|
||||
func (s3 *S3) DeleteBucketLifecycle(input DeleteBucketInput) error {
|
||||
// Reuse DeleteBucketInput since it just needs the bucket name
|
||||
|
||||
// Validate input
|
||||
if input.Bucket == "" {
|
||||
return fmt.Errorf("bucket name is required")
|
||||
}
|
||||
|
||||
// Renew IAM token if needed
|
||||
if err := s3.renewIAMToken(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build URL with ?lifecycle query parameter
|
||||
baseURL := s3.getURL(input.Bucket)
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parsedURL.RawQuery = "lifecycle"
|
||||
|
||||
// Create DELETE request
|
||||
req, err := http.NewRequest(http.MethodDelete, parsedURL.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Sign the request
|
||||
if err := s3.signRequest(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Execute request
|
||||
client := s3.getClient()
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
res.Body.Close()
|
||||
io.Copy(io.Discard, res.Body)
|
||||
}()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle non-success status codes
|
||||
// AWS returns 204 No Content on successful deletion
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("status code: %s: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -777,3 +777,223 @@ func (s3 *S3) DeleteObjects(input DeleteObjectsInput) (DeleteObjectsOutput, erro
|
||||
Errors: result.Errors,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- ACL Operations ---
|
||||
|
||||
// PutObjectAclInput is passed to PutObjectAcl.
|
||||
type PutObjectAclInput struct {
|
||||
// Required: The name of the bucket
|
||||
Bucket string
|
||||
|
||||
// Required: The object key
|
||||
ObjectKey string
|
||||
|
||||
// Optional: Version ID of the object
|
||||
VersionId string
|
||||
|
||||
// Optional: Canned ACL (private, public-read, public-read-write, authenticated-read)
|
||||
// If CannedACL is set, AccessControlPolicy is ignored.
|
||||
CannedACL string
|
||||
|
||||
// Optional: Full Access Control Policy
|
||||
AccessControlPolicy *AccessControlPolicy
|
||||
}
|
||||
|
||||
// PutObjectAcl sets the Access Control List (ACL) for an object.
|
||||
// You can either use a CannedACL OR provide a full AccessControlPolicy.
|
||||
func (s3 *S3) PutObjectAcl(input PutObjectAclInput) error {
|
||||
// Validate input
|
||||
if input.Bucket == "" {
|
||||
return fmt.Errorf("bucket name is required")
|
||||
}
|
||||
if input.ObjectKey == "" {
|
||||
return fmt.Errorf("object key is required")
|
||||
}
|
||||
if input.CannedACL == "" && input.AccessControlPolicy == nil {
|
||||
return fmt.Errorf("either CannedACL or AccessControlPolicy must be provided")
|
||||
}
|
||||
|
||||
// Renew IAM token if needed
|
||||
if err := s3.renewIAMToken(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build URL with ?acl query parameter
|
||||
baseURL := s3.getURL(input.Bucket, input.ObjectKey)
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := parsedURL.Query()
|
||||
q.Set("acl", "")
|
||||
if input.VersionId != "" {
|
||||
q.Set("versionId", input.VersionId)
|
||||
}
|
||||
parsedURL.RawQuery = q.Encode()
|
||||
|
||||
var req *http.Request
|
||||
var bodyReader io.Reader
|
||||
var contentMD5 string
|
||||
var sha256Hash string
|
||||
|
||||
if input.AccessControlPolicy != nil {
|
||||
// Use custom ACL body
|
||||
input.AccessControlPolicy.XMLNS = "http://s3.amazonaws.com/doc/2006-03-01/"
|
||||
// Ensure namespace is set on Grantees if needed
|
||||
for i := range input.AccessControlPolicy.AccessControlList {
|
||||
input.AccessControlPolicy.AccessControlList[i].Grantee.XMLNS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
}
|
||||
|
||||
xmlBody, err := xml.Marshal(input.AccessControlPolicy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bodyReader = bytes.NewReader(xmlBody)
|
||||
|
||||
// Calculate MD5
|
||||
md5Sum := md5.Sum(xmlBody)
|
||||
contentMD5 = base64.StdEncoding.EncodeToString(md5Sum[:])
|
||||
|
||||
// Calculate SHA256
|
||||
h := sha256.New()
|
||||
h.Write(xmlBody)
|
||||
sha256Hash = fmt.Sprintf("%x", h.Sum(nil))
|
||||
|
||||
req, err = http.NewRequest(http.MethodPut, parsedURL.String(), bodyReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.ContentLength = int64(len(xmlBody))
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
req.Header.Set("Content-MD5", contentMD5)
|
||||
req.Header.Set("x-amz-content-sha256", sha256Hash)
|
||||
|
||||
} else {
|
||||
// Use Canned ACL via header
|
||||
req, err = http.NewRequest(http.MethodPut, parsedURL.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("x-amz-acl", input.CannedACL)
|
||||
// Empty body SHA256
|
||||
h := sha256.New()
|
||||
sha256Hash = fmt.Sprintf("%x", h.Sum(nil))
|
||||
req.Header.Set("x-amz-content-sha256", sha256Hash)
|
||||
}
|
||||
|
||||
req.Header.Set("Host", req.URL.Host)
|
||||
|
||||
// Sign the request
|
||||
if err := s3.signRequest(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Execute request
|
||||
client := s3.getClient()
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
res.Body.Close()
|
||||
io.Copy(io.Discard, res.Body)
|
||||
}()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle non-OK status codes
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("status code: %s: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetObjectAclInput is passed to GetObjectAcl.
|
||||
type GetObjectAclInput struct {
|
||||
// Required: The name of the bucket
|
||||
Bucket string
|
||||
|
||||
// Required: The object key
|
||||
ObjectKey string
|
||||
|
||||
// Optional: Version ID of the object
|
||||
VersionId string
|
||||
}
|
||||
|
||||
// GetObjectAcl gets the Access Control List (ACL) for an object.
|
||||
func (s3 *S3) GetObjectAcl(input GetObjectAclInput) (AccessControlPolicy, error) {
|
||||
// Validate input
|
||||
if input.Bucket == "" {
|
||||
return AccessControlPolicy{}, fmt.Errorf("bucket name is required")
|
||||
}
|
||||
if input.ObjectKey == "" {
|
||||
return AccessControlPolicy{}, fmt.Errorf("object key is required")
|
||||
}
|
||||
|
||||
// Renew IAM token if needed
|
||||
if err := s3.renewIAMToken(); err != nil {
|
||||
return AccessControlPolicy{}, err
|
||||
}
|
||||
|
||||
// Build URL with ?acl query parameter
|
||||
baseURL := s3.getURL(input.Bucket, input.ObjectKey)
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return AccessControlPolicy{}, err
|
||||
}
|
||||
q := parsedURL.Query()
|
||||
q.Set("acl", "")
|
||||
if input.VersionId != "" {
|
||||
q.Set("versionId", input.VersionId)
|
||||
}
|
||||
parsedURL.RawQuery = q.Encode()
|
||||
|
||||
// Create GET request
|
||||
req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil)
|
||||
if err != nil {
|
||||
return AccessControlPolicy{}, err
|
||||
}
|
||||
|
||||
// Sign the request
|
||||
if err := s3.signRequest(req); err != nil {
|
||||
return AccessControlPolicy{}, err
|
||||
}
|
||||
|
||||
// Execute request
|
||||
client := s3.getClient()
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return AccessControlPolicy{}, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
res.Body.Close()
|
||||
io.Copy(io.Discard, res.Body)
|
||||
}()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return AccessControlPolicy{}, err
|
||||
}
|
||||
|
||||
// Handle non-OK status codes
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return AccessControlPolicy{}, fmt.Errorf("status code: %s: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
// Parse XML response
|
||||
var policy AccessControlPolicy
|
||||
if err := xml.Unmarshal(body, &policy); err != nil {
|
||||
return AccessControlPolicy{}, err
|
||||
}
|
||||
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
+13
-13
@@ -17,7 +17,7 @@ func TestVersioning(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBucket failed: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Cleanup function
|
||||
defer func() {
|
||||
// List all versions and delete markers to clean up
|
||||
@@ -52,25 +52,25 @@ func TestVersioning(t *testing.T) {
|
||||
}
|
||||
|
||||
key := "test-object.txt"
|
||||
|
||||
|
||||
// 3. Upload v1
|
||||
_, err = s3.FilePut(UploadInput{
|
||||
Bucket: bucket,
|
||||
Bucket: bucket,
|
||||
ObjectKey: key,
|
||||
Body: strings.NewReader("v1"),
|
||||
Body: strings.NewReader("v1"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FilePut v1 failed: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Sleep briefly to ensure timestamp difference (MinIO sometimes has granularity issues)
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// 4. Upload v2
|
||||
_, err = s3.FilePut(UploadInput{
|
||||
Bucket: bucket,
|
||||
Bucket: bucket,
|
||||
ObjectKey: key,
|
||||
Body: strings.NewReader("v2"),
|
||||
Body: strings.NewReader("v2"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FilePut v2 failed: %v", err)
|
||||
@@ -106,7 +106,7 @@ func TestVersioning(t *testing.T) {
|
||||
|
||||
// 6. Download v1 specific version
|
||||
rc, err := s3.FileDownload(DownloadInput{
|
||||
Bucket: bucket,
|
||||
Bucket: bucket,
|
||||
ObjectKey: key,
|
||||
VersionId: v1Id,
|
||||
})
|
||||
@@ -121,7 +121,7 @@ func TestVersioning(t *testing.T) {
|
||||
|
||||
// 7. Download v2 specific version
|
||||
rc, err = s3.FileDownload(DownloadInput{
|
||||
Bucket: bucket,
|
||||
Bucket: bucket,
|
||||
ObjectKey: key,
|
||||
VersionId: v2Id,
|
||||
})
|
||||
@@ -133,10 +133,10 @@ func TestVersioning(t *testing.T) {
|
||||
if string(content) != "v2" {
|
||||
t.Errorf("Expected v2 content 'v2', got '%s'", string(content))
|
||||
}
|
||||
|
||||
|
||||
// 8. Get Details for v1
|
||||
details, err := s3.FileDetails(DetailsInput{
|
||||
Bucket: bucket,
|
||||
Bucket: bucket,
|
||||
ObjectKey: key,
|
||||
VersionId: v1Id,
|
||||
})
|
||||
@@ -149,7 +149,7 @@ func TestVersioning(t *testing.T) {
|
||||
|
||||
// 9. Delete v2 (latest)
|
||||
err = s3.FileDelete(DeleteInput{
|
||||
Bucket: bucket,
|
||||
Bucket: bucket,
|
||||
ObjectKey: key,
|
||||
VersionId: v2Id,
|
||||
})
|
||||
@@ -159,7 +159,7 @@ func TestVersioning(t *testing.T) {
|
||||
|
||||
// 10. Verify current object is v1
|
||||
rc, err = s3.FileDownload(DownloadInput{
|
||||
Bucket: bucket,
|
||||
Bucket: bucket,
|
||||
ObjectKey: key,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user