From f3964a2c8fff77de696d45f339795fcee83283b9 Mon Sep 17 00:00:00 2001 From: Joshua Leuenberger Date: Tue, 28 Apr 2026 07:13:10 +0200 Subject: [PATCH 1/3] feat(bucket): introduce new per bucket fields --- models/buckets.go | 68 ++++++++++++++++++ services/buckets.go | 155 +++++++++++++++++++++++++++++++++++++++++ testing/bucket_mock.go | 97 ++++++++++++++++++++++++++ 3 files changed, 320 insertions(+) diff --git a/models/buckets.go b/models/buckets.go index 0a3aab3..1db6787 100644 --- a/models/buckets.go +++ b/models/buckets.go @@ -52,3 +52,71 @@ type BucketDeleteObjectStatus struct { // initial Object Bytes before the operation InitialObjectBytes *int64 `json:"initialObjectBytes,omitempty"` } + +// BucketUsage represents the per-bucket usage metrics returned by +// GET /org/containers/{bucketName}/usage. +type BucketUsage struct { + // number of objects in the bucket + ObjectCount *int64 `json:"objectCount,omitempty"` + // logical size in bytes of all objects in the bucket + DataBytes *int64 `json:"dataBytes,omitempty"` +} + +// BucketCorsConfiguration wraps the CORS XML configuration for an S3 bucket. +// A nil Cors field disables CORS on the bucket. +type BucketCorsConfiguration struct { + // XML for configuring CORS, or null to disable CORS + Cors *string `json:"cors"` +} + +// BucketNotificationConfiguration wraps the notification XML configuration for an S3 bucket. +// A nil Notification field disables notifications on the bucket. +type BucketNotificationConfiguration struct { + // notification configuration XML, or null to disable notifications + Notification *string `json:"notification"` +} + +// BucketPolicyConfiguration wraps the bucket policy document for an S3 bucket. +// A nil Policy field disables the bucket policy. +type BucketPolicyConfiguration struct { + Policy *BucketPolicy `json:"policy"` +} + +// BucketPolicy represents an S3 bucket policy document. +type BucketPolicy struct { + // Optional policy identifier. + Id string `json:"Id,omitempty"` + // Policy language version (e.g. "2012-10-17" or "2015-09-08"). + Version string `json:"Version,omitempty"` + // One or more policy statements. + Statement []BucketPolicyStatement `json:"Statement"` +} + +// BucketPolicyStatement represents a single statement within a BucketPolicy. +// +// Several fields use interface{} because the S3 policy language allows either a +// single string or a list of strings (and Principal/NotPrincipal additionally +// allow either the string "*" or an object such as {"AWS": "..."}). Callers may +// pass a string, []string, map[string]interface{}, or json.RawMessage as +// appropriate. +type BucketPolicyStatement struct { + // Optional statement identifier. + Sid string `json:"Sid,omitempty"` + // "Allow" or "Deny". + Effect string `json:"Effect"` + // String or []string. Mutually exclusive with NotAction. + Action interface{} `json:"Action,omitempty"` + // String or []string. Mutually exclusive with Action. + NotAction interface{} `json:"NotAction,omitempty"` + // String or []string. Mutually exclusive with NotResource. + Resource interface{} `json:"Resource,omitempty"` + // String or []string. Mutually exclusive with Resource. + NotResource interface{} `json:"NotResource,omitempty"` + // Condition block keyed by condition type, then condition key. + Condition map[string]map[string]interface{} `json:"Condition,omitempty"` + // "*" for anonymous, or an object such as {"AWS": ""}. + // Mutually exclusive with NotPrincipal. + Principal interface{} `json:"Principal,omitempty"` + // "*" or an object. Mutually exclusive with Principal. + NotPrincipal interface{} `json:"NotPrincipal,omitempty"` +} diff --git a/services/buckets.go b/services/buckets.go index c89a161..7e7f70c 100644 --- a/services/buckets.go +++ b/services/buckets.go @@ -17,11 +17,34 @@ type BucketServiceInterface interface { List(ctx context.Context) (*[]models.Bucket, error) GetByName(ctx context.Context, name string) (*models.Bucket, error) Create(ctx context.Context, bucket *models.Bucket) (*models.Bucket, error) + // Deprecated: GetUsage retrieves the full tenant usage and filters by bucket + // name. Use GetBucketUsage instead, which targets the per-bucket usage + // endpoint (/org/containers/{name}/usage). GetUsage(ctx context.Context, name string) (*models.BucketStats, error) Delete(ctx context.Context, name string) error Drain(ctx context.Context, name string) (*models.BucketDeleteObjectStatus, error) CancelDrain(ctx context.Context, name string) (*models.BucketDeleteObjectStatus, error) DrainStatus(ctx context.Context, name string) (*models.BucketDeleteObjectStatus, error) + + // Per-bucket sub-resources + + GetBucketUsage(ctx context.Context, name string) (*models.BucketUsage, error) + GetRegion(ctx context.Context, name string) (string, error) + + GetObjectLock(ctx context.Context, name string) (*models.BucketS3ObjectLockSettings, error) + UpdateObjectLock(ctx context.Context, name string, settings *models.BucketS3ObjectLockSettings) (*models.BucketS3ObjectLockSettings, error) + + GetNotification(ctx context.Context, name string) (*models.BucketNotificationConfiguration, error) + UpdateNotification(ctx context.Context, name string, config *models.BucketNotificationConfiguration) (*models.BucketNotificationConfiguration, error) + + GetPolicy(ctx context.Context, name string) (*models.BucketPolicyConfiguration, error) + UpdatePolicy(ctx context.Context, name string, policy *models.BucketPolicyConfiguration) (*models.BucketPolicyConfiguration, error) + + GetCors(ctx context.Context, name string) (*models.BucketCorsConfiguration, error) + UpdateCors(ctx context.Context, name string, config *models.BucketCorsConfiguration) (*models.BucketCorsConfiguration, error) + + GetCompliance(ctx context.Context, name string) (*models.BucketComplianceSettings, error) + UpdateCompliance(ctx context.Context, name string, settings *models.BucketComplianceSettings) (*models.BucketComplianceSettings, error) } type BucketService struct { @@ -74,6 +97,9 @@ func (s *BucketService) Create(ctx context.Context, bucket *models.Bucket) (*mod return bucket, nil } +// Deprecated: prefer GetBucketUsage which targets the per-bucket usage endpoint +// (/org/containers/{name}/usage) instead of fetching and filtering the full +// tenant usage payload. func (s *BucketService) GetUsage(ctx context.Context, name string) (*models.BucketStats, error) { response := models.Response{} response.Data = &models.TenantUsage{} @@ -147,3 +173,132 @@ func (s *BucketService) DrainStatus(ctx context.Context, name string) (*models.B return deleteObjectStatus, nil } + +// bucketSubresource builds the URL path for a per-bucket sub-resource endpoint. +func bucketSubresource(name, subresource string) string { + return bucketEndpoint + "/" + name + "/" + subresource +} + +// GetBucketUsage retrieves the per-bucket usage metrics from +// GET /org/containers/{name}/usage. +func (s *BucketService) GetBucketUsage(ctx context.Context, name string) (*models.BucketUsage, error) { + response := models.Response{} + response.Data = &models.BucketUsage{} + if err := s.client.DoParsed(ctx, "GET", bucketSubresource(name, "usage"), nil, &response); err != nil { + return nil, err + } + return response.Data.(*models.BucketUsage), nil +} + +// GetRegion retrieves the region for a bucket from GET /org/containers/{name}/region. +func (s *BucketService) GetRegion(ctx context.Context, name string) (string, error) { + response := models.Response{} + data := struct { + Region string `json:"region"` + }{} + response.Data = &data + if err := s.client.DoParsed(ctx, "GET", bucketSubresource(name, "region"), nil, &response); err != nil { + return "", err + } + return data.Region, nil +} + +// GetObjectLock retrieves the S3 Object Lock settings for a bucket. +func (s *BucketService) GetObjectLock(ctx context.Context, name string) (*models.BucketS3ObjectLockSettings, error) { + response := models.Response{} + response.Data = &models.BucketS3ObjectLockSettings{} + if err := s.client.DoParsed(ctx, "GET", bucketSubresource(name, "object-lock"), nil, &response); err != nil { + return nil, err + } + return response.Data.(*models.BucketS3ObjectLockSettings), nil +} + +// UpdateObjectLock updates the S3 Object Lock settings for a bucket. +func (s *BucketService) UpdateObjectLock(ctx context.Context, name string, settings *models.BucketS3ObjectLockSettings) (*models.BucketS3ObjectLockSettings, error) { + response := models.Response{} + response.Data = &models.BucketS3ObjectLockSettings{} + if err := s.client.DoParsed(ctx, "PUT", bucketSubresource(name, "object-lock"), settings, &response); err != nil { + return nil, err + } + return response.Data.(*models.BucketS3ObjectLockSettings), nil +} + +// GetNotification retrieves the notification configuration for a bucket. +func (s *BucketService) GetNotification(ctx context.Context, name string) (*models.BucketNotificationConfiguration, error) { + response := models.Response{} + response.Data = &models.BucketNotificationConfiguration{} + if err := s.client.DoParsed(ctx, "GET", bucketSubresource(name, "notification"), nil, &response); err != nil { + return nil, err + } + return response.Data.(*models.BucketNotificationConfiguration), nil +} + +// UpdateNotification updates the notification configuration for a bucket. +func (s *BucketService) UpdateNotification(ctx context.Context, name string, config *models.BucketNotificationConfiguration) (*models.BucketNotificationConfiguration, error) { + response := models.Response{} + response.Data = &models.BucketNotificationConfiguration{} + if err := s.client.DoParsed(ctx, "PUT", bucketSubresource(name, "notification"), config, &response); err != nil { + return nil, err + } + return response.Data.(*models.BucketNotificationConfiguration), nil +} + +// GetPolicy retrieves the bucket policy. +func (s *BucketService) GetPolicy(ctx context.Context, name string) (*models.BucketPolicyConfiguration, error) { + response := models.Response{} + response.Data = &models.BucketPolicyConfiguration{} + if err := s.client.DoParsed(ctx, "GET", bucketSubresource(name, "policy"), nil, &response); err != nil { + return nil, err + } + return response.Data.(*models.BucketPolicyConfiguration), nil +} + +// UpdatePolicy updates the bucket policy. +func (s *BucketService) UpdatePolicy(ctx context.Context, name string, policy *models.BucketPolicyConfiguration) (*models.BucketPolicyConfiguration, error) { + response := models.Response{} + response.Data = &models.BucketPolicyConfiguration{} + if err := s.client.DoParsed(ctx, "PUT", bucketSubresource(name, "policy"), policy, &response); err != nil { + return nil, err + } + return response.Data.(*models.BucketPolicyConfiguration), nil +} + +// GetCors retrieves the CORS configuration for a bucket. +func (s *BucketService) GetCors(ctx context.Context, name string) (*models.BucketCorsConfiguration, error) { + response := models.Response{} + response.Data = &models.BucketCorsConfiguration{} + if err := s.client.DoParsed(ctx, "GET", bucketSubresource(name, "cors"), nil, &response); err != nil { + return nil, err + } + return response.Data.(*models.BucketCorsConfiguration), nil +} + +// UpdateCors updates the CORS configuration for a bucket. +func (s *BucketService) UpdateCors(ctx context.Context, name string, config *models.BucketCorsConfiguration) (*models.BucketCorsConfiguration, error) { + response := models.Response{} + response.Data = &models.BucketCorsConfiguration{} + if err := s.client.DoParsed(ctx, "PUT", bucketSubresource(name, "cors"), config, &response); err != nil { + return nil, err + } + return response.Data.(*models.BucketCorsConfiguration), nil +} + +// GetCompliance retrieves the legacy Compliance settings for a bucket. +func (s *BucketService) GetCompliance(ctx context.Context, name string) (*models.BucketComplianceSettings, error) { + response := models.Response{} + response.Data = &models.BucketComplianceSettings{} + if err := s.client.DoParsed(ctx, "GET", bucketSubresource(name, "compliance"), nil, &response); err != nil { + return nil, err + } + return response.Data.(*models.BucketComplianceSettings), nil +} + +// UpdateCompliance updates the legacy Compliance settings for a bucket. +func (s *BucketService) UpdateCompliance(ctx context.Context, name string, settings *models.BucketComplianceSettings) (*models.BucketComplianceSettings, error) { + response := models.Response{} + response.Data = &models.BucketComplianceSettings{} + if err := s.client.DoParsed(ctx, "PUT", bucketSubresource(name, "compliance"), settings, &response); err != nil { + return nil, err + } + return response.Data.(*models.BucketComplianceSettings), nil +} diff --git a/testing/bucket_mock.go b/testing/bucket_mock.go index 652542d..804d109 100644 --- a/testing/bucket_mock.go +++ b/testing/bucket_mock.go @@ -17,6 +17,19 @@ type MockBucketService struct { DrainFunc func(ctx context.Context, name string) (*models.BucketDeleteObjectStatus, error) CancelDrainFunc func(ctx context.Context, name string) (*models.BucketDeleteObjectStatus, error) DrainStatusFunc func(ctx context.Context, name string) (*models.BucketDeleteObjectStatus, error) + + GetBucketUsageFunc func(ctx context.Context, name string) (*models.BucketUsage, error) + GetRegionFunc func(ctx context.Context, name string) (string, error) + GetObjectLockFunc func(ctx context.Context, name string) (*models.BucketS3ObjectLockSettings, error) + UpdateObjectLockFunc func(ctx context.Context, name string, settings *models.BucketS3ObjectLockSettings) (*models.BucketS3ObjectLockSettings, error) + GetNotificationFunc func(ctx context.Context, name string) (*models.BucketNotificationConfiguration, error) + UpdateNotificationFunc func(ctx context.Context, name string, config *models.BucketNotificationConfiguration) (*models.BucketNotificationConfiguration, error) + GetPolicyFunc func(ctx context.Context, name string) (*models.BucketPolicyConfiguration, error) + UpdatePolicyFunc func(ctx context.Context, name string, policy *models.BucketPolicyConfiguration) (*models.BucketPolicyConfiguration, error) + GetCorsFunc func(ctx context.Context, name string) (*models.BucketCorsConfiguration, error) + UpdateCorsFunc func(ctx context.Context, name string, config *models.BucketCorsConfiguration) (*models.BucketCorsConfiguration, error) + GetComplianceFunc func(ctx context.Context, name string) (*models.BucketComplianceSettings, error) + UpdateComplianceFunc func(ctx context.Context, name string, settings *models.BucketComplianceSettings) (*models.BucketComplianceSettings, error) } func (m *MockBucketService) List(ctx context.Context) (*[]models.Bucket, error) { @@ -76,5 +89,89 @@ func (m *MockBucketService) DrainStatus(ctx context.Context, name string) (*mode return &models.BucketDeleteObjectStatus{}, nil } +func (m *MockBucketService) GetBucketUsage(ctx context.Context, name string) (*models.BucketUsage, error) { + if m.GetBucketUsageFunc != nil { + return m.GetBucketUsageFunc(ctx, name) + } + return &models.BucketUsage{}, nil +} + +func (m *MockBucketService) GetRegion(ctx context.Context, name string) (string, error) { + if m.GetRegionFunc != nil { + return m.GetRegionFunc(ctx, name) + } + return "", nil +} + +func (m *MockBucketService) GetObjectLock(ctx context.Context, name string) (*models.BucketS3ObjectLockSettings, error) { + if m.GetObjectLockFunc != nil { + return m.GetObjectLockFunc(ctx, name) + } + return &models.BucketS3ObjectLockSettings{}, nil +} + +func (m *MockBucketService) UpdateObjectLock(ctx context.Context, name string, settings *models.BucketS3ObjectLockSettings) (*models.BucketS3ObjectLockSettings, error) { + if m.UpdateObjectLockFunc != nil { + return m.UpdateObjectLockFunc(ctx, name, settings) + } + return settings, nil +} + +func (m *MockBucketService) GetNotification(ctx context.Context, name string) (*models.BucketNotificationConfiguration, error) { + if m.GetNotificationFunc != nil { + return m.GetNotificationFunc(ctx, name) + } + return &models.BucketNotificationConfiguration{}, nil +} + +func (m *MockBucketService) UpdateNotification(ctx context.Context, name string, config *models.BucketNotificationConfiguration) (*models.BucketNotificationConfiguration, error) { + if m.UpdateNotificationFunc != nil { + return m.UpdateNotificationFunc(ctx, name, config) + } + return config, nil +} + +func (m *MockBucketService) GetPolicy(ctx context.Context, name string) (*models.BucketPolicyConfiguration, error) { + if m.GetPolicyFunc != nil { + return m.GetPolicyFunc(ctx, name) + } + return &models.BucketPolicyConfiguration{}, nil +} + +func (m *MockBucketService) UpdatePolicy(ctx context.Context, name string, policy *models.BucketPolicyConfiguration) (*models.BucketPolicyConfiguration, error) { + if m.UpdatePolicyFunc != nil { + return m.UpdatePolicyFunc(ctx, name, policy) + } + return policy, nil +} + +func (m *MockBucketService) GetCors(ctx context.Context, name string) (*models.BucketCorsConfiguration, error) { + if m.GetCorsFunc != nil { + return m.GetCorsFunc(ctx, name) + } + return &models.BucketCorsConfiguration{}, nil +} + +func (m *MockBucketService) UpdateCors(ctx context.Context, name string, config *models.BucketCorsConfiguration) (*models.BucketCorsConfiguration, error) { + if m.UpdateCorsFunc != nil { + return m.UpdateCorsFunc(ctx, name, config) + } + return config, nil +} + +func (m *MockBucketService) GetCompliance(ctx context.Context, name string) (*models.BucketComplianceSettings, error) { + if m.GetComplianceFunc != nil { + return m.GetComplianceFunc(ctx, name) + } + return &models.BucketComplianceSettings{}, nil +} + +func (m *MockBucketService) UpdateCompliance(ctx context.Context, name string, settings *models.BucketComplianceSettings) (*models.BucketComplianceSettings, error) { + if m.UpdateComplianceFunc != nil { + return m.UpdateComplianceFunc(ctx, name, settings) + } + return settings, nil +} + // Compile-time interface compliance check var _ services.BucketServiceInterface = (*MockBucketService)(nil) From c7f2dbbef0ef0781b7fcf807b8272eefc51f5b3f Mon Sep 17 00:00:00 2001 From: Joshua Leuenberger Date: Tue, 28 Apr 2026 07:13:46 +0200 Subject: [PATCH 2/3] feat(objectlock): add capability to configure objectlock --- client/grid.go | 28 +++++++++++-------- models/s3ObjectLock.go | 10 +++++++ models/tenants.go | 6 +++++ services/s3ObjectLock.go | 52 ++++++++++++++++++++++++++++++++++++ testing/s3objectlock_mock.go | 31 +++++++++++++++++++++ 5 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 models/s3ObjectLock.go create mode 100644 services/s3ObjectLock.go create mode 100644 testing/s3objectlock_mock.go diff --git a/client/grid.go b/client/grid.go index fb0ac21..8eeb47a 100644 --- a/client/grid.go +++ b/client/grid.go @@ -14,11 +14,12 @@ type GridClient struct { client *Client // Services - tenant services.TenantServiceInterface - health services.HealthServiceInterface - region services.RegionServiceInterface - haGroup services.HAGroupServiceInterface - gateway services.GatewayConfigServiceInterface + tenant services.TenantServiceInterface + health services.HealthServiceInterface + region services.RegionServiceInterface + haGroup services.HAGroupServiceInterface + gateway services.GatewayConfigServiceInterface + s3ObjectLock services.S3ObjectLockServiceInterface } func NewGridClient(options ...ClientOption) (*GridClient, error) { @@ -30,12 +31,13 @@ func NewGridClient(options ...ClientOption) (*GridClient, error) { c.baseURL = c.baseURL.ResolveReference(&url.URL{Path: gridAPI}) return &GridClient{ - client: c, - tenant: services.NewTenantService(c), - health: services.NewHealthService(c), - region: services.NewRegionGridService(c), - haGroup: services.NewHAGroupService(c), - gateway: services.NewGatewayConfigService(c), + client: c, + tenant: services.NewTenantService(c), + health: services.NewHealthService(c), + region: services.NewRegionGridService(c), + haGroup: services.NewHAGroupService(c), + gateway: services.NewGatewayConfigService(c), + s3ObjectLock: services.NewS3ObjectLockService(c), }, nil } @@ -60,3 +62,7 @@ func (gc *GridClient) HAGroup() services.HAGroupServiceInterface { func (gc *GridClient) Gateway() services.GatewayConfigServiceInterface { return gc.gateway } + +func (gc *GridClient) S3ObjectLock() services.S3ObjectLockServiceInterface { + return gc.s3ObjectLock +} diff --git a/models/s3ObjectLock.go b/models/s3ObjectLock.go new file mode 100644 index 0000000..4a991b3 --- /dev/null +++ b/models/s3ObjectLock.go @@ -0,0 +1,10 @@ +package models + +// S3ObjectLock represents the grid-wide S3 Object Lock (compliance-global) settings. +// Fields are pointers so callers can submit partial PUT bodies and so GET responses +// with omitted fields decode cleanly. +type S3ObjectLock struct { + ComplianceEnabled *bool `json:"complianceEnabled,omitempty"` // Whether S3 Object Lock is enabled grid-wide. + LegacyComplianceEnabled *bool `json:"legacyComplianceEnabled,omitempty"` // Whether the deprecated legacy Compliance feature is enabled. + CreateLegacyComplianceBuckets *bool `json:"createLegacyComplianceBuckets,omitempty"` // Whether tenants are allowed to create new legacy Compliance buckets. +} diff --git a/models/tenants.go b/models/tenants.go index eef7067..f472bcc 100644 --- a/models/tenants.go +++ b/models/tenants.go @@ -38,4 +38,10 @@ type TenantPolicy struct { AllowedGridFederationConnections []string `json:"allowedGridFederationConnections,omitempty"` // the maximum number of bytes available for this tenant's objects. Represents a logical amount (object size), not a physical amount (size on disk). If null, an unlimited number of bytes is available. QuotaObjectBytes *int64 `json:"quotaObjectBytes,omitempty"` + // whether a tenant can use S3 Object Lock in compliance mode. Requires that S3 Object Lock is enabled grid-wide (see /grid/compliance-global). + AllowComplianceMode *bool `json:"allowComplianceMode,omitempty"` + // the maximum retention period, in days, that a tenant can apply to objects using S3 Object Lock. If null, no per-tenant maximum is enforced. + MaxRetentionDays *int `json:"maxRetentionDays,omitempty"` + // the maximum retention period, in years, that a tenant can apply to objects using S3 Object Lock. If null, no per-tenant maximum is enforced. + MaxRetentionYears *int `json:"maxRetentionYears,omitempty"` } diff --git a/services/s3ObjectLock.go b/services/s3ObjectLock.go new file mode 100644 index 0000000..5d063c7 --- /dev/null +++ b/services/s3ObjectLock.go @@ -0,0 +1,52 @@ +package services + +import ( + "context" + + "github.com/bedag/storagegrid-sdk-go/models" +) + +const ( + s3ObjectLockEndpoint string = "/grid/compliance-global" +) + +// S3ObjectLockServiceInterface defines the contract for grid-wide S3 Object Lock +// (compliance-global) operations. +type S3ObjectLockServiceInterface interface { + Get(ctx context.Context) (*models.S3ObjectLock, error) + Update(ctx context.Context, settings *models.S3ObjectLock) (*models.S3ObjectLock, error) +} + +type S3ObjectLockService struct { + client HTTPClient +} + +func NewS3ObjectLockService(client HTTPClient) *S3ObjectLockService { + return &S3ObjectLockService{client: client} +} + +func (s *S3ObjectLockService) Get(ctx context.Context) (*models.S3ObjectLock, error) { + response := models.Response{} + response.Data = &models.S3ObjectLock{} + err := s.client.DoParsed(ctx, "GET", s3ObjectLockEndpoint, nil, &response) + if err != nil { + return nil, err + } + + settings := response.Data.(*models.S3ObjectLock) + + return settings, nil +} + +func (s *S3ObjectLockService) Update(ctx context.Context, settings *models.S3ObjectLock) (*models.S3ObjectLock, error) { + response := models.Response{} + response.Data = &models.S3ObjectLock{} + err := s.client.DoParsed(ctx, "PUT", s3ObjectLockEndpoint, settings, &response) + if err != nil { + return nil, err + } + + updated := response.Data.(*models.S3ObjectLock) + + return updated, nil +} diff --git a/testing/s3objectlock_mock.go b/testing/s3objectlock_mock.go new file mode 100644 index 0000000..429d7d4 --- /dev/null +++ b/testing/s3objectlock_mock.go @@ -0,0 +1,31 @@ +package testing + +import ( + "context" + + "github.com/bedag/storagegrid-sdk-go/models" + "github.com/bedag/storagegrid-sdk-go/services" +) + +// MockS3ObjectLockService implements services.S3ObjectLockServiceInterface for testing +type MockS3ObjectLockService struct { + GetFunc func(ctx context.Context) (*models.S3ObjectLock, error) + UpdateFunc func(ctx context.Context, settings *models.S3ObjectLock) (*models.S3ObjectLock, error) +} + +func (m *MockS3ObjectLockService) Get(ctx context.Context) (*models.S3ObjectLock, error) { + if m.GetFunc != nil { + return m.GetFunc(ctx) + } + return &models.S3ObjectLock{}, nil +} + +func (m *MockS3ObjectLockService) Update(ctx context.Context, settings *models.S3ObjectLock) (*models.S3ObjectLock, error) { + if m.UpdateFunc != nil { + return m.UpdateFunc(ctx, settings) + } + return settings, nil +} + +// Compile-time interface compliance check +var _ services.S3ObjectLockServiceInterface = (*MockS3ObjectLockService)(nil) From 01d0996023f31343b3f8181cd99c23394447b8a1 Mon Sep 17 00:00:00 2001 From: Joshua Leuenberger Date: Tue, 28 Apr 2026 07:14:09 +0200 Subject: [PATCH 3/3] chore(docs): add s3objectlock sample and update readme --- README.md | 43 ++--- examples/grid/s3-object-lock/README.md | 77 +++++++++ examples/grid/s3-object-lock/main.go | 183 ++++++++++++++++++++++ examples/tenant/bucket-operations/main.go | 18 +-- 4 files changed, 290 insertions(+), 31 deletions(-) create mode 100644 examples/grid/s3-object-lock/README.md create mode 100644 examples/grid/s3-object-lock/main.go diff --git a/README.md b/README.md index ff12d01..434e3d9 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,6 @@ > > This SDK was created by the community due to the lack of an official NetApp StorageGRID SDK for Go. It is designed to fulfill the needs of its maintainers and contributors. If you find something missing or spot a bug, please open an [issue](https://github.com/bedag/storagegrid-sdk-go/issues) or submit a [pull request](https://github.com/bedag/storagegrid-sdk-go/pulls)! Contributions are highly encouraged. -> [!NOTE] -> This SDK was originally developed by an employee and it's history can be seen in the [original repository](https://github.com/bedag/storagegrid-sdk-go). The original repository is no longer actively maintained, and this copy serves as the new home for ongoing development and contributions from the community. - ## Table of Contents - [Overview](#overview) @@ -43,9 +40,10 @@ This SDK reflects this architecture with corresponding client types. For more de - **Regions**: List available regions for grid and tenant contexts - **HA Groups**: Manage High Availability groups - **Gateway Configs**: Configure load balancer endpoints +- **S3 Object Lock**: Read and update grid-wide compliance (S3 Object Lock) settings ### Tenant Management -- **Buckets**: Create, list, delete, drain buckets; monitor bucket usage and compliance settings +- **Buckets**: Create, list, delete, drain buckets; manage per-bucket sub-resources (region, usage, S3 Object Lock, notifications, policy, CORS, legacy compliance) - **Users**: Manage tenant users with password management - **Groups**: Manage tenant groups with policies and permissions - **S3 Access Keys**: Generate and manage S3 access keys for users @@ -291,8 +289,8 @@ fmt.Printf("Secret Key: %s\n", *keys.SecretAccessKey) Comprehensive examples are available in the [`examples/`](examples/) directory: -- **[Grid Management](examples/grid/)**: Health monitoring, tenant management -- **[Tenant Operations](examples/tenant/)**: Bucket operations, user management +- **[Grid Management](examples/grid/)**: Health monitoring, tenant management, end-to-end S3 Object Lock +- **[Tenant Operations](examples/tenant/)**: Bucket operations (incl. per-bucket sub-resources) - **[Testing](examples/testing/)**: Unit tests with mocks, integration tests ### Quick Examples @@ -434,6 +432,7 @@ The `testing` package provides mocks for all service interfaces: - `MockHAGroupService` - HA group management - `MockGatewayConfigService` - Gateway configuration - `MockRegionService` - Region management +- `MockS3ObjectLockService` - Grid-wide S3 Object Lock (compliance-global) settings ## API Coverage @@ -442,25 +441,27 @@ This SDK provides access to StorageGRID's dual API architecture: ### Grid Management APIs (GridClient) Used for system-wide administration with grid administrator credentials: -| Service | Endpoint | Operations | Description | -|---------|----------|------------|-------------| -| **Tenants** | `/grid/accounts` | Create, Read, Update, Delete, List | Manage tenant accounts | -| **Health** | `/grid/health` | Read | Monitor grid health, alarms, alerts, node status | -| **Regions** | `/grid/regions` | List | Manage grid-wide regions | -| **HA Groups** | `/private/ha-groups` | Create, Read, Update, Delete, List | Configure High Availability groups | -| **Gateways** | `/private/gateway-configs` | Create, Read, Update, Delete, List | Manage load balancer endpoints | +| Service | Endpoint | Operations | Description | +| ------------------ | -------------------------- | ---------------------------------- | ----------------------------------------------------- | +| **Tenants** | `/grid/accounts` | Create, Read, Update, Delete, List | Manage tenant accounts | +| **Health** | `/grid/health` | Read | Monitor grid health, alarms, alerts, node status | +| **Regions** | `/grid/regions` | List | Manage grid-wide regions | +| **HA Groups** | `/private/ha-groups` | Create, Read, Update, Delete, List | Configure High Availability groups | +| **Gateways** | `/private/gateway-configs` | Create, Read, Update, Delete, List | Manage load balancer endpoints | +| **S3 Object Lock** | `/grid/compliance-global` | Read, Update | Manage grid-wide S3 Object Lock (compliance) settings | ### Tenant Management APIs (TenantClient) Used for tenant-specific operations with tenant user credentials: -| Service | Endpoint | Operations | Description | -|---------|----------|------------|-------------| -| **Buckets** | `/org/containers` | Create, Read, Delete, List, Drain | Manage S3 buckets within tenant | -| **Users** | `/org/users` | Create, Read, Update, Delete, List | Manage tenant users | -| **Groups** | `/org/groups` | Create, Read, Update, Delete, List | Manage tenant groups and permissions | -| **S3 Keys** | `/org/users/*/s3-access-keys` | Create, Read, Delete, List | Generate and manage S3 access credentials | -| **Regions** | `/org/regions` | List | List tenant-accessible regions | -| **Usage** | `/org/usage` | Read | Monitor tenant usage statistics | +| Service | Endpoint | Operations | Description | +| ------------------------ | --------------------------------------------------------------------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| **Buckets** | `/org/containers` | Create, Read, Delete, List, Drain | Manage S3 buckets within tenant | +| **Bucket sub-resources** | `/org/containers/{name}/{region,usage,object-lock,notification,policy,cors,compliance}` | Read, Update | Per-bucket configuration: region, usage, S3 Object Lock, notifications, bucket policy, CORS, legacy compliance | +| **Users** | `/org/users` | Create, Read, Update, Delete, List | Manage tenant users | +| **Groups** | `/org/groups` | Create, Read, Update, Delete, List | Manage tenant groups and permissions | +| **S3 Keys** | `/org/users/*/s3-access-keys` | Create, Read, Delete, List | Generate and manage S3 access credentials | +| **Regions** | `/org/regions` | List | List tenant-accessible regions | +| **Usage** | `/org/usage` | Read | Monitor tenant usage statistics | > šŸ“š **Official Documentation**: For comprehensive API documentation, refer to the [NetApp StorageGRID REST API Reference](https://docs.netapp.com/us-en/storagegrid-115/s3/storagegrid-s3-rest-api-operations.html). diff --git a/examples/grid/s3-object-lock/README.md b/examples/grid/s3-object-lock/README.md new file mode 100644 index 0000000..8d6a2ac --- /dev/null +++ b/examples/grid/s3-object-lock/README.md @@ -0,0 +1,77 @@ +# S3 Object Lock Example (end-to-end) + +This example walks through the full S3 Object Lock flow against a StorageGRID +deployment: + +1. Reads the grid-wide compliance-global settings via the **GridClient** + (`/grid/compliance-global`). +2. Optionally enables S3 Object Lock grid-wide (set `STORAGEGRID_APPLY=true`). +3. Updates the target tenant's policy (`allowComplianceMode`, + `maxRetentionYears`) so the tenant can use S3 Object Lock and bucket + retention is capped grid-side. +4. Creates a bucket via the **TenantClient** with S3 Object Lock enabled and a + default compliance-mode retention period. +5. Reads the per-bucket Object Lock settings back via the dedicated + `/org/containers/{bucketName}/object-lock` sub-resource. + +> āš ļø **Enabling S3 Object Lock grid-wide is irreversible.** See the +> [official documentation](https://docs.netapp.com/us-en/storagegrid/ilm/managing-objects-with-s3-object-lock.html#what-is-s3-object-lock). + and tenant policy + update) +- Tenant administrator credentials and an account ID (for the bucket stepsdentials (for the grid-wide settings) +- Tenant administrator credentials and an account ID (for the bucket steps) +- The tenant must have S3 Object Lock allowed via its tenant policy + (`allowComplianceMode`) + +## Environment Variables + +```bash +# Required for the grid-side steps +export STORAGEGRID_ENDPOINT="https://your-storagegrid.example.com" +export STORAGEGRID_USERNAME="grid-admin" +export STORAGEGRID_PASSWORD="grid-password" + +# Required for the tenant/bucket steps +export STORAGEGRID_TENANT_USERNAME="tenant-admin" +export STORAGEGRID_TENANT_PASSWORD="tenant-password" +export STORAGEGRID_ACCOUNT_ID="12345678901234567890" + +# Optional +export STORAGEGRID_SKIP_SSL="true" # development only +export STORAGEGRID_APPLY="true" # actually enable S3 Object Lock grid-wide +``` + +If `STORAGEGRID_APPLY` is not set, the example only reads grid-wide settings +and does not create any buckets. + +## Running + +```bash +cd examples/grid/s3-object-lock +go mod init s3-object-lock-example +go mod tidy +go run main.go +``` + +## What you should see + +``` +šŸ” Grid-wide S3 Object Lock settings (/grid/compliance-global) + +šŸ“Š Current: + complianceEnabled: true + legacyComplianceEnabled: false + createLegacyComplianceBuckets: false + +šŸ—ļø Creating bucket "object-lock-demo-20260428-101530" with S3 Object Lock enabled... +šŸ” Updating tenant policy to allow S3 Object Lock... + āœ… allowComplianceMode=true, maxRetentionYears=10 on tenant 12345678901234567890 + + āœ… Created object-lock-demo-20260428-101530 in us-east-1 + +šŸ” Reading bucket Object Lock settings (/org/containers//object-lock)... + enabled: true + defaultRetentionSetting: + mode: compliance + days: 30 +``` diff --git a/examples/grid/s3-object-lock/main.go b/examples/grid/s3-object-lock/main.go new file mode 100644 index 0000000..2d28a52 --- /dev/null +++ b/examples/grid/s3-object-lock/main.go @@ -0,0 +1,183 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/bedag/storagegrid-sdk-go/client" + "github.com/bedag/storagegrid-sdk-go/models" +) + +// End-to-end S3 Object Lock example. +// +// 1. Reads the grid-wide compliance-global settings via the GridClient. +// 2. Optionally enables S3 Object Lock grid-wide (irreversible). +// 3. Updates the tenant policy to allow S3 Object Lock for the target tenant +// and caps the maximum retention a bucket may specify. +// 4. Creates a bucket via the TenantClient with S3 Object Lock enabled and a +// default compliance-mode retention period. +// 5. Reads the per-bucket Object Lock settings back using the new +// /org/containers/{bucketName}/object-lock endpoint. +// +// See: https://docs.netapp.com/us-en/storagegrid/ilm/managing-objects-with-s3-object-lock.html +func main() { + endpoint := os.Getenv("STORAGEGRID_ENDPOINT") + gridUser := os.Getenv("STORAGEGRID_USERNAME") + gridPass := os.Getenv("STORAGEGRID_PASSWORD") + tenantUser := os.Getenv("STORAGEGRID_TENANT_USERNAME") + tenantPass := os.Getenv("STORAGEGRID_TENANT_PASSWORD") + accountID := os.Getenv("STORAGEGRID_ACCOUNT_ID") + skipSSL := os.Getenv("STORAGEGRID_SKIP_SSL") == "true" + apply := os.Getenv("STORAGEGRID_APPLY") == "true" + + if endpoint == "" || gridUser == "" || gridPass == "" { + log.Fatal("Required env vars: STORAGEGRID_ENDPOINT, STORAGEGRID_USERNAME, STORAGEGRID_PASSWORD") + } + + ctx := context.Background() + + gridClient, err := client.NewGridClient(buildOpts(endpoint, gridUser, gridPass, nil, skipSSL)...) + if err != nil { + log.Fatalf("Failed to create grid client: %v", err) + } + + // 1. Inspect grid-wide settings. + fmt.Println("šŸ” Grid-wide S3 Object Lock settings (/grid/compliance-global)") + current, err := gridClient.S3ObjectLock().Get(ctx) + if err != nil { + log.Fatalf("Failed to get S3 Object Lock settings: %v", err) + } + printGridSettings("Current", current) + + // 2. Optionally enable grid-wide. + if apply { + enable := true + fmt.Println("\nāœļø Enabling complianceEnabled grid-wide (irreversible)...") + updated, err := gridClient.S3ObjectLock().Update(ctx, &models.S3ObjectLock{ComplianceEnabled: &enable}) + if err != nil { + log.Fatalf("Failed to update S3 Object Lock settings: %v", err) + } + printGridSettings("Updated", updated) + current = updated + } else { + fmt.Println("\nā„¹ļø Set STORAGEGRID_APPLY=true to enable S3 Object Lock grid-wide.") + } + + if current.ComplianceEnabled == nil || !*current.ComplianceEnabled { + fmt.Println("\nāš ļø Grid-wide S3 Object Lock is not enabled — skipping bucket demo.") + return + } + + if tenantUser == "" || tenantPass == "" || accountID == "" { + fmt.Println("\nā„¹ļø Set STORAGEGRID_TENANT_USERNAME, STORAGEGRID_TENANT_PASSWORD, and STORAGEGRID_ACCOUNT_ID to run the bucket demo.") + return + } + + // 3. Update the tenant policy: allow S3 Object Lock and cap max retention. + // Per-tenant settings live on TenantPolicy and gate what tenants can do + // with S3 Object Lock once it is enabled grid-wide. + fmt.Println("\nšŸ” Updating tenant policy to allow S3 Object Lock...") + tenant, err := gridClient.Tenant().GetById(ctx, accountID) + if err != nil { + log.Fatalf("Failed to get tenant %s: %v", accountID, err) + } + if tenant.Policy == nil { + tenant.Policy = &models.TenantPolicy{} + } + allow := true + maxYears := 10 + tenant.Policy.AllowComplianceMode = &allow + tenant.Policy.MaxRetentionYears = &maxYears + tenant.Policy.MaxRetentionDays = nil // no per-day cap (years cap is sufficient) + if _, err := gridClient.Tenant().Update(ctx, tenant); err != nil { + log.Fatalf("Failed to update tenant policy: %v", err) + } + fmt.Printf(" āœ… allowComplianceMode=true, maxRetentionYears=%d on tenant %s\n", maxYears, accountID) + + tenantClient, err := client.NewTenantClient(buildOpts(endpoint, tenantUser, tenantPass, &accountID, skipSSL)...) + if err != nil { + log.Fatalf("Failed to create tenant client: %v", err) + } + + // 4. Create a bucket with S3 Object Lock enabled. + bucketName := fmt.Sprintf("object-lock-demo-%s", time.Now().Format("20060102-150405")) + enabled := true + bucket := &models.Bucket{ + Name: bucketName, + Region: "us-east-1", + EnableVersioning: &enabled, // S3 Object Lock requires versioning + S3ObjectLock: &models.BucketS3ObjectLockSettings{ + Enabled: &enabled, + DefaultRetentionSetting: &models.BucketS3ObjectLockDefaultRetentionSettings{ + Mode: "compliance", + Days: 30, + }, + }, + } + + fmt.Printf("\nšŸ—ļø Creating bucket %q with S3 Object Lock enabled...\n", bucketName) + created, err := tenantClient.Bucket().Create(ctx, bucket) + if err != nil { + log.Fatalf("Failed to create bucket: %v", err) + } + fmt.Printf(" āœ… Created %s in %s\n", created.Name, created.Region) + + // 5. Read back per-bucket Object Lock settings via the dedicated sub-resource. + fmt.Println("\nšŸ” Reading bucket Object Lock settings (/org/containers//object-lock)...") + settings, err := tenantClient.Bucket().GetObjectLock(ctx, bucketName) + if err != nil { + log.Fatalf("Failed to get bucket Object Lock settings: %v", err) + } + printBucketObjectLock(settings) +} + +func buildOpts(endpoint, user, pass string, accountID *string, skipSSL bool) []client.ClientOption { + opts := []client.ClientOption{ + client.WithEndpoint(endpoint), + client.WithCredentials(&models.Credentials{ + Username: user, + Password: pass, + AccountId: accountID, + }), + } + if skipSSL { + opts = append(opts, client.WithSkipSSL()) + } + return opts +} + +func printGridSettings(label string, s *models.S3ObjectLock) { + fmt.Printf("\nšŸ“Š %s:\n", label) + fmt.Printf(" complianceEnabled: %s\n", boolStr(s.ComplianceEnabled)) + fmt.Printf(" legacyComplianceEnabled: %s\n", boolStr(s.LegacyComplianceEnabled)) + fmt.Printf(" createLegacyComplianceBuckets: %s\n", boolStr(s.CreateLegacyComplianceBuckets)) +} + +func printBucketObjectLock(s *models.BucketS3ObjectLockSettings) { + fmt.Printf(" enabled: %s\n", boolStr(s.Enabled)) + if s.DefaultRetentionSetting == nil { + fmt.Println(" defaultRetentionSetting: (none)") + return + } + fmt.Println(" defaultRetentionSetting:") + fmt.Printf(" mode: %s\n", s.DefaultRetentionSetting.Mode) + if s.DefaultRetentionSetting.Days != 0 { + fmt.Printf(" days: %d\n", s.DefaultRetentionSetting.Days) + } + if s.DefaultRetentionSetting.Years != 0 { + fmt.Printf(" years: %d\n", s.DefaultRetentionSetting.Years) + } +} + +func boolStr(b *bool) string { + if b == nil { + return "(unset)" + } + if *b { + return "true" + } + return "false" +} diff --git a/examples/tenant/bucket-operations/main.go b/examples/tenant/bucket-operations/main.go index 7a6436a..dc30ec6 100644 --- a/examples/tenant/bucket-operations/main.go +++ b/examples/tenant/bucket-operations/main.go @@ -188,26 +188,24 @@ func monitorBucketUsage(ctx context.Context, client *client.TenantClient) error for _, bucket := range *buckets { fmt.Printf("\n šŸ“ˆ Usage for %s:\n", bucket.Name) - usage, err := client.Bucket().GetUsage(ctx, bucket.Name) + usage, err := client.Bucket().GetBucketUsage(ctx, bucket.Name) if err != nil { fmt.Printf(" āŒ Failed to get usage: %v\n", err) continue } if usage.ObjectCount != nil { - fmt.Printf(" Objects: %s\n", formatNumber(int64(*usage.ObjectCount))) + fmt.Printf(" Objects: %s\n", formatNumber(*usage.ObjectCount)) } if usage.DataBytes != nil { fmt.Printf(" Data: %s\n", formatBytes(*usage.DataBytes)) } - if usage.Region != nil { - fmt.Printf(" Region: %s\n", *usage.Region) - } - if usage.VersioningEnabled != nil { - fmt.Printf(" Versioning: %v\n", *usage.VersioningEnabled) - } - if usage.Encryption != nil { - fmt.Printf(" Encryption: %s\n", *usage.Encryption) + + region, err := client.Bucket().GetRegion(ctx, bucket.Name) + if err != nil { + fmt.Printf(" āŒ Failed to get region: %v\n", err) + } else if region != "" { + fmt.Printf(" Region: %s\n", region) } }