Skip to content

Commit e9af986

Browse files
authored
fix: handle expiration date in regard to changed timezones (#667)
1 parent 2923621 commit e9af986

3 files changed

Lines changed: 212 additions & 3 deletions

File tree

stackit/internal/services/objectstorage/credential/resource.go

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,34 @@ func (r *credentialResource) ModifyPlan(ctx context.Context, req resource.Modify
8686
}
8787
}
8888

89+
// ModifyPlan implements resource.ResourceWithModifyPlan.
90+
func (r *credentialResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
91+
p := path.Root("expiration_timestamp")
92+
var (
93+
stateDate time.Time
94+
planDate time.Time
95+
)
96+
97+
resp.Diagnostics.Append(utils.GetTimeFromStringAttribute(ctx, p, req.State, time.RFC3339, &stateDate)...)
98+
if resp.Diagnostics.HasError() {
99+
return
100+
}
101+
resp.Diagnostics.Append(utils.GetTimeFromStringAttribute(ctx, p, resp.Plan, time.RFC3339, &planDate)...)
102+
if resp.Diagnostics.HasError() {
103+
return
104+
}
105+
106+
// replace the planned expiration time with the current state date, iff they represent
107+
// the same point in time (but perhaps with different textual representation)
108+
// this will prevent no-op updates
109+
if stateDate.Equal(planDate) {
110+
resp.Diagnostics.Append(resp.Plan.SetAttribute(ctx, p, types.StringValue(stateDate.Format(time.RFC3339)))...)
111+
if resp.Diagnostics.HasError() {
112+
return
113+
}
114+
}
115+
}
116+
89117
// Metadata returns the resource type name.
90118
func (r *credentialResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
91119
resp.TypeName = req.ProviderTypeName + "_objectstorage_credential"
@@ -202,6 +230,7 @@ func (r *credentialResource) Schema(_ context.Context, _ resource.SchemaRequest,
202230
},
203231
PlanModifiers: []planmodifier.String{
204232
stringplanmodifier.UseStateForUnknown(),
233+
stringplanmodifier.RequiresReplace(),
205234
},
206235
},
207236
"region": schema.StringAttribute{
@@ -265,6 +294,25 @@ func (r *credentialResource) Create(ctx context.Context, req resource.CreateRequ
265294
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credential", fmt.Sprintf("Processing API payload: %v", err))
266295
return
267296
}
297+
298+
var (
299+
actualDate time.Time
300+
planDate time.Time
301+
)
302+
resp.Diagnostics.Append(utils.ToTime(ctx, time.RFC3339, model.ExpirationTimestamp, &actualDate)...)
303+
if resp.Diagnostics.HasError() {
304+
return
305+
}
306+
resp.Diagnostics.Append(utils.GetTimeFromStringAttribute(ctx, path.Root("expiration_timestamp"), req.Plan, time.RFC3339, &planDate)...)
307+
if resp.Diagnostics.HasError() {
308+
return
309+
}
310+
// replace the planned expiration date with the original date, iff
311+
// they represent the same point in time, (perhaps with different textual representations)
312+
if actualDate.Equal(planDate) {
313+
model.ExpirationTimestamp = types.StringValue(planDate.Format(time.RFC3339))
314+
}
315+
268316
diags = resp.State.Set(ctx, model)
269317
resp.Diagnostics.Append(diags...)
270318
if resp.Diagnostics.HasError() {
@@ -301,6 +349,26 @@ func (r *credentialResource) Read(ctx context.Context, req resource.ReadRequest,
301349
resp.State.RemoveResource(ctx)
302350
return
303351
}
352+
var (
353+
currentApiDate time.Time
354+
stateDate time.Time
355+
)
356+
357+
resp.Diagnostics.Append(utils.ToTime(ctx, time.RFC3339, model.ExpirationTimestamp, &currentApiDate)...)
358+
if resp.Diagnostics.HasError() {
359+
return
360+
}
361+
362+
resp.Diagnostics.Append(utils.GetTimeFromStringAttribute(ctx, path.Root("expiration_timestamp"), req.State, time.RFC3339, &stateDate)...)
363+
if resp.Diagnostics.HasError() {
364+
return
365+
}
366+
367+
// replace the resulting expiration date with the original date, iff
368+
// they represent the same point in time, (perhaps with different textual representations)
369+
if currentApiDate.Equal(stateDate) {
370+
model.ExpirationTimestamp = types.StringValue(stateDate.Format(time.RFC3339))
371+
}
304372

305373
// Set refreshed state
306374
diags = resp.State.Set(ctx, model)
@@ -312,9 +380,16 @@ func (r *credentialResource) Read(ctx context.Context, req resource.ReadRequest,
312380
}
313381

314382
// Update updates the resource and sets the updated Terraform state on success.
315-
func (r *credentialResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
316-
// Update shouldn't be called
317-
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating credential", "Credential can't be updated")
383+
func (r *credentialResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
384+
/*
385+
While a credential cannot be updated, the Update call must not be prevented with an error:
386+
When the expiration timestamp has been updated to the same point in time, but e.g. with a different timezone,
387+
terraform will still trigger an Update due to the computed attributes. These will not change,
388+
but terraform has no way of knowing this without calling this function. So it is
389+
still updated as a no-op.
390+
A possible enhancement would be to emit an error, if it is attempted to change one of the not computed attributes
391+
and abort with an error in this case.
392+
*/
318393
}
319394

320395
// Delete deletes the resource and removes the Terraform state on success.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package utils
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/hashicorp/terraform-plugin-framework/diag"
9+
"github.com/hashicorp/terraform-plugin-framework/path"
10+
"github.com/hashicorp/terraform-plugin-framework/types"
11+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
12+
)
13+
14+
type attributeGetter interface {
15+
GetAttribute(ctx context.Context, attributePath path.Path, target interface{}) diag.Diagnostics
16+
}
17+
18+
func ToTime(ctx context.Context, format string, val types.String, target *time.Time) (diags diag.Diagnostics) {
19+
var err error
20+
text := val.ValueString()
21+
*target, err = time.Parse(format, text)
22+
if err != nil {
23+
core.LogAndAddError(ctx, &diags, "cannot parse date", fmt.Sprintf("cannot parse date %q with format %q: %v", text, format, err))
24+
return diags
25+
}
26+
return diags
27+
}
28+
29+
// GetTimeFromStringAttribute retrieves a string attribute from e.g. a [plan.Plan], [tfsdk.Config] or a [tfsdk.State] and
30+
// converts it to a [time.Time] object with a given format, if possible.
31+
func GetTimeFromStringAttribute(ctx context.Context, attributePath path.Path, source attributeGetter, dateFormat string, target *time.Time) (diags diag.Diagnostics) {
32+
var date types.String
33+
diags.Append(source.GetAttribute(ctx, attributePath, &date)...)
34+
if diags.HasError() {
35+
return diags
36+
}
37+
if date.IsNull() || date.IsUnknown() {
38+
return diags
39+
}
40+
diags.Append(ToTime(ctx, dateFormat, date, target)...)
41+
if diags.HasError() {
42+
return diags
43+
}
44+
45+
return diags
46+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package utils
2+
3+
import (
4+
"context"
5+
"log"
6+
"testing"
7+
"time"
8+
9+
"github.com/hashicorp/terraform-plugin-framework/diag"
10+
"github.com/hashicorp/terraform-plugin-framework/path"
11+
"github.com/hashicorp/terraform-plugin-framework/types"
12+
)
13+
14+
type attributeGetterFunc func(ctx context.Context, attributePath path.Path, target interface{}) diag.Diagnostics
15+
16+
func (a attributeGetterFunc) GetAttribute(ctx context.Context, attributePath path.Path, target interface{}) diag.Diagnostics {
17+
return a(ctx, attributePath, target)
18+
}
19+
20+
func mustLocation(name string) *time.Location {
21+
loc, err := time.LoadLocation(name)
22+
if err != nil {
23+
log.Panicf("cannot load location %s: %v", name, err)
24+
}
25+
return loc
26+
}
27+
28+
func TestGetTimeFromString(t *testing.T) {
29+
type args struct {
30+
path path.Path
31+
source attributeGetterFunc
32+
dateFormat string
33+
}
34+
tests := []struct {
35+
name string
36+
args args
37+
wantErr bool
38+
want time.Time
39+
}{
40+
{
41+
name: "simple string",
42+
args: args{
43+
path: path.Root("foo"),
44+
source: func(_ context.Context, _ path.Path, target interface{}) diag.Diagnostics {
45+
t, ok := target.(*types.String)
46+
if !ok {
47+
log.Panicf("wrong type %T", target)
48+
}
49+
*t = types.StringValue("2025-02-06T09:41:00+01:00")
50+
return nil
51+
},
52+
dateFormat: time.RFC3339,
53+
},
54+
want: time.Date(2025, 2, 6, 9, 41, 0, 0, mustLocation("Europe/Berlin")),
55+
},
56+
{
57+
name: "invalid type",
58+
args: args{
59+
path: path.Root("foo"),
60+
source: func(_ context.Context, p path.Path, _ interface{}) (diags diag.Diagnostics) {
61+
diags.AddAttributeError(p, "kapow", "kapow")
62+
return diags
63+
},
64+
dateFormat: time.RFC3339,
65+
},
66+
wantErr: true,
67+
},
68+
}
69+
for _, tt := range tests {
70+
t.Run(tt.name, func(t *testing.T) {
71+
var target time.Time
72+
gotDiags := GetTimeFromStringAttribute(context.Background(), tt.args.path, tt.args.source, tt.args.dateFormat, &target)
73+
if tt.wantErr {
74+
if !gotDiags.HasError() {
75+
t.Errorf("expected error")
76+
}
77+
} else {
78+
if gotDiags.HasError() {
79+
t.Errorf("expected no errors, but got %v", gotDiags)
80+
} else {
81+
if want, got := tt.want, target; !want.Equal(got) {
82+
t.Errorf("got wrong date, want %s but got %s", want, got)
83+
}
84+
}
85+
}
86+
})
87+
}
88+
}

0 commit comments

Comments
 (0)