diff --git a/stackit/internal/services/objectstorage/credential/resource.go b/stackit/internal/services/objectstorage/credential/resource.go index ba92a944..ddf0d08e 100644 --- a/stackit/internal/services/objectstorage/credential/resource.go +++ b/stackit/internal/services/objectstorage/credential/resource.go @@ -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. diff --git a/stackit/internal/utils/attributes.go b/stackit/internal/utils/attributes.go new file mode 100644 index 00000000..bddc30ba --- /dev/null +++ b/stackit/internal/utils/attributes.go @@ -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 +} diff --git a/stackit/internal/utils/attributes_test.go b/stackit/internal/utils/attributes_test.go new file mode 100644 index 00000000..b7b3c8a1 --- /dev/null +++ b/stackit/internal/utils/attributes_test.go @@ -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) + } + } + } + }) + } +}