Skip to content

Commit cf74f41

Browse files
authored
feat: Implement S3 Object Lock retention ceiling for tenants and ensure default value (#155)
1 parent 05e571d commit cf74f41

15 files changed

Lines changed: 597 additions & 45 deletions

.github/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,36 @@ spec:
207207
credentialsSecret:
208208
name: storagegrid-credentials
209209
namespace: storagegrid-operator-system
210+
211+
# Grid-wide S3 Object Lock retention ceiling, in days, for tenants that do not
212+
# set spec.s3ObjectLock.maxRetentionInDays themselves. Defaults to 365.
213+
# Must be between 1 and 36500 (100 years). See "S3 Object Lock retention" below.
214+
defaultMaxRetentionInDays: 365
210215
```
211216

217+
#### S3 Object Lock retention
218+
219+
StorageGrid requires every tenant to carry an S3 Object Lock retention ceiling once Object Lock
220+
is enabled grid-wide, and **silently applies a 100-year ceiling to any tenant created without
221+
one**. The operator therefore always writes the field explicitly, resolving it as:
222+
223+
| Setting | Effect |
224+
| --- | --- |
225+
| `S3Tenant.spec.s3ObjectLock.maxRetentionInDays` > 0 | That value is used |
226+
| `S3Tenant.spec.s3ObjectLock.maxRetentionInDays` unset or `0` | Inherits `StorageGrid.spec.defaultMaxRetentionInDays` |
227+
| Neither set | Falls back to 365 days |
228+
229+
The ceiling is independent of `s3ObjectLock.mode`. Mode governs whether an `S3Bucket` may enable
230+
Object Lock at all, but the retention ceiling also caps Governance-mode retention that a tenant
231+
can request directly over the S3 API, where the operator's bucket-level checks do not apply.
232+
233+
The operator owns this field: it is reconciled on every loop, so a value changed by hand in the
234+
Tenant Manager is reset to what the spec requires. It also expresses the ceiling in days only,
235+
clearing StorageGrid's `maxRetentionYears` whenever it writes.
236+
237+
An `S3Bucket`'s `s3ObjectLock.retentionInDays` may never exceed its tenant's effective ceiling;
238+
the admission webhook rejects the bucket otherwise.
239+
212240
### 2. Define an S3TenantClass
213241

214242
```yaml
@@ -250,6 +278,16 @@ spec:
250278
project: "my-project"
251279
environment: "production"
252280
owner: "team-alpha"
281+
282+
# Optional: S3 Object Lock. Omit the whole block if the tenant does not need it -
283+
# the operator still applies the grid's retention ceiling in the backend.
284+
s3ObjectLock:
285+
# Maximum mode buckets in this tenant may use: Disabled (default), Governance, Compliance.
286+
# Compliance requires the parent StorageGrid to have Object Lock enabled grid-wide.
287+
mode: Governance
288+
# Caps retentionInDays on every bucket in this tenant.
289+
# 0 or omitted inherits StorageGrid.spec.defaultMaxRetentionInDays.
290+
maxRetentionInDays: 90
253291
```
254292

255293
#### Available Annotations

api/v1alpha1/s3tenant_common.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,11 @@ type S3ObjectLockTenantSpec struct {
119119

120120
// MaxRetentionInDays caps the retentionInDays a bucket in this tenant may request.
121121
// Mapped to the backend tenant policy MaxRetentionDays.
122+
// Zero means inherit the grid-wide StorageGrid spec.defaultMaxRetentionInDays; the backend
123+
// always ends up with a concrete ceiling because StorageGrid does not accept an empty one.
122124
// +kubebuilder:default=0
123125
// +kubebuilder:validation:Minimum=0
126+
// +kubebuilder:validation:Maximum=36500
124127
// +optional
125128
MaxRetentionInDays int32 `json:"maxRetentionInDays,omitempty"`
126129
}

api/v1alpha1/storagegrid_types.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ const (
2727
DefaultRetentionDuration = "168h" // 7 days
2828
DefaultTenantDeletionProcedure = TenantDeletionPolicyRetain // Default policy for tenant deletion
2929

30+
// DefaultMaxRetentionInDays is the fallback S3 Object Lock retention ceiling applied when
31+
// neither the tenant nor the StorageGrid specifies one. StorageGrid itself would default to
32+
// 100 years, so the operator pins one year instead, matching what the Tenant Manager writes.
33+
DefaultMaxRetentionInDays int32 = 365
34+
3035
// Default bucket drain operation intervals.
3136
DefaultDrainInitialPollInterval = 3 * time.Minute
3237
DefaultDrainLongRunningPollInterval = 30 * time.Minute
@@ -97,6 +102,17 @@ type StorageGridSpec struct {
97102
// +kubebuilder:default={retentionDuration: "168h"}
98103
DefaultTenantDeletionPolicy *TenantDeletionPolicy `json:"defaultTenantDeletionPolicy,omitempty"`
99104

105+
// DefaultMaxRetentionInDays is the S3 Object Lock retention ceiling applied to tenants on this
106+
// grid that do not set spec.s3ObjectLock.maxRetentionInDays themselves.
107+
// StorageGrid requires a value between 1 day and 100 years once S3 Object Lock is enabled
108+
// grid-wide, and silently applies 100 years when no value is sent, so the operator always
109+
// writes this field explicitly.
110+
// +kubebuilder:default=365
111+
// +kubebuilder:validation:Minimum=1
112+
// +kubebuilder:validation:Maximum=36500
113+
// +optional
114+
DefaultMaxRetentionInDays int32 `json:"defaultMaxRetentionInDays,omitempty"`
115+
100116
// Amount of nodes (default: 1) that can be unavailable before the StorageGrid is considered not ready.
101117
// This is a safeguard to prevent operations when the StorageGrid is not fully available.
102118
// +kubebuilder:default=1
@@ -211,6 +227,11 @@ type StorageGridStatus struct {
211227
// +kubebuilder:default={}
212228
DefaultTenantDeletionPolicy *TenantDeletionPolicy `json:"defaultTenantDeletionPolicy,omitempty"`
213229

230+
// Currently active default S3 Object Lock retention ceiling in days, applied to tenants
231+
// that do not set spec.s3ObjectLock.maxRetentionInDays themselves.
232+
// +optional
233+
DefaultMaxRetentionInDays int32 `json:"defaultMaxRetentionInDays,omitempty"`
234+
214235
// Usage of the StorageGrid summarizes the usage of all tenants.
215236
// +optional
216237
StorageGridUsage StorageGridUsage `json:"usage,omitempty"`

config/crd/bases/s3.bedag.ch_s3tenantaccounts.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,10 @@ spec:
290290
description: |-
291291
MaxRetentionInDays caps the retentionInDays a bucket in this tenant may request.
292292
Mapped to the backend tenant policy MaxRetentionDays.
293+
Zero means inherit the grid-wide StorageGrid spec.defaultMaxRetentionInDays; the backend
294+
always ends up with a concrete ceiling because StorageGrid does not accept an empty one.
293295
format: int32
296+
maximum: 36500
294297
minimum: 0
295298
type: integer
296299
mode:

config/crd/bases/s3.bedag.ch_s3tenants.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,10 @@ spec:
226226
description: |-
227227
MaxRetentionInDays caps the retentionInDays a bucket in this tenant may request.
228228
Mapped to the backend tenant policy MaxRetentionDays.
229+
Zero means inherit the grid-wide StorageGrid spec.defaultMaxRetentionInDays; the backend
230+
always ends up with a concrete ceiling because StorageGrid does not accept an empty one.
229231
format: int32
232+
maximum: 36500
230233
minimum: 0
231234
type: integer
232235
mode:

config/crd/bases/s3.bedag.ch_storagegrids.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ spec:
5858
description: Default region for buckets created in the StorageGrid,
5959
defaults to the first region in the list.
6060
type: string
61+
defaultMaxRetentionInDays:
62+
default: 365
63+
description: |-
64+
DefaultMaxRetentionInDays is the S3 Object Lock retention ceiling applied to tenants on this
65+
grid that do not set spec.s3ObjectLock.maxRetentionInDays themselves.
66+
StorageGrid requires a value between 1 day and 100 years once S3 Object Lock is enabled
67+
grid-wide, and silently applies 100 years when no value is sent, so the operator always
68+
writes this field explicitly.
69+
format: int32
70+
maximum: 36500
71+
minimum: 1
72+
type: integer
6173
defaultTenantDeletionPolicy:
6274
default:
6375
retentionDuration: 168h
@@ -303,6 +315,12 @@ spec:
303315
defaultBucketRegion:
304316
description: DefaultRegion for buckets created in the StorageGrid.
305317
type: string
318+
defaultMaxRetentionInDays:
319+
description: |-
320+
Currently active default S3 Object Lock retention ceiling in days, applied to tenants
321+
that do not set spec.s3ObjectLock.maxRetentionInDays themselves.
322+
format: int32
323+
type: integer
306324
defaultTenantDeletionPolicy:
307325
default: {}
308326
description: Currently active tenant deletion policy.

config/samples/s3_v1alpha1_storagegrid.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,8 @@ spec:
2323
s3ApiEndpoint:
2424
pathStyleAccess: true
2525
url: https://s3.storagegrid.example.com:10443
26+
# S3 Object Lock retention ceiling in days for tenants that do not set
27+
# spec.s3ObjectLock.maxRetentionInDays themselves. Must be 1..36500 (100 years).
28+
# StorageGrid applies a 100 year ceiling of its own when no value is sent, so the
29+
# operator always writes this field explicitly.
30+
defaultMaxRetentionInDays: 365
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
Copyright 2025.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package controller
18+
19+
import (
20+
"testing"
21+
22+
s3v1alpha1 "github.com/bedag/storagegrid-operator/api/v1alpha1"
23+
)
24+
25+
func gridWithDefault(days int32) *s3v1alpha1.StorageGrid {
26+
return &s3v1alpha1.StorageGrid{
27+
Spec: s3v1alpha1.StorageGridSpec{DefaultMaxRetentionInDays: days},
28+
}
29+
}
30+
31+
// StorageGrid rejects an empty S3 Object Lock ceiling and silently substitutes 100 years when a
32+
// request carries none, so this resolution must never yield zero no matter how sparse the input.
33+
func TestEffectiveMaxRetentionInDays(t *testing.T) {
34+
tests := []struct {
35+
name string
36+
spec *s3v1alpha1.S3ObjectLockTenantSpec
37+
sg *s3v1alpha1.StorageGrid
38+
want int32
39+
}{
40+
{
41+
name: "tenant ceiling wins over the grid default",
42+
spec: &s3v1alpha1.S3ObjectLockTenantSpec{Mode: s3v1alpha1.S3ObjectLockModeGovernance, MaxRetentionInDays: 90},
43+
sg: gridWithDefault(365),
44+
want: 90,
45+
},
46+
{
47+
name: "zero is the inherit sentinel",
48+
spec: &s3v1alpha1.S3ObjectLockTenantSpec{Mode: s3v1alpha1.S3ObjectLockModeDisabled, MaxRetentionInDays: 0},
49+
sg: gridWithDefault(365),
50+
want: 365,
51+
},
52+
{
53+
name: "absent object lock config inherits the grid default",
54+
spec: nil,
55+
sg: gridWithDefault(730),
56+
want: 730,
57+
},
58+
{
59+
// The ceiling also caps Governance retention requested straight over the S3 API,
60+
// which the S3Bucket mode gate never sees, so mode must not suppress it.
61+
name: "a ceiling applies even when the mode is Disabled",
62+
spec: &s3v1alpha1.S3ObjectLockTenantSpec{Mode: s3v1alpha1.S3ObjectLockModeDisabled, MaxRetentionInDays: 30},
63+
sg: gridWithDefault(365),
64+
want: 30,
65+
},
66+
{
67+
name: "an unconfigured grid falls back to one year",
68+
spec: nil,
69+
sg: gridWithDefault(0),
70+
want: s3v1alpha1.DefaultMaxRetentionInDays,
71+
},
72+
{
73+
name: "a nil grid still resolves",
74+
spec: &s3v1alpha1.S3ObjectLockTenantSpec{},
75+
sg: nil,
76+
want: s3v1alpha1.DefaultMaxRetentionInDays,
77+
},
78+
}
79+
80+
for _, tt := range tests {
81+
t.Run(tt.name, func(t *testing.T) {
82+
got := EffectiveMaxRetentionInDays(tt.spec, tt.sg)
83+
if got != tt.want {
84+
t.Errorf("got %d, want %d", got, tt.want)
85+
}
86+
if got <= 0 {
87+
t.Errorf("resolution must never yield a non-positive ceiling, got %d", got)
88+
}
89+
if d := desiredMaxRetentionDays(tt.spec, tt.sg); d != int(tt.want) {
90+
t.Errorf("desiredMaxRetentionDays: got %d, want %d", d, tt.want)
91+
}
92+
})
93+
}
94+
}

internal/controller/s3tenantaccount_controller.go

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,7 +1139,7 @@ func (r *S3TenantAccountReconciler) reconcileCreate(ctx context.Context, rctx *a
11391139
// initial description will include some details about the account in case the reconcileDescription fails later.
11401140
initialDescription := fmt.Sprintf("Created by storagegrid-operator for S3TenantAccount %s in namespace %s at %s", rctx.Account.Name, rctx.Account.Namespace, time.Now().Format(time.RFC3339))
11411141

1142-
tenantID, password, err := grid.CreateTenant(ctx, *rctx.Account.Status.DesiredTenantBackendName, initialDescription, rctx.Account.Spec.StorageQuota.Value(), desiredAllowComplianceMode(rctx.Account.Spec.S3ObjectLock), desiredMaxRetentionDays(rctx.Account.Spec.S3ObjectLock), rctx.GridClient)
1142+
tenantID, password, err := grid.CreateTenant(ctx, *rctx.Account.Status.DesiredTenantBackendName, initialDescription, rctx.Account.Spec.StorageQuota.Value(), desiredAllowComplianceMode(rctx.Account.Spec.S3ObjectLock), desiredMaxRetentionDays(rctx.Account.Spec.S3ObjectLock, rctx.SG), rctx.GridClient)
11431143
if err != nil {
11441144
r.emitEvent(rctx, corev1.EventTypeWarning, EventTenantCreateFailed,
11451145
fmt.Sprintf("Failed to create tenant: %v", err))
@@ -1417,48 +1417,58 @@ func desiredAllowComplianceMode(spec *s3v1alpha1.S3ObjectLockTenantSpec) bool {
14171417
return spec.Mode == s3v1alpha1.S3ObjectLockModeCompliance
14181418
}
14191419

1420-
// desiredMaxRetentionDays maps the spec MaxRetentionInDays to the backend tenant's
1421-
// MaxRetentionDays. Returns nil when object lock is Disabled (no per-tenant cap).
1422-
func desiredMaxRetentionDays(spec *s3v1alpha1.S3ObjectLockTenantSpec) *int {
1423-
if spec == nil || spec.Mode == "" || spec.Mode == s3v1alpha1.S3ObjectLockModeDisabled {
1424-
return nil
1420+
// EffectiveMaxRetentionInDays resolves the S3 Object Lock retention ceiling for a tenant:
1421+
// its own MaxRetentionInDays when set, otherwise the grid-wide default.
1422+
//
1423+
// Deliberately independent of Mode. Mode governs whether an S3Bucket may enable object lock at
1424+
// all, but the ceiling also binds Governance retention a tenant can request directly over the S3
1425+
// API, where the bucket-level gate never applies.
1426+
//
1427+
// Never returns zero: StorageGrid rejects an empty ceiling once S3 Object Lock is enabled
1428+
// grid-wide, and silently substitutes 100 years for a request that carries no ceiling at all.
1429+
func EffectiveMaxRetentionInDays(spec *s3v1alpha1.S3ObjectLockTenantSpec, sg *s3v1alpha1.StorageGrid) int32 {
1430+
if spec != nil && spec.MaxRetentionInDays > 0 {
1431+
return spec.MaxRetentionInDays
1432+
}
1433+
if sg != nil && sg.Spec.DefaultMaxRetentionInDays > 0 {
1434+
return sg.Spec.DefaultMaxRetentionInDays
14251435
}
1426-
v := int(spec.MaxRetentionInDays)
1427-
return &v
1436+
return s3v1alpha1.DefaultMaxRetentionInDays
14281437
}
14291438

1430-
// intPtrEqual compares two *int values for equality, treating nil == nil as equal.
1431-
func intPtrEqual(a, b *int) bool {
1432-
if a == nil && b == nil {
1433-
return true
1434-
}
1435-
if a == nil || b == nil {
1436-
return false
1437-
}
1438-
return *a == *b
1439+
// desiredMaxRetentionDays adapts the resolved ceiling to the backend tenant's MaxRetentionDays.
1440+
func desiredMaxRetentionDays(spec *s3v1alpha1.S3ObjectLockTenantSpec, sg *s3v1alpha1.StorageGrid) int {
1441+
return int(EffectiveMaxRetentionInDays(spec, sg))
14391442
}
14401443

14411444
// reconcileObjectLockPolicy syncs the backend tenant's S3 Object Lock policy fields
1442-
// (AllowComplianceMode, MaxRetentionDays) with the spec. Issues a single full PUT on drift.
1445+
// (AllowComplianceMode, MaxRetentionDays, MaxRetentionYears) with the spec.
1446+
// Issues a single full PUT on drift.
1447+
//
1448+
// MaxRetentionYears participates in the comparison even though the operator never sets it:
1449+
// StorageGrid stamps 100 years onto any tenant created without an explicit ceiling, and a
1450+
// non-nil value there means the tenant is not yet expressed in the operator's terms.
14431451
func (r *S3TenantAccountReconciler) reconcileObjectLockPolicy(ctx context.Context, rctx *accountReconcileContext) error {
14441452
log := log.FromContext(ctx)
14451453

14461454
desiredAllow := desiredAllowComplianceMode(rctx.Account.Spec.S3ObjectLock)
1447-
desiredMax := desiredMaxRetentionDays(rctx.Account.Spec.S3ObjectLock)
1455+
desiredMax := desiredMaxRetentionDays(rctx.Account.Spec.S3ObjectLock, rctx.SG)
14481456

14491457
currentAllow := grid.GetConfiguredAllowComplianceMode(rctx.BackendTenant)
14501458
currentMax := grid.GetConfiguredMaxRetentionDays(rctx.BackendTenant)
1459+
currentYears := grid.GetConfiguredMaxRetentionYears(rctx.BackendTenant)
14511460

1452-
if currentAllow == desiredAllow && intPtrEqual(currentMax, desiredMax) {
1461+
if currentAllow == desiredAllow && currentMax != nil && *currentMax == desiredMax && currentYears == nil {
14531462
log.V(1).Info("Object lock policy already in sync")
14541463
return nil
14551464
}
14561465

14571466
log.V(1).Info("Object lock policy drift detected, updating tenant",
14581467
"currentAllowComplianceMode", currentAllow, "desiredAllowComplianceMode", desiredAllow,
1459-
"currentMaxRetentionDays", currentMax, "desiredMaxRetentionDays", desiredMax)
1468+
"currentMaxRetentionDays", currentMax, "desiredMaxRetentionDays", desiredMax,
1469+
"currentMaxRetentionYears", currentYears)
14601470
r.emitEvent(rctx, corev1.EventTypeNormal, EventTenantUpdating,
1461-
fmt.Sprintf("Updating S3 Object Lock policy (allowComplianceMode=%v, maxRetentionDays=%v)", desiredAllow, desiredMax))
1471+
fmt.Sprintf("Updating S3 Object Lock policy (allowComplianceMode=%v, maxRetentionDays=%d)", desiredAllow, desiredMax))
14621472

14631473
if err := grid.UpdateTenantObjectLockPolicy(ctx, desiredAllow, desiredMax, rctx.BackendTenant, rctx.GridClient); err != nil {
14641474
r.emitEvent(rctx, corev1.EventTypeWarning, EventTenantUpdateFailed,

internal/controller/storagegrid_controller.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,9 @@ func (r *StorageGridReconciler) doReconcile(ctx context.Context, rctx *sgReconci
185185
// probe grid-wide S3 Object Lock capability (transient failures must not flip Ready).
186186
r.reconcileS3ObjectLockCapability(ctx, rctx)
187187

188+
// mirror the grid-wide S3 Object Lock retention default into the status.
189+
r.reconcileDefaultMaxRetention(ctx, rctx)
190+
188191
// set the condition to true, as we successfully reconciled the storageGrid.
189192
r.setCondition(rctx.SG, s3v1alpha1.ConditionTypeReconcileSucceeded, metav1.ConditionTrue, "ReconcileSucceeded", "StorageGrid reconciled successfully")
190193

@@ -393,6 +396,23 @@ func (r *StorageGridReconciler) reconcileDeletionPolicy(ctx context.Context, sg
393396
}
394397
}
395398

399+
// reconcileDefaultMaxRetention mirrors the grid-wide S3 Object Lock retention default into the
400+
// status so it is visible via kubectl. The S3TenantAccount controller resolves against the spec
401+
// directly, so this mirror is purely informational and never gates a backend write.
402+
func (r *StorageGridReconciler) reconcileDefaultMaxRetention(ctx context.Context, rctx *sgReconcileContext) {
403+
log := log.FromContext(ctx)
404+
405+
desired := rctx.SG.Spec.DefaultMaxRetentionInDays
406+
if desired <= 0 {
407+
desired = s3v1alpha1.DefaultMaxRetentionInDays
408+
}
409+
410+
if rctx.SG.Status.DefaultMaxRetentionInDays != desired {
411+
log.V(1).Info(fmt.Sprintf("Updating default max retention from %d to %d days", rctx.SG.Status.DefaultMaxRetentionInDays, desired))
412+
rctx.SG.Status.DefaultMaxRetentionInDays = desired
413+
}
414+
}
415+
396416
func (r *StorageGridReconciler) initGridClient(ctx context.Context, rctx *sgReconcileContext) error {
397417
log := log.FromContext(ctx)
398418

0 commit comments

Comments
 (0)