fix: handle expiration date in regard to changed timezones (#667)
This commit is contained in:
parent
2923621ab0
commit
e9af986913
3 changed files with 212 additions and 3 deletions
|
|
@ -86,6 +86,34 @@ func (r *credentialResource) ModifyPlan(ctx context.Context, req resource.Modify
|
|||
}
|
||||
}
|
||||
|
||||
// ModifyPlan implements resource.ResourceWithModifyPlan.
|
||||
func (r *credentialResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform
|
||||
p := path.Root("expiration_timestamp")
|
||||
var (
|
||||
stateDate time.Time
|
||||
planDate time.Time
|
||||
)
|
||||
|
||||
resp.Diagnostics.Append(utils.GetTimeFromStringAttribute(ctx, p, req.State, time.RFC3339, &stateDate)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
resp.Diagnostics.Append(utils.GetTimeFromStringAttribute(ctx, p, resp.Plan, time.RFC3339, &planDate)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
// replace the planned expiration time with the current state date, iff they represent
|
||||
// the same point in time (but perhaps with different textual representation)
|
||||
// this will prevent no-op updates
|
||||
if stateDate.Equal(planDate) {
|
||||
resp.Diagnostics.Append(resp.Plan.SetAttribute(ctx, p, types.StringValue(stateDate.Format(time.RFC3339)))...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata returns the resource type name.
|
||||
func (r *credentialResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||
resp.TypeName = req.ProviderTypeName + "_objectstorage_credential"
|
||||
|
|
@ -202,6 +230,7 @@ func (r *credentialResource) Schema(_ context.Context, _ resource.SchemaRequest,
|
|||
},
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
stringplanmodifier.RequiresReplace(),
|
||||
},
|
||||
},
|
||||
"region": schema.StringAttribute{
|
||||
|
|
@ -265,6 +294,25 @@ func (r *credentialResource) Create(ctx context.Context, req resource.CreateRequ
|
|||
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credential", fmt.Sprintf("Processing API payload: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
actualDate time.Time
|
||||
planDate time.Time
|
||||
)
|
||||
resp.Diagnostics.Append(utils.ToTime(ctx, time.RFC3339, model.ExpirationTimestamp, &actualDate)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
resp.Diagnostics.Append(utils.GetTimeFromStringAttribute(ctx, path.Root("expiration_timestamp"), req.Plan, time.RFC3339, &planDate)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
// replace the planned expiration date with the original date, iff
|
||||
// they represent the same point in time, (perhaps with different textual representations)
|
||||
if actualDate.Equal(planDate) {
|
||||
model.ExpirationTimestamp = types.StringValue(planDate.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
diags = resp.State.Set(ctx, model)
|
||||
resp.Diagnostics.Append(diags...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
|
|
@ -301,6 +349,26 @@ func (r *credentialResource) Read(ctx context.Context, req resource.ReadRequest,
|
|||
resp.State.RemoveResource(ctx)
|
||||
return
|
||||
}
|
||||
var (
|
||||
currentApiDate time.Time
|
||||
stateDate time.Time
|
||||
)
|
||||
|
||||
resp.Diagnostics.Append(utils.ToTime(ctx, time.RFC3339, model.ExpirationTimestamp, ¤tApiDate)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.Append(utils.GetTimeFromStringAttribute(ctx, path.Root("expiration_timestamp"), req.State, time.RFC3339, &stateDate)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
// replace the resulting expiration date with the original date, iff
|
||||
// they represent the same point in time, (perhaps with different textual representations)
|
||||
if currentApiDate.Equal(stateDate) {
|
||||
model.ExpirationTimestamp = types.StringValue(stateDate.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// Set refreshed state
|
||||
diags = resp.State.Set(ctx, model)
|
||||
|
|
@ -312,9 +380,16 @@ func (r *credentialResource) Read(ctx context.Context, req resource.ReadRequest,
|
|||
}
|
||||
|
||||
// Update updates the resource and sets the updated Terraform state on success.
|
||||
func (r *credentialResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
|
||||
// Update shouldn't be called
|
||||
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating credential", "Credential can't be updated")
|
||||
func (r *credentialResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
|
||||
/*
|
||||
While a credential cannot be updated, the Update call must not be prevented with an error:
|
||||
When the expiration timestamp has been updated to the same point in time, but e.g. with a different timezone,
|
||||
terraform will still trigger an Update due to the computed attributes. These will not change,
|
||||
but terraform has no way of knowing this without calling this function. So it is
|
||||
still updated as a no-op.
|
||||
A possible enhancement would be to emit an error, if it is attempted to change one of the not computed attributes
|
||||
and abort with an error in this case.
|
||||
*/
|
||||
}
|
||||
|
||||
// Delete deletes the resource and removes the Terraform state on success.
|
||||
|
|
|
|||
46
stackit/internal/utils/attributes.go
Normal file
46
stackit/internal/utils/attributes.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/diag"
|
||||
"github.com/hashicorp/terraform-plugin-framework/path"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
|
||||
)
|
||||
|
||||
type attributeGetter interface {
|
||||
GetAttribute(ctx context.Context, attributePath path.Path, target interface{}) diag.Diagnostics
|
||||
}
|
||||
|
||||
func ToTime(ctx context.Context, format string, val types.String, target *time.Time) (diags diag.Diagnostics) {
|
||||
var err error
|
||||
text := val.ValueString()
|
||||
*target, err = time.Parse(format, text)
|
||||
if err != nil {
|
||||
core.LogAndAddError(ctx, &diags, "cannot parse date", fmt.Sprintf("cannot parse date %q with format %q: %v", text, format, err))
|
||||
return diags
|
||||
}
|
||||
return diags
|
||||
}
|
||||
|
||||
// GetTimeFromStringAttribute retrieves a string attribute from e.g. a [plan.Plan], [tfsdk.Config] or a [tfsdk.State] and
|
||||
// converts it to a [time.Time] object with a given format, if possible.
|
||||
func GetTimeFromStringAttribute(ctx context.Context, attributePath path.Path, source attributeGetter, dateFormat string, target *time.Time) (diags diag.Diagnostics) {
|
||||
var date types.String
|
||||
diags.Append(source.GetAttribute(ctx, attributePath, &date)...)
|
||||
if diags.HasError() {
|
||||
return diags
|
||||
}
|
||||
if date.IsNull() || date.IsUnknown() {
|
||||
return diags
|
||||
}
|
||||
diags.Append(ToTime(ctx, dateFormat, date, target)...)
|
||||
if diags.HasError() {
|
||||
return diags
|
||||
}
|
||||
|
||||
return diags
|
||||
}
|
||||
88
stackit/internal/utils/attributes_test.go
Normal file
88
stackit/internal/utils/attributes_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/diag"
|
||||
"github.com/hashicorp/terraform-plugin-framework/path"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
)
|
||||
|
||||
type attributeGetterFunc func(ctx context.Context, attributePath path.Path, target interface{}) diag.Diagnostics
|
||||
|
||||
func (a attributeGetterFunc) GetAttribute(ctx context.Context, attributePath path.Path, target interface{}) diag.Diagnostics {
|
||||
return a(ctx, attributePath, target)
|
||||
}
|
||||
|
||||
func mustLocation(name string) *time.Location {
|
||||
loc, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
log.Panicf("cannot load location %s: %v", name, err)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
func TestGetTimeFromString(t *testing.T) {
|
||||
type args struct {
|
||||
path path.Path
|
||||
source attributeGetterFunc
|
||||
dateFormat string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantErr bool
|
||||
want time.Time
|
||||
}{
|
||||
{
|
||||
name: "simple string",
|
||||
args: args{
|
||||
path: path.Root("foo"),
|
||||
source: func(_ context.Context, _ path.Path, target interface{}) diag.Diagnostics {
|
||||
t, ok := target.(*types.String)
|
||||
if !ok {
|
||||
log.Panicf("wrong type %T", target)
|
||||
}
|
||||
*t = types.StringValue("2025-02-06T09:41:00+01:00")
|
||||
return nil
|
||||
},
|
||||
dateFormat: time.RFC3339,
|
||||
},
|
||||
want: time.Date(2025, 2, 6, 9, 41, 0, 0, mustLocation("Europe/Berlin")),
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
args: args{
|
||||
path: path.Root("foo"),
|
||||
source: func(_ context.Context, p path.Path, _ interface{}) (diags diag.Diagnostics) {
|
||||
diags.AddAttributeError(p, "kapow", "kapow")
|
||||
return diags
|
||||
},
|
||||
dateFormat: time.RFC3339,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var target time.Time
|
||||
gotDiags := GetTimeFromStringAttribute(context.Background(), tt.args.path, tt.args.source, tt.args.dateFormat, &target)
|
||||
if tt.wantErr {
|
||||
if !gotDiags.HasError() {
|
||||
t.Errorf("expected error")
|
||||
}
|
||||
} else {
|
||||
if gotDiags.HasError() {
|
||||
t.Errorf("expected no errors, but got %v", gotDiags)
|
||||
} else {
|
||||
if want, got := tt.want, target; !want.Equal(got) {
|
||||
t.Errorf("got wrong date, want %s but got %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue