Skip to content

Commit 2b41997

Browse files
authored
Merge pull request #446 from gridscale/backport/s3-lifecycle
Add lifecycle config to s3 resource
2 parents 828f715 + c501e27 commit 2b41997

File tree

4 files changed

+336
-0
lines changed

4 files changed

+336
-0
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 1.28.0 (July 09, 2025)
4+
5+
FEATURES:
6+
- Add lifecycle config to s3 resource. [PR #446](https://github.com/gridscale/terraform-provider-gridscale/pull/446)
7+
38
## 1.27.5 (June 24, 2025)
49

510
BUG FIXES:

gridscale/resource_gridscale_bucket.go

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package gridscale
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"log"
78
"time"
89

910
"github.com/aws/aws-sdk-go/aws"
11+
"github.com/aws/aws-sdk-go/aws/awserr"
1012
"github.com/aws/aws-sdk-go/aws/credentials"
1113
"github.com/aws/aws-sdk-go/aws/session"
1214
"github.com/aws/aws-sdk-go/service/s3"
@@ -31,6 +33,7 @@ func resourceGridscaleBucket() *schema.Resource {
3133
return &schema.Resource{
3234
Create: resourceGridscaleBucketCreate,
3335
Read: resourceGridscaleBucketRead,
36+
Update: resourceGridscaleBucketUpdate,
3437
Delete: resourceGridscaleBucketDelete,
3538
Importer: &schema.ResourceImporter{
3639
State: schema.ImportStatePassthrough,
@@ -63,15 +66,108 @@ func resourceGridscaleBucket() *schema.Resource {
6366
ForceNew: true,
6467
Default: "gos3.io",
6568
},
69+
"lifecycle_rule": {
70+
Type: schema.TypeList,
71+
Optional: true,
72+
Elem: &schema.Resource{
73+
Schema: map[string]*schema.Schema{
74+
"id": {
75+
Type: schema.TypeString,
76+
Required: true,
77+
},
78+
"enabled": {
79+
Type: schema.TypeBool,
80+
Required: true,
81+
},
82+
"prefix": {
83+
Type: schema.TypeString,
84+
Optional: true,
85+
},
86+
"expiration_days": {
87+
Type: schema.TypeInt,
88+
Optional: true,
89+
Default: 365,
90+
},
91+
"noncurrent_version_expiration_days": {
92+
Type: schema.TypeInt,
93+
Optional: true,
94+
Default: 365,
95+
},
96+
"incomplete_upload_expiration_days": {
97+
Type: schema.TypeInt,
98+
Optional: true,
99+
Default: 3,
100+
},
101+
},
102+
},
103+
},
66104
},
67105
Timeouts: &schema.ResourceTimeout{
68106
Create: schema.DefaultTimeout(5 * time.Minute),
69107
Delete: schema.DefaultTimeout(5 * time.Minute),
108+
Update: schema.DefaultTimeout(5 * time.Minute),
70109
},
71110
}
72111
}
73112

74113
func resourceGridscaleBucketRead(d *schema.ResourceData, meta interface{}) error {
114+
s3Host := d.Get("s3_host").(string)
115+
accessKey := d.Get("access_key").(string)
116+
secretKey := d.Get("secret_key").(string)
117+
bucketName := d.Get("bucket_name").(string)
118+
119+
s3Client := initS3Client(&gridscaleS3Provider{
120+
AccessKey: accessKey,
121+
SecretKey: secretKey,
122+
}, s3Host)
123+
124+
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout(schema.TimeoutRead))
125+
defer cancel()
126+
127+
// Fetch lifecycle configuration
128+
output, err := s3Client.GetBucketLifecycleConfigurationWithContext(ctx, &s3.GetBucketLifecycleConfigurationInput{
129+
Bucket: aws.String(bucketName),
130+
})
131+
if err != nil {
132+
var aerr awserr.Error
133+
if errors.As(err, &aerr) && aerr.Code() == "NoSuchLifecycleConfiguration" {
134+
// If the error indicates no lifecycle configuration exists, set the lifecycle_rule attribute to nil
135+
d.Set("lifecycle_rule", nil)
136+
} else {
137+
// For any other error, return a formatted error message with context
138+
return fmt.Errorf("error reading lifecycle configuration for bucket %s: %w", bucketName, err)
139+
}
140+
} else {
141+
rules := []map[string]interface{}{}
142+
for _, rule := range output.Rules {
143+
r := map[string]interface{}{
144+
"id": aws.StringValue(rule.ID),
145+
"enabled": aws.StringValue(rule.Status) == "Enabled",
146+
"expiration_days": 0,
147+
"noncurrent_version_expiration_days": 0,
148+
}
149+
// Check if the rule has a filter and set the prefix accordingly
150+
if rule.Filter != nil && rule.Filter.Prefix != nil {
151+
r["prefix"] = aws.StringValue(rule.Filter.Prefix)
152+
} else {
153+
r["prefix"] = ""
154+
}
155+
// Check if the rule has expiration or noncurrent version expiration days set
156+
if rule.Expiration != nil && rule.Expiration.Days != nil {
157+
r["expiration_days"] = int(*rule.Expiration.Days)
158+
}
159+
if rule.NoncurrentVersionExpiration != nil && rule.NoncurrentVersionExpiration.NoncurrentDays != nil {
160+
r["noncurrent_version_expiration_days"] = int(*rule.NoncurrentVersionExpiration.NoncurrentDays)
161+
}
162+
// Check if the rule has incomplete upload expiration days set
163+
if rule.AbortIncompleteMultipartUpload != nil && rule.AbortIncompleteMultipartUpload.DaysAfterInitiation != nil {
164+
r["incomplete_upload_expiration_days"] = int(*rule.AbortIncompleteMultipartUpload.DaysAfterInitiation)
165+
}
166+
rules = append(rules, r)
167+
}
168+
d.Set("lifecycle_rule", rules)
169+
}
170+
75171
return nil
76172
}
77173

@@ -101,13 +197,143 @@ func resourceGridscaleBucketCreate(d *schema.ResourceData, meta interface{}) err
101197
return fmt.Errorf("%s error: %v", errorPrefix, err)
102198
}
103199

200+
lifecycleRules := d.Get("lifecycle_rule").([]interface{})
201+
if len(lifecycleRules) > 0 {
202+
lifecycleConfig := &s3.BucketLifecycleConfiguration{
203+
Rules: []*s3.LifecycleRule{},
204+
}
205+
206+
for _, rule := range lifecycleRules {
207+
r := rule.(map[string]interface{})
208+
lifecycleRule := &s3.LifecycleRule{
209+
ID: aws.String(r["id"].(string)),
210+
Filter: &s3.LifecycleRuleFilter{
211+
Prefix: aws.String(r["prefix"].(string)),
212+
},
213+
Status: aws.String("Enabled"),
214+
}
215+
// Check if the rule is enabled
216+
if !r["enabled"].(bool) {
217+
lifecycleRule.Status = aws.String("Disabled")
218+
}
219+
// Set expiration days if provided
220+
if v, ok := r["expiration_days"].(int); ok && v > 0 {
221+
lifecycleRule.Expiration = &s3.LifecycleExpiration{
222+
Days: aws.Int64(int64(v)),
223+
}
224+
}
225+
// Set noncurrent version expiration days if provided
226+
if v, ok := r["noncurrent_version_expiration_days"].(int); ok && v > 0 {
227+
lifecycleRule.NoncurrentVersionExpiration = &s3.NoncurrentVersionExpiration{
228+
NoncurrentDays: aws.Int64(int64(v)),
229+
}
230+
}
231+
// Set incomplete upload expiration days if provided
232+
if v, ok := r["incomplete_upload_expiration_days"].(int); ok && v > 0 {
233+
lifecycleRule.AbortIncompleteMultipartUpload = &s3.AbortIncompleteMultipartUpload{
234+
DaysAfterInitiation: aws.Int64(int64(v)),
235+
}
236+
}
237+
238+
lifecycleConfig.Rules = append(lifecycleConfig.Rules, lifecycleRule)
239+
}
240+
241+
_, err := s3Client.PutBucketLifecycleConfigurationWithContext(ctx, &s3.PutBucketLifecycleConfigurationInput{
242+
Bucket: &bucketNameStr,
243+
LifecycleConfiguration: lifecycleConfig,
244+
})
245+
if err != nil {
246+
// Delete the bucket if lifecycle configuration fails to set
247+
return resourceGridscaleBucketDelete(d, meta)
248+
}
249+
}
250+
104251
id := fmt.Sprintf("%s/%s", s3HostStr, bucketNameStr)
105252
d.SetId(id)
106253

107254
log.Printf("The id for the new bucket has been set to %v", id)
108255
return nil
109256
}
110257

258+
func resourceGridscaleBucketUpdate(d *schema.ResourceData, meta interface{}) error {
259+
s3Host := d.Get("s3_host").(string)
260+
accessKey := d.Get("access_key").(string)
261+
secretKey := d.Get("secret_key").(string)
262+
bucketName := d.Get("bucket_name").(string)
263+
264+
s3Client := initS3Client(&gridscaleS3Provider{
265+
AccessKey: accessKey,
266+
SecretKey: secretKey,
267+
}, s3Host)
268+
269+
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout(schema.TimeoutUpdate))
270+
defer cancel()
271+
272+
if d.HasChange("lifecycle_rule") {
273+
lifecycleRules := d.Get("lifecycle_rule").([]interface{})
274+
275+
if len(lifecycleRules) == 0 {
276+
// If no lifecycle rules are provided, clear the lifecycle configuration
277+
_, err := s3Client.DeleteBucketLifecycleWithContext(ctx, &s3.DeleteBucketLifecycleInput{
278+
Bucket: aws.String(bucketName),
279+
})
280+
if err != nil {
281+
return fmt.Errorf("error clearing lifecycle configuration for bucket %s using DeleteBucketLifecycle: %v", bucketName, err)
282+
}
283+
return resourceGridscaleBucketRead(d, meta)
284+
} else {
285+
lifecycleConfig := &s3.BucketLifecycleConfiguration{
286+
Rules: []*s3.LifecycleRule{},
287+
}
288+
289+
for _, rule := range lifecycleRules {
290+
r := rule.(map[string]interface{})
291+
lifecycleRule := &s3.LifecycleRule{
292+
ID: aws.String(r["id"].(string)),
293+
Filter: &s3.LifecycleRuleFilter{
294+
Prefix: aws.String(r["prefix"].(string)),
295+
},
296+
Status: aws.String("Enabled"),
297+
}
298+
299+
if !r["enabled"].(bool) {
300+
lifecycleRule.Status = aws.String("Disabled")
301+
}
302+
303+
if v, ok := r["expiration_days"].(int); ok && v > 0 {
304+
lifecycleRule.Expiration = &s3.LifecycleExpiration{
305+
Days: aws.Int64(int64(v)),
306+
}
307+
}
308+
309+
if v, ok := r["noncurrent_version_expiration_days"].(int); ok && v > 0 {
310+
lifecycleRule.NoncurrentVersionExpiration = &s3.NoncurrentVersionExpiration{
311+
NoncurrentDays: aws.Int64(int64(v)),
312+
}
313+
}
314+
315+
if v, ok := r["incomplete_upload_expiration_days"].(int); ok && v > 0 {
316+
lifecycleRule.AbortIncompleteMultipartUpload = &s3.AbortIncompleteMultipartUpload{
317+
DaysAfterInitiation: aws.Int64(int64(v)),
318+
}
319+
}
320+
321+
lifecycleConfig.Rules = append(lifecycleConfig.Rules, lifecycleRule)
322+
}
323+
324+
_, err := s3Client.PutBucketLifecycleConfigurationWithContext(ctx, &s3.PutBucketLifecycleConfigurationInput{
325+
Bucket: aws.String(bucketName),
326+
LifecycleConfiguration: lifecycleConfig,
327+
})
328+
if err != nil {
329+
return fmt.Errorf("error updating lifecycle configuration for bucket %s: %v", bucketName, err)
330+
}
331+
}
332+
}
333+
334+
return resourceGridscaleBucketRead(d, meta)
335+
}
336+
111337
func resourceGridscaleBucketDelete(d *schema.ResourceData, meta interface{}) error {
112338
s3Host := d.Get("s3_host")
113339
accessKey := d.Get("access_key")

gridscale/resource_gridscale_bucket_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,101 @@ resource "gridscale_object_storage_bucket" "foo" {
4545
}
4646
`
4747
}
48+
49+
func TestAccResourceGridscaleBucketLifecycleRules(t *testing.T) {
50+
resource.Test(t, resource.TestCase{
51+
PreCheck: func() { testAccPreCheck(t) },
52+
Providers: testAccProviders,
53+
Steps: []resource.TestStep{
54+
{
55+
Config: testAccCheckResourceGridscaleBucketConfigWithLifecycleRules(),
56+
Check: resource.ComposeTestCheckFunc(
57+
resource.TestCheckResourceAttr(
58+
"gridscale_object_storage_bucket.foo", "lifecycle_rule.0.id", "rule1"),
59+
resource.TestCheckResourceAttr(
60+
"gridscale_object_storage_bucket.foo", "lifecycle_rule.0.enabled", "true"),
61+
resource.TestCheckResourceAttr(
62+
"gridscale_object_storage_bucket.foo", "lifecycle_rule.0.expiration_days", "30"),
63+
),
64+
},
65+
{
66+
Config: testAccCheckResourceGridscaleBucketConfigWithUpdatedLifecycleRules(),
67+
Check: resource.ComposeTestCheckFunc(
68+
resource.TestCheckResourceAttr(
69+
"gridscale_object_storage_bucket.foo", "lifecycle_rule.0.id", "rule1"),
70+
resource.TestCheckResourceAttr(
71+
"gridscale_object_storage_bucket.foo", "lifecycle_rule.0.enabled", "false"),
72+
resource.TestCheckResourceAttr(
73+
"gridscale_object_storage_bucket.foo", "lifecycle_rule.0.expiration_days", "60"),
74+
),
75+
},
76+
{
77+
Config: testAccCheckResourceGridscaleBucketConfigWithoutLifecycleRules(),
78+
Check: resource.ComposeTestCheckFunc(
79+
resource.TestCheckNoResourceAttr(
80+
"gridscale_object_storage_bucket.foo", "lifecycle_rule"),
81+
),
82+
},
83+
},
84+
})
85+
}
86+
87+
func testAccCheckResourceGridscaleBucketConfigWithLifecycleRules() string {
88+
return `
89+
resource "gridscale_object_storage_accesskey" "test" {
90+
timeouts {
91+
create="10m"
92+
}
93+
}
94+
95+
resource "gridscale_object_storage_bucket" "foo" {
96+
access_key = gridscale_object_storage_accesskey.test.access_key
97+
secret_key = gridscale_object_storage_accesskey.test.secret_key
98+
bucket_name = "myterraformbucket"
99+
100+
lifecycle_rule {
101+
id = "rule1"
102+
enabled = true
103+
expiration_days = 30
104+
}
105+
}
106+
`
107+
}
108+
109+
func testAccCheckResourceGridscaleBucketConfigWithUpdatedLifecycleRules() string {
110+
return `
111+
resource "gridscale_object_storage_accesskey" "test" {
112+
timeouts {
113+
create="10m"
114+
}
115+
}
116+
117+
resource "gridscale_object_storage_bucket" "foo" {
118+
access_key = gridscale_object_storage_accesskey.test.access_key
119+
secret_key = gridscale_object_storage_accesskey.test.secret_key
120+
bucket_name = "myterraformbucket"
121+
122+
lifecycle_rule {
123+
id = "rule1"
124+
enabled = false
125+
expiration_days = 60
126+
}
127+
}
128+
`
129+
}
130+
131+
func testAccCheckResourceGridscaleBucketConfigWithoutLifecycleRules() string {
132+
return `
133+
resource "gridscale_object_storage_accesskey" "test" {
134+
timeouts {
135+
create="10m"
136+
}
137+
}
138+
139+
resource "gridscale_object_storage_bucket" "foo" {
140+
access_key = gridscale_object_storage_accesskey.test.access_key
141+
secret_key = gridscale_object_storage_accesskey.test.secret_key
142+
bucket_name = "myterraformbucket"
143+
}
144+
`
145+
}

website/docs/r/object_storage_bucket.html.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,10 @@ The following arguments are supported:
4242
* `secret_key` - (Required, Force New) Secret key.
4343
* `s3_host` - (Required, Force New) Host of the s3. Default: "gos3.io".
4444
* `bucket_name` - (Required, Force New) Name of the bucket.
45+
* `lifecycle_rule` - (Optional) A list of lifecycle rules for the bucket. Each rule supports the following:
46+
* `id` - (Required) Unique identifier for the rule.
47+
* `enabled` - (Required) Whether the rule is enabled.
48+
* `prefix` - (Optional) Object key prefix identifying one or more objects to which the rule applies.
49+
* `expiration_days` - (Optional) Number of days after which objects are deleted. Default: `365`.
50+
* `noncurrent_version_expiration_days` - (Optional) Number of days after which noncurrent object versions are deleted. Default: `365`.
51+
* `incomplete_upload_expiration_days` - (Optional) Number of days after which incomplete multipart uploads are deleted. Default: `3`.

0 commit comments

Comments
 (0)