From 8712b7316e5fdb70729b347776dc6636c7603718 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Mon, 15 Sep 2025 12:13:04 +0200 Subject: [PATCH 01/20] add continue attribute for observability service alert config --- .../observability/instance/datasource.go | 4 ++++ .../services/observability/instance/resource.go | 17 +++++++++++++++++ .../observability/instance/resource_test.go | 16 ++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/stackit/internal/services/observability/instance/datasource.go b/stackit/internal/services/observability/instance/datasource.go index 9f3a86346..ee8761d34 100644 --- a/stackit/internal/services/observability/instance/datasource.go +++ b/stackit/internal/services/observability/instance/datasource.go @@ -308,6 +308,10 @@ func (d *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaReques Description: "How long to initially wait to send a notification for a group of alerts. Allows to wait for an inhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.) .", Computed: true, }, + "continue": schema.BoolAttribute{ + Description: "Whether an alert should continue matching subsequent sibling nodes.", + Computed: true, + }, "receiver": schema.StringAttribute{ Description: "The name of the receiver to route the alerts to.", Computed: true, diff --git a/stackit/internal/services/observability/instance/resource.go b/stackit/internal/services/observability/instance/resource.go index eccad5e48..1fe618ed1 100644 --- a/stackit/internal/services/observability/instance/resource.go +++ b/stackit/internal/services/observability/instance/resource.go @@ -119,6 +119,7 @@ var globalConfigurationTypes = map[string]attr.Type{ // Struct corresponding to Model.AlertConfig.route type mainRouteModel struct { + Continue types.Bool `tfsdk:"continue"` GroupBy types.List `tfsdk:"group_by"` GroupInterval types.String `tfsdk:"group_interval"` GroupWait types.String `tfsdk:"group_wait"` @@ -130,6 +131,7 @@ type mainRouteModel struct { // Struct corresponding to Model.AlertConfig.route // This is used to map the routes between the mainRouteModel and the last level of recursion of the routes field type routeModelMiddle struct { + Continue types.Bool `tfsdk:"continue"` GroupBy types.List `tfsdk:"group_by"` GroupInterval types.String `tfsdk:"group_interval"` GroupWait types.String `tfsdk:"group_wait"` @@ -146,6 +148,7 @@ type routeModelMiddle struct { // Struct corresponding to Model.AlertConfig.route but without the recursive routes field // This is used to map the last level of recursion of the routes field type routeModelNoRoutes struct { + Continue types.Bool `tfsdk:"continue"` GroupBy types.List `tfsdk:"group_by"` GroupInterval types.String `tfsdk:"group_interval"` GroupWait types.String `tfsdk:"group_wait"` @@ -159,6 +162,7 @@ type routeModelNoRoutes struct { } var routeTypes = map[string]attr.Type{ + "continue": types.BoolType, "group_by": types.ListType{ElemType: types.StringType}, "group_interval": types.StringType, "group_wait": types.StringType, @@ -236,6 +240,7 @@ var webHooksConfigsTypes = map[string]attr.Type{ } var routeDescriptions = map[string]string{ + "continue": "Whether an alert should continue matching subsequent sibling nodes.", "group_by": "The labels by which incoming alerts are grouped together. For example, multiple alerts coming in for cluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels use the special value '...' as the sole label name, for example: group_by: ['...']. This effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what you want, unless you have a very low alert volume or your upstream notification system performs its own grouping.", "group_interval": "How long to wait before sending a notification about new alerts that are added to a group of alerts for which an initial notification has already been sent. (Usually ~5m or more.)", "group_wait": "How long to initially wait to send a notification for a group of alerts. Allows to wait for an inhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.)", @@ -258,6 +263,7 @@ func getRouteListType() types.ObjectType { // The level should be lower or equal to the limit, if higher, the function will produce a stack overflow. func getRouteListTypeAux(level, limit int) types.ObjectType { attributeTypes := map[string]attr.Type{ + "continue": types.BoolType, "group_by": types.ListType{ElemType: types.StringType}, "group_interval": types.StringType, "group_wait": types.StringType, @@ -290,6 +296,11 @@ func getDatasourceRouteNestedObject() schema.ListNestedAttribute { // The level should be lower or equal to the limit, if higher, the function will produce a stack overflow. func getRouteNestedObjectAux(isDatasource bool, level, limit int) schema.ListNestedAttribute { attributesMap := map[string]schema.Attribute{ + "continue": schema.BoolAttribute{ + Description: routeDescriptions["continue"], + Optional: !isDatasource, + Computed: isDatasource, + }, "group_by": schema.ListAttribute{ Description: routeDescriptions["group_by"], Optional: !isDatasource, @@ -1550,6 +1561,7 @@ func getMockAlertConfig(ctx context.Context) (alertConfigModel, error) { } mockRoute, diags := types.ObjectValue(routeTypes, map[string]attr.Value{ + "continue": types.BoolNull(), "receiver": types.StringValue("email-me"), "group_by": mockGroupByList, "group_wait": types.StringValue("30s"), @@ -1773,6 +1785,7 @@ func mapRouteToAttributes(ctx context.Context, route *observability.Route) (attr } routeMap := map[string]attr.Value{ + "continue": types.BoolPointerValue(route.Continue), "group_by": groupByModel, "group_interval": types.StringPointerValue(route.GroupInterval), "group_wait": types.StringPointerValue(route.GroupWait), @@ -1822,6 +1835,7 @@ func mapChildRoutesToAttributes(ctx context.Context, routes *[]observability.Rou } routeMap := map[string]attr.Value{ + "continue": types.BoolPointerValue(route.Continue), "group_by": groupByModel, "group_interval": types.StringPointerValue(route.GroupInterval), "group_wait": types.StringPointerValue(route.GroupWait), @@ -2082,6 +2096,7 @@ func toRoutePayload(ctx context.Context, routeTF *mainRouteModel) (*observabilit } for i := range lastChildRoutes { childRoute := routeModelMiddle{ + Continue: lastChildRoutes[i].Continue, GroupBy: lastChildRoutes[i].GroupBy, GroupInterval: lastChildRoutes[i].GroupInterval, GroupWait: lastChildRoutes[i].GroupWait, @@ -2110,6 +2125,7 @@ func toRoutePayload(ctx context.Context, routeTF *mainRouteModel) (*observabilit } return &observability.UpdateAlertConfigsPayloadRoute{ + Continue: conversion.BoolValueToPointer(routeTF.Continue), GroupBy: groupByPayload, GroupInterval: conversion.StringValueToPointer(routeTF.GroupInterval), GroupWait: conversion.StringValueToPointer(routeTF.GroupWait), @@ -2160,6 +2176,7 @@ func toChildRoutePayload(ctx context.Context, routeTF *routeModelMiddle) (*obser } return &observability.UpdateAlertConfigsPayloadRouteRoutesInner{ + Continue: conversion.BoolValueToPointer(routeTF.Continue), GroupBy: groupByPayload, GroupInterval: conversion.StringValueToPointer(routeTF.GroupInterval), GroupWait: conversion.StringValueToPointer(routeTF.GroupWait), diff --git a/stackit/internal/services/observability/instance/resource_test.go b/stackit/internal/services/observability/instance/resource_test.go index e9229ccb5..f0516ae39 100644 --- a/stackit/internal/services/observability/instance/resource_test.go +++ b/stackit/internal/services/observability/instance/resource_test.go @@ -65,6 +65,7 @@ func fixtureReceiverModel(emailConfigs, opsGenieConfigs, webHooksConfigs basetyp func fixtureRouteModel() basetypes.ObjectValue { return types.ObjectValueMust(routeTypes, map[string]attr.Value{ + "continue": types.BoolValue(true), "group_by": types.ListValueMust(types.StringType, []attr.Value{ types.StringValue("label1"), types.StringValue("label2"), @@ -76,6 +77,7 @@ func fixtureRouteModel() basetypes.ObjectValue { // "routes": types.ListNull(getRouteListType()), "routes": types.ListValueMust(getRouteListType(), []attr.Value{ types.ObjectValueMust(getRouteListType().AttrTypes, map[string]attr.Value{ + "continue": types.BoolValue(false), "group_by": types.ListValueMust(types.StringType, []attr.Value{ types.StringValue("label1"), types.StringValue("label2"), @@ -97,6 +99,7 @@ func fixtureRouteModel() basetypes.ObjectValue { func fixtureNullRouteModel() basetypes.ObjectValue { return types.ObjectValueMust(routeTypes, map[string]attr.Value{ + "continue": types.BoolNull(), "group_by": types.ListNull(types.StringType), "group_interval": types.StringNull(), "group_wait": types.StringNull(), @@ -174,6 +177,7 @@ func fixtureReceiverPayload(emailConfigs *[]observability.CreateAlertConfigRecei func fixtureRoutePayload() *observability.UpdateAlertConfigsPayloadRoute { return &observability.UpdateAlertConfigsPayloadRoute{ + Continue: utils.Ptr(true), GroupBy: utils.Ptr([]string{"label1", "label2"}), GroupInterval: utils.Ptr("1m"), GroupWait: utils.Ptr("1m"), @@ -181,6 +185,7 @@ func fixtureRoutePayload() *observability.UpdateAlertConfigsPayloadRoute { RepeatInterval: utils.Ptr("1m"), Routes: &[]observability.UpdateAlertConfigsPayloadRouteRoutesInner{ { + Continue: utils.Ptr(false), GroupBy: utils.Ptr([]string{"label1", "label2"}), GroupInterval: utils.Ptr("1m"), GroupWait: utils.Ptr("1m"), @@ -249,6 +254,7 @@ func fixtureWebHooksConfigsResponse() observability.WebHook { func fixtureRouteResponse() *observability.Route { return &observability.Route{ + Continue: utils.Ptr(true), GroupBy: utils.Ptr([]string{"label1", "label2"}), GroupInterval: utils.Ptr("1m"), GroupWait: utils.Ptr("1m"), @@ -259,6 +265,7 @@ func fixtureRouteResponse() *observability.Route { RepeatInterval: utils.Ptr("1m"), Routes: &[]observability.RouteSerializer{ { + Continue: utils.Ptr(false), GroupBy: utils.Ptr([]string{"label1", "label2"}), GroupInterval: utils.Ptr("1m"), GroupWait: utils.Ptr("1m"), @@ -287,6 +294,11 @@ func fixtureGlobalConfigResponse() *observability.Global { func fixtureRouteAttributeSchema(route *schema.ListNestedAttribute, isDatasource bool) map[string]schema.Attribute { attributeMap := map[string]schema.Attribute{ + "continue": schema.BoolAttribute{ + Description: routeDescriptions["continue"], + Optional: !isDatasource, + Computed: isDatasource, + }, "group_by": schema.ListAttribute{ Description: routeDescriptions["group_by"], Optional: !isDatasource, @@ -1497,6 +1509,7 @@ func TestGetRouteListTypeAux(t *testing.T) { 1, types.ObjectType{ AttrTypes: map[string]attr.Type{ + "continue": types.BoolType, "group_by": types.ListType{ElemType: types.StringType}, "group_interval": types.StringType, "group_wait": types.StringType, @@ -1514,6 +1527,7 @@ func TestGetRouteListTypeAux(t *testing.T) { 2, types.ObjectType{ AttrTypes: map[string]attr.Type{ + "continue": types.BoolType, "group_by": types.ListType{ElemType: types.StringType}, "group_interval": types.StringType, "group_wait": types.StringType, @@ -1523,6 +1537,7 @@ func TestGetRouteListTypeAux(t *testing.T) { "receiver": types.StringType, "repeat_interval": types.StringType, "routes": types.ListType{ElemType: types.ObjectType{AttrTypes: map[string]attr.Type{ + "continue": types.BoolType, "group_by": types.ListType{ElemType: types.StringType}, "group_interval": types.StringType, "group_wait": types.StringType, @@ -1541,6 +1556,7 @@ func TestGetRouteListTypeAux(t *testing.T) { 2, types.ObjectType{ AttrTypes: map[string]attr.Type{ + "continue": types.BoolType, "group_by": types.ListType{ElemType: types.StringType}, "group_interval": types.StringType, "group_wait": types.StringType, From 7ef421332753c3d0f37bbb6a5e34f317b509d79f Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Mon, 15 Sep 2025 13:18:49 +0200 Subject: [PATCH 02/20] adjust the acceptance test observability --- .../services/observability/observability_acc_test.go | 7 +++++++ .../services/observability/testdata/resource-max.tf | 3 +++ 2 files changed, 10 insertions(+) diff --git a/stackit/internal/services/observability/observability_acc_test.go b/stackit/internal/services/observability/observability_acc_test.go index 7e3953019..374c5c5e3 100644 --- a/stackit/internal/services/observability/observability_acc_test.go +++ b/stackit/internal/services/observability/observability_acc_test.go @@ -104,6 +104,7 @@ var testConfigVarsMax = config.Variables{ "match": config.StringVariable("alert1"), "match_regex": config.StringVariable("alert1"), "matchers": config.StringVariable("instance =~ \".*\""), + "continue": config.StringVariable("true"), // logalertgroup "logalertgroup_for_time": config.StringVariable("60s"), "logalertgroup_label": config.StringVariable("label1"), @@ -526,12 +527,14 @@ func TestAccResourceMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.group_wait", testutil.ConvertConfigVariable(testConfigVarsMax["group_wait"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.receiver", testutil.ConvertConfigVariable(testConfigVarsMax["receiver_name"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.repeat_interval", testutil.ConvertConfigVariable(testConfigVarsMax["repeat_interval"])), + resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.continue", testutil.ConvertConfigVariable(testConfigVarsMax["continue"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.group_by.0", testutil.ConvertConfigVariable(testConfigVarsMax["group_by"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.group_interval", testutil.ConvertConfigVariable(testConfigVarsMax["group_interval"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.group_wait", testutil.ConvertConfigVariable(testConfigVarsMax["group_wait"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.receiver", testutil.ConvertConfigVariable(testConfigVarsMax["receiver_name"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.repeat_interval", testutil.ConvertConfigVariable(testConfigVarsMax["repeat_interval"])), + resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.continue", testutil.ConvertConfigVariable(testConfigVarsMax["continue"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.match.match1", testutil.ConvertConfigVariable(testConfigVarsMax["match"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.match_regex.match_regex1", testutil.ConvertConfigVariable(testConfigVarsMax["match_regex"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.matchers.0", testutil.ConvertConfigVariable(testConfigVarsMax["matchers"])), @@ -691,12 +694,14 @@ func TestAccResourceMax(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.group_wait", testutil.ConvertConfigVariable(testConfigVarsMax["group_wait"])), resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.receiver", testutil.ConvertConfigVariable(testConfigVarsMax["receiver_name"])), resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.repeat_interval", testutil.ConvertConfigVariable(testConfigVarsMax["repeat_interval"])), + resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.continue", testutil.ConvertConfigVariable(testConfigVarsMax["continue"])), resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.routes.0.group_by.0", testutil.ConvertConfigVariable(testConfigVarsMax["group_by"])), resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.routes.0.group_interval", testutil.ConvertConfigVariable(testConfigVarsMax["group_interval"])), resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.routes.0.group_wait", testutil.ConvertConfigVariable(testConfigVarsMax["group_wait"])), resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.routes.0.receiver", testutil.ConvertConfigVariable(testConfigVarsMax["receiver_name"])), resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.routes.0.repeat_interval", testutil.ConvertConfigVariable(testConfigVarsMax["repeat_interval"])), + resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.routes.0.continue", testutil.ConvertConfigVariable(testConfigVarsMax["continue"])), resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.routes.0.match.match1", testutil.ConvertConfigVariable(testConfigVarsMax["match"])), resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.routes.0.match_regex.match_regex1", testutil.ConvertConfigVariable(testConfigVarsMax["match_regex"])), resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.routes.0.matchers.0", testutil.ConvertConfigVariable(testConfigVarsMax["matchers"])), @@ -917,12 +922,14 @@ func TestAccResourceMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.group_wait", testutil.ConvertConfigVariable(testConfigVarsMax["group_wait"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.receiver", testutil.ConvertConfigVariable(testConfigVarsMax["receiver_name"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.repeat_interval", testutil.ConvertConfigVariable(testConfigVarsMax["repeat_interval"])), + resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.continue", testutil.ConvertConfigVariable(testConfigVarsMax["continue"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.group_by.0", testutil.ConvertConfigVariable(testConfigVarsMax["group_by"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.group_interval", testutil.ConvertConfigVariable(testConfigVarsMax["group_interval"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.group_wait", testutil.ConvertConfigVariable(testConfigVarsMax["group_wait"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.receiver", testutil.ConvertConfigVariable(testConfigVarsMax["receiver_name"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.repeat_interval", testutil.ConvertConfigVariable(testConfigVarsMax["repeat_interval"])), + resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.continue", testutil.ConvertConfigVariable(testConfigVarsMax["continue"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.match.match1", testutil.ConvertConfigVariable(testConfigVarsMax["match"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.match_regex.match_regex1", testutil.ConvertConfigVariable(testConfigVarsMax["match_regex"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.matchers.0", testutil.ConvertConfigVariable(configVarsMaxUpdated()["matchers"])), diff --git a/stackit/internal/services/observability/testdata/resource-max.tf b/stackit/internal/services/observability/testdata/resource-max.tf index 9b82a43a3..3e0e26766 100644 --- a/stackit/internal/services/observability/testdata/resource-max.tf +++ b/stackit/internal/services/observability/testdata/resource-max.tf @@ -46,6 +46,7 @@ variable "smtp_smart_host" {} variable "match" {} variable "match_regex" {} variable "matchers" {} +variable "continue" {} variable "logalertgroup_name" {} variable "logalertgroup_alert" {} @@ -145,6 +146,7 @@ resource "stackit_observability_instance" "instance" { group_wait = var.group_wait receiver = var.receiver_name repeat_interval = var.repeat_interval + continue = var.continue routes = [ { group_by = [var.group_by] @@ -152,6 +154,7 @@ resource "stackit_observability_instance" "instance" { group_wait = var.group_wait receiver = var.receiver_name repeat_interval = var.repeat_interval + continue = var.continue match = { match1 = var.match } From 702122b4e625927b9ae742c6f298cd3dfa8afe78 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Mon, 15 Sep 2025 13:57:30 +0200 Subject: [PATCH 03/20] adjust docs --- docs/data-sources/observability_instance.md | 2 ++ docs/resources/observability_instance.md | 1 + 2 files changed, 3 insertions(+) diff --git a/docs/data-sources/observability_instance.md b/docs/data-sources/observability_instance.md index 23bfdb5c3..bc10541dd 100644 --- a/docs/data-sources/observability_instance.md +++ b/docs/data-sources/observability_instance.md @@ -133,6 +133,7 @@ Read-Only: Read-Only: +- `continue` (Boolean) Whether an alert should continue matching subsequent sibling nodes. - `group_by` (List of String) The labels by which incoming alerts are grouped together. For example, multiple alerts coming in for cluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels use the special value '...' as the sole label name, for example: group_by: ['...']. This effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what you want, unless you have a very low alert volume or your upstream notification system performs its own grouping. - `group_interval` (String) How long to wait before sending a notification about new alerts that are added to a group of alerts for which an initial notification has already been sent. (Usually ~5m or more.) - `group_wait` (String) How long to initially wait to send a notification for a group of alerts. Allows to wait for an inhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.) . @@ -145,6 +146,7 @@ Read-Only: Read-Only: +- `continue` (Boolean) Whether an alert should continue matching subsequent sibling nodes. - `group_by` (List of String) The labels by which incoming alerts are grouped together. For example, multiple alerts coming in for cluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels use the special value '...' as the sole label name, for example: group_by: ['...']. This effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what you want, unless you have a very low alert volume or your upstream notification system performs its own grouping. - `group_interval` (String) How long to wait before sending a notification about new alerts that are added to a group of alerts for which an initial notification has already been sent. (Usually ~5m or more.) - `group_wait` (String) How long to initially wait to send a notification for a group of alerts. Allows to wait for an inhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.) diff --git a/docs/resources/observability_instance.md b/docs/resources/observability_instance.md index c60c21673..21d22c3cf 100644 --- a/docs/resources/observability_instance.md +++ b/docs/resources/observability_instance.md @@ -157,6 +157,7 @@ Required: Optional: +- `continue` (Boolean) Whether an alert should continue matching subsequent sibling nodes. - `group_by` (List of String) The labels by which incoming alerts are grouped together. For example, multiple alerts coming in for cluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels use the special value '...' as the sole label name, for example: group_by: ['...']. This effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what you want, unless you have a very low alert volume or your upstream notification system performs its own grouping. - `group_interval` (String) How long to wait before sending a notification about new alerts that are added to a group of alerts for which an initial notification has already been sent. (Usually ~5m or more.) - `group_wait` (String) How long to initially wait to send a notification for a group of alerts. Allows to wait for an inhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.) From 56eb26561ef1dc6cdad666a71d50aa4ff52ff39f Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Wed, 17 Sep 2025 16:31:14 +0200 Subject: [PATCH 04/20] add continue in another case --- .../internal/services/observability/instance/resource.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/stackit/internal/services/observability/instance/resource.go b/stackit/internal/services/observability/instance/resource.go index 1fe618ed1..b30ffcad2 100644 --- a/stackit/internal/services/observability/instance/resource.go +++ b/stackit/internal/services/observability/instance/resource.go @@ -734,6 +734,12 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r Description: "Route configuration for the alerts.", Required: true, Attributes: map[string]schema.Attribute{ + "continue": schema.BoolAttribute{ + Description: routeDescriptions["continue"], + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, "group_by": schema.ListAttribute{ Description: routeDescriptions["group_by"], Optional: true, From 13fdd53b6afc019de9d737fa68233539ded32112 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Fri, 19 Sep 2025 08:46:07 +0200 Subject: [PATCH 05/20] remove continue attribute from root --- .../observability/instance/datasource.go | 4 --- .../observability/instance/resource.go | 30 +++++++++---------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/stackit/internal/services/observability/instance/datasource.go b/stackit/internal/services/observability/instance/datasource.go index ee8761d34..9f3a86346 100644 --- a/stackit/internal/services/observability/instance/datasource.go +++ b/stackit/internal/services/observability/instance/datasource.go @@ -308,10 +308,6 @@ func (d *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaReques Description: "How long to initially wait to send a notification for a group of alerts. Allows to wait for an inhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.) .", Computed: true, }, - "continue": schema.BoolAttribute{ - Description: "Whether an alert should continue matching subsequent sibling nodes.", - Computed: true, - }, "receiver": schema.StringAttribute{ Description: "The name of the receiver to route the alerts to.", Computed: true, diff --git a/stackit/internal/services/observability/instance/resource.go b/stackit/internal/services/observability/instance/resource.go index b30ffcad2..1cc591075 100644 --- a/stackit/internal/services/observability/instance/resource.go +++ b/stackit/internal/services/observability/instance/resource.go @@ -90,7 +90,7 @@ type alertConfigModel struct { var alertConfigTypes = map[string]attr.Type{ "receivers": types.ListType{ElemType: types.ObjectType{AttrTypes: receiversTypes}}, - "route": types.ObjectType{AttrTypes: routeTypes}, + "route": types.ObjectType{AttrTypes: mainRouteTypes}, "global": types.ObjectType{AttrTypes: globalConfigurationTypes}, } @@ -119,7 +119,6 @@ var globalConfigurationTypes = map[string]attr.Type{ // Struct corresponding to Model.AlertConfig.route type mainRouteModel struct { - Continue types.Bool `tfsdk:"continue"` GroupBy types.List `tfsdk:"group_by"` GroupInterval types.String `tfsdk:"group_interval"` GroupWait types.String `tfsdk:"group_wait"` @@ -161,6 +160,15 @@ type routeModelNoRoutes struct { RepeatInterval types.String `tfsdk:"repeat_interval"` } +var mainRouteTypes = map[string]attr.Type{ + "group_by": types.ListType{ElemType: types.StringType}, + "group_interval": types.StringType, + "group_wait": types.StringType, + "receiver": types.StringType, + "repeat_interval": types.StringType, + "routes": types.ListType{ElemType: getRouteListType()}, +} + var routeTypes = map[string]attr.Type{ "continue": types.BoolType, "group_by": types.ListType{ElemType: types.StringType}, @@ -734,12 +742,6 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r Description: "Route configuration for the alerts.", Required: true, Attributes: map[string]schema.Attribute{ - "continue": schema.BoolAttribute{ - Description: routeDescriptions["continue"], - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), - }, "group_by": schema.ListAttribute{ Description: routeDescriptions["group_by"], Optional: true, @@ -1777,21 +1779,20 @@ func mapReceiversToAttributes(ctx context.Context, respReceivers *[]observabilit func mapRouteToAttributes(ctx context.Context, route *observability.Route) (attr.Value, error) { if route == nil { - return types.ObjectNull(routeTypes), nil + return types.ObjectNull(mainRouteTypes), nil } groupByModel, diags := types.ListValueFrom(ctx, types.StringType, route.GroupBy) if diags.HasError() { - return types.ObjectNull(routeTypes), fmt.Errorf("mapping group by: %w", core.DiagsToError(diags)) + return types.ObjectNull(mainRouteTypes), fmt.Errorf("mapping group by: %w", core.DiagsToError(diags)) } childRoutes, err := mapChildRoutesToAttributes(ctx, route.Routes) if err != nil { - return types.ObjectNull(routeTypes), fmt.Errorf("mapping child routes: %w", err) + return types.ObjectNull(mainRouteTypes), fmt.Errorf("mapping child routes: %w", err) } routeMap := map[string]attr.Value{ - "continue": types.BoolPointerValue(route.Continue), "group_by": groupByModel, "group_interval": types.StringPointerValue(route.GroupInterval), "group_wait": types.StringPointerValue(route.GroupWait), @@ -1800,9 +1801,9 @@ func mapRouteToAttributes(ctx context.Context, route *observability.Route) (attr "routes": childRoutes, } - routeModel, diags := types.ObjectValue(routeTypes, routeMap) + routeModel, diags := types.ObjectValue(mainRouteTypes, routeMap) if diags.HasError() { - return types.ObjectNull(routeTypes), fmt.Errorf("converting route to TF types: %w", core.DiagsToError(diags)) + return types.ObjectNull(mainRouteTypes), fmt.Errorf("converting route to TF types: %w", core.DiagsToError(diags)) } return routeModel, nil @@ -2131,7 +2132,6 @@ func toRoutePayload(ctx context.Context, routeTF *mainRouteModel) (*observabilit } return &observability.UpdateAlertConfigsPayloadRoute{ - Continue: conversion.BoolValueToPointer(routeTF.Continue), GroupBy: groupByPayload, GroupInterval: conversion.StringValueToPointer(routeTF.GroupInterval), GroupWait: conversion.StringValueToPointer(routeTF.GroupWait), From 113bbb9663b8eb1bb510e92a833368843d9ad346 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Fri, 19 Sep 2025 10:57:28 +0200 Subject: [PATCH 06/20] fix acc test --- stackit/internal/services/observability/instance/resource.go | 3 +-- .../internal/services/observability/observability_acc_test.go | 3 --- .../internal/services/observability/testdata/resource-max.tf | 1 - 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/stackit/internal/services/observability/instance/resource.go b/stackit/internal/services/observability/instance/resource.go index 1cc591075..b379320cc 100644 --- a/stackit/internal/services/observability/instance/resource.go +++ b/stackit/internal/services/observability/instance/resource.go @@ -1568,8 +1568,7 @@ func getMockAlertConfig(ctx context.Context) (alertConfigModel, error) { return alertConfigModel{}, fmt.Errorf("mapping group by list: %w", core.DiagsToError(diags)) } - mockRoute, diags := types.ObjectValue(routeTypes, map[string]attr.Value{ - "continue": types.BoolNull(), + mockRoute, diags := types.ObjectValue(mainRouteTypes, map[string]attr.Value{ "receiver": types.StringValue("email-me"), "group_by": mockGroupByList, "group_wait": types.StringValue("30s"), diff --git a/stackit/internal/services/observability/observability_acc_test.go b/stackit/internal/services/observability/observability_acc_test.go index 260e1a282..27a703bea 100644 --- a/stackit/internal/services/observability/observability_acc_test.go +++ b/stackit/internal/services/observability/observability_acc_test.go @@ -531,7 +531,6 @@ func TestAccResourceMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.group_wait", testutil.ConvertConfigVariable(testConfigVarsMax["group_wait"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.receiver", testutil.ConvertConfigVariable(testConfigVarsMax["receiver_name"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.repeat_interval", testutil.ConvertConfigVariable(testConfigVarsMax["repeat_interval"])), - resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.continue", testutil.ConvertConfigVariable(testConfigVarsMax["continue"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.group_by.0", testutil.ConvertConfigVariable(testConfigVarsMax["group_by"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.group_interval", testutil.ConvertConfigVariable(testConfigVarsMax["group_interval"])), @@ -699,7 +698,6 @@ func TestAccResourceMax(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.group_wait", testutil.ConvertConfigVariable(testConfigVarsMax["group_wait"])), resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.receiver", testutil.ConvertConfigVariable(testConfigVarsMax["receiver_name"])), resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.repeat_interval", testutil.ConvertConfigVariable(testConfigVarsMax["repeat_interval"])), - resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.continue", testutil.ConvertConfigVariable(testConfigVarsMax["continue"])), resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.routes.0.group_by.0", testutil.ConvertConfigVariable(testConfigVarsMax["group_by"])), resource.TestCheckResourceAttr("data.stackit_observability_instance.instance", "alert_config.route.routes.0.group_interval", testutil.ConvertConfigVariable(testConfigVarsMax["group_interval"])), @@ -927,7 +925,6 @@ func TestAccResourceMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.group_wait", testutil.ConvertConfigVariable(testConfigVarsMax["group_wait"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.receiver", testutil.ConvertConfigVariable(testConfigVarsMax["receiver_name"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.repeat_interval", testutil.ConvertConfigVariable(testConfigVarsMax["repeat_interval"])), - resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.continue", testutil.ConvertConfigVariable(testConfigVarsMax["continue"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.group_by.0", testutil.ConvertConfigVariable(testConfigVarsMax["group_by"])), resource.TestCheckResourceAttr("stackit_observability_instance.instance", "alert_config.route.routes.0.group_interval", testutil.ConvertConfigVariable(testConfigVarsMax["group_interval"])), diff --git a/stackit/internal/services/observability/testdata/resource-max.tf b/stackit/internal/services/observability/testdata/resource-max.tf index 8b845b2b3..871d4d1fd 100644 --- a/stackit/internal/services/observability/testdata/resource-max.tf +++ b/stackit/internal/services/observability/testdata/resource-max.tf @@ -149,7 +149,6 @@ resource "stackit_observability_instance" "instance" { group_wait = var.group_wait receiver = var.receiver_name repeat_interval = var.repeat_interval - continue = var.continue routes = [ { group_by = [var.group_by] From 8118f173feda3e0374299ffa6d1d5d4ce9198792 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Fri, 19 Sep 2025 11:00:16 +0200 Subject: [PATCH 07/20] fix docs --- docs/data-sources/observability_instance.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/data-sources/observability_instance.md b/docs/data-sources/observability_instance.md index bc10541dd..c6dab428c 100644 --- a/docs/data-sources/observability_instance.md +++ b/docs/data-sources/observability_instance.md @@ -133,7 +133,6 @@ Read-Only: Read-Only: -- `continue` (Boolean) Whether an alert should continue matching subsequent sibling nodes. - `group_by` (List of String) The labels by which incoming alerts are grouped together. For example, multiple alerts coming in for cluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels use the special value '...' as the sole label name, for example: group_by: ['...']. This effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what you want, unless you have a very low alert volume or your upstream notification system performs its own grouping. - `group_interval` (String) How long to wait before sending a notification about new alerts that are added to a group of alerts for which an initial notification has already been sent. (Usually ~5m or more.) - `group_wait` (String) How long to initially wait to send a notification for a group of alerts. Allows to wait for an inhibiting alert to arrive or collect more initial alerts for the same group. (Usually ~0s to few minutes.) . From 3e1a403bc31c18d2e42b28dc7eaca0aaeb76a524 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Fri, 19 Sep 2025 11:11:37 +0200 Subject: [PATCH 08/20] fix unit tests --- .../services/observability/instance/resource_test.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/stackit/internal/services/observability/instance/resource_test.go b/stackit/internal/services/observability/instance/resource_test.go index f0516ae39..055fa7165 100644 --- a/stackit/internal/services/observability/instance/resource_test.go +++ b/stackit/internal/services/observability/instance/resource_test.go @@ -64,8 +64,7 @@ func fixtureReceiverModel(emailConfigs, opsGenieConfigs, webHooksConfigs basetyp } func fixtureRouteModel() basetypes.ObjectValue { - return types.ObjectValueMust(routeTypes, map[string]attr.Value{ - "continue": types.BoolValue(true), + return types.ObjectValueMust(mainRouteTypes, map[string]attr.Value{ "group_by": types.ListValueMust(types.StringType, []attr.Value{ types.StringValue("label1"), types.StringValue("label2"), @@ -98,8 +97,7 @@ func fixtureRouteModel() basetypes.ObjectValue { } func fixtureNullRouteModel() basetypes.ObjectValue { - return types.ObjectValueMust(routeTypes, map[string]attr.Value{ - "continue": types.BoolNull(), + return types.ObjectValueMust(mainRouteTypes, map[string]attr.Value{ "group_by": types.ListNull(types.StringType), "group_interval": types.StringNull(), "group_wait": types.StringNull(), @@ -177,7 +175,7 @@ func fixtureReceiverPayload(emailConfigs *[]observability.CreateAlertConfigRecei func fixtureRoutePayload() *observability.UpdateAlertConfigsPayloadRoute { return &observability.UpdateAlertConfigsPayloadRoute{ - Continue: utils.Ptr(true), + Continue: nil, GroupBy: utils.Ptr([]string{"label1", "label2"}), GroupInterval: utils.Ptr("1m"), GroupWait: utils.Ptr("1m"), @@ -254,7 +252,7 @@ func fixtureWebHooksConfigsResponse() observability.WebHook { func fixtureRouteResponse() *observability.Route { return &observability.Route{ - Continue: utils.Ptr(true), + Continue: nil, GroupBy: utils.Ptr([]string{"label1", "label2"}), GroupInterval: utils.Ptr("1m"), GroupWait: utils.Ptr("1m"), @@ -889,7 +887,7 @@ func TestMapAlertConfigField(t *testing.T) { fixtureWebHooksConfigsModel(), ), }), - "route": types.ObjectNull(routeTypes), + "route": types.ObjectNull(mainRouteTypes), "global": types.ObjectNull(globalConfigurationTypes), }), }, From 039719f63027a61f0db320d94c69a76bd6dc38c7 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Fri, 19 Sep 2025 11:21:21 +0200 Subject: [PATCH 09/20] remove route types --- .../services/observability/instance/resource.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/stackit/internal/services/observability/instance/resource.go b/stackit/internal/services/observability/instance/resource.go index b379320cc..accf435db 100644 --- a/stackit/internal/services/observability/instance/resource.go +++ b/stackit/internal/services/observability/instance/resource.go @@ -169,16 +169,6 @@ var mainRouteTypes = map[string]attr.Type{ "routes": types.ListType{ElemType: getRouteListType()}, } -var routeTypes = map[string]attr.Type{ - "continue": types.BoolType, - "group_by": types.ListType{ElemType: types.StringType}, - "group_interval": types.StringType, - "group_wait": types.StringType, - "receiver": types.StringType, - "repeat_interval": types.StringType, - "routes": types.ListType{ElemType: getRouteListType()}, -} - // Struct corresponding to Model.AlertConfig.receivers type receiversModel struct { Name types.String `tfsdk:"name"` From ee3a0c81e133c186245304a69381b0f33a919519 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Mon, 10 Nov 2025 21:06:31 +0100 Subject: [PATCH 10/20] add skip wait and set partial model --- .../services/dns/recordset/resource.go | 236 ++++++++++++-- .../internal/services/dns/zone/resource.go | 243 +++++++++++--- stackit/internal/utils/utils.go | 109 +++++++ stackit/internal/utils/utils_test.go | 298 ++++++++++++++++++ 4 files changed, 809 insertions(+), 77 deletions(-) diff --git a/stackit/internal/services/dns/recordset/resource.go b/stackit/internal/services/dns/recordset/resource.go index 9a9046350..52f0cced0 100644 --- a/stackit/internal/services/dns/recordset/resource.go +++ b/stackit/internal/services/dns/recordset/resource.go @@ -3,6 +3,7 @@ package dns import ( "context" "fmt" + "net/http" "strings" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" @@ -16,6 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" "github.com/stackitcloud/stackit-sdk-go/services/dns" "github.com/stackitcloud/stackit-sdk-go/services/dns/wait" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" @@ -59,12 +61,20 @@ type recordSetResource struct { } // Metadata returns the resource type name. -func (r *recordSetResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { +func (r *recordSetResource) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { resp.TypeName = req.ProviderTypeName + "_dns_record_set" } // Configure adds the provider configured client to the resource. -func (r *recordSetResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { +func (r *recordSetResource) Configure( + ctx context.Context, + req resource.ConfigureRequest, + resp *resource.ConfigureResponse, +) { providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) if !ok { return @@ -79,7 +89,11 @@ func (r *recordSetResource) Configure(ctx context.Context, req resource.Configur } // Schema defines the schema for the resource. -func (r *recordSetResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { +func (r *recordSetResource) Schema( + _ context.Context, + _ resource.SchemaRequest, + resp *resource.SchemaResponse, +) { resp.Schema = schema.Schema{ Description: "DNS Record Set Resource schema.", Attributes: map[string]schema.Attribute{ @@ -191,7 +205,11 @@ func (r *recordSetResource) Schema(_ context.Context, _ resource.SchemaRequest, } // Create creates the resource and sets the initial Terraform state. -func (r *recordSetResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform +func (r *recordSetResource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { // nolint:gocritic // function signature required by Terraform // Retrieve values from plan var model Model diags := req.Plan.Get(ctx, &model) @@ -208,36 +226,74 @@ func (r *recordSetResource) Create(ctx context.Context, req resource.CreateReque // Generate API request body from model payload, err := toCreatePayload(&model) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Creating API payload: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error creating record set", + fmt.Sprintf("Creating API payload: %v", err), + ) return } // Create new recordset - recordSetResp, err := r.client.CreateRecordSet(ctx, projectId, zoneId).CreateRecordSetPayload(*payload).Execute() + recordSetResp, err := r.client.CreateRecordSet(ctx, projectId, zoneId). + CreateRecordSetPayload(*payload). + Execute() if err != nil || recordSetResp.Rrset == nil || recordSetResp.Rrset.Id == nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Calling API: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error creating record set", + fmt.Sprintf("Calling API: %v", err), + ) return } // Write id attributes to state before polling via the wait handler - just in case anything goes wrong during the wait handler - utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ - "project_id": projectId, - "zone_id": zoneId, - "record_set_id": *recordSetResp.Rrset.Id, - }) + recordSetId := *recordSetResp.Rrset.Id + model.RecordSetId = types.StringValue(recordSetId) + model.Id = utils.BuildInternalTerraformId(projectId, zoneId, recordSetId) + + // Set all unknown/null fields to null before saving state + if err := utils.SetModelFieldsToNull(ctx, &model); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Setting model fields to null: %v", err)) + return + } + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - waitResp, err := wait.CreateRecordSetWaitHandler(ctx, r.client, projectId, zoneId, *recordSetResp.Rrset.Id).WaitWithContext(ctx) + if !utils.ShouldWait() { + tflog.Info(ctx, "Skipping wait; async mode for Crossplane/Upjet") + return + } + + waitResp, err := wait.CreateRecordSetWaitHandler(ctx, r.client, projectId, zoneId, recordSetId). + WaitWithContext(ctx) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Instance creation waiting: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error creating record set", + fmt.Sprintf( + "Record set creation waiting: %v. The record set was created but is not yet ready. You can check its status in the STACKIT Portal or run 'terraform refresh' to update the state once it's ready.", + err, + ), + ) return } // Map response body to schema err = mapFields(ctx, waitResp, &model) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Processing API payload: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error creating record set", + fmt.Sprintf("Processing API payload: %v", err), + ) return } // Set state to fully populated data @@ -250,7 +306,11 @@ func (r *recordSetResource) Create(ctx context.Context, req resource.CreateReque } // Read refreshes the Terraform state with the latest data. -func (r *recordSetResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform +func (r *recordSetResource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { // nolint:gocritic // function signature required by Terraform var model Model diags := req.State.Get(ctx, &model) resp.Diagnostics.Append(diags...) @@ -264,12 +324,29 @@ func (r *recordSetResource) Read(ctx context.Context, req resource.ReadRequest, ctx = tflog.SetField(ctx, "zone_id", zoneId) ctx = tflog.SetField(ctx, "record_set_id", recordSetId) + if recordSetId == "" || zoneId == "" || projectId == "" { + tflog.Info(ctx, "Record set ID, zone ID, or project ID is empty, removing resource") + resp.State.RemoveResource(ctx) + return + } + recordSetResp, err := r.client.GetRecordSet(ctx, projectId, zoneId, recordSetId).Execute() if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading record set", fmt.Sprintf("Calling API: %v", err)) + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error reading record set", + fmt.Sprintf("Calling API: %v", err), + ) return } - if recordSetResp != nil && recordSetResp.Rrset.State != nil && *recordSetResp.Rrset.State == dns.RECORDSETSTATE_DELETE_SUCCEEDED { + if recordSetResp != nil && recordSetResp.Rrset.State != nil && + *recordSetResp.Rrset.State == dns.RECORDSETSTATE_DELETE_SUCCEEDED { resp.State.RemoveResource(ctx) return } @@ -277,7 +354,12 @@ func (r *recordSetResource) Read(ctx context.Context, req resource.ReadRequest, // Map response body to schema err = mapFields(ctx, recordSetResp, &model) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading record set", fmt.Sprintf("Processing API payload: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error reading record set", + fmt.Sprintf("Processing API payload: %v", err), + ) return } @@ -291,7 +373,11 @@ func (r *recordSetResource) Read(ctx context.Context, req resource.ReadRequest, } // Update updates the resource and sets the updated Terraform state on success. -func (r *recordSetResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform +func (r *recordSetResource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { // nolint:gocritic // function signature required by Terraform // Retrieve values from plan var model Model diags := req.Plan.Get(ctx, &model) @@ -310,24 +396,51 @@ func (r *recordSetResource) Update(ctx context.Context, req resource.UpdateReque // Generate API request body from model payload, err := toUpdatePayload(&model) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating record set", fmt.Sprintf("Creating API payload: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error updating record set", + fmt.Sprintf("Creating API payload: %v", err), + ) return } // Update recordset - _, err = r.client.PartialUpdateRecordSet(ctx, projectId, zoneId, recordSetId).PartialUpdateRecordSetPayload(*payload).Execute() + _, err = r.client.PartialUpdateRecordSet(ctx, projectId, zoneId, recordSetId). + PartialUpdateRecordSetPayload(*payload). + Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating record set", err.Error()) return } - waitResp, err := wait.PartialUpdateRecordSetWaitHandler(ctx, r.client, projectId, zoneId, recordSetId).WaitWithContext(ctx) + + if !utils.ShouldWait() { + tflog.Info(ctx, "Skipping wait; async mode for Crossplane/Upjet") + return + } + + waitResp, err := wait.PartialUpdateRecordSetWaitHandler(ctx, r.client, projectId, zoneId, recordSetId). + WaitWithContext(ctx) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating record set", fmt.Sprintf("Instance update waiting: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error updating record set", + fmt.Sprintf( + "Record set update waiting: %v. The update was triggered but may not be complete. Run 'terraform refresh' to check the current state.", + err, + ), + ) return } err = mapFields(ctx, waitResp, &model) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating record set", fmt.Sprintf("Processing API payload: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error updating record set", + fmt.Sprintf("Processing API payload: %v", err), + ) return } diags = resp.State.Set(ctx, model) @@ -339,7 +452,11 @@ func (r *recordSetResource) Update(ctx context.Context, req resource.UpdateReque } // Delete deletes the resource and removes the Terraform state on success. -func (r *recordSetResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform +func (r *recordSetResource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { // nolint:gocritic // function signature required by Terraform // Retrieve values from plan var model Model diags := req.State.Get(ctx, &model) @@ -358,11 +475,39 @@ func (r *recordSetResource) Delete(ctx context.Context, req resource.DeleteReque // Delete existing record set _, err := r.client.DeleteRecordSet(ctx, projectId, zoneId, recordSetId).Execute() if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting record set", fmt.Sprintf("Calling API: %v", err)) + // If resource is already gone (404 or 410), treat as success for idempotency + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && + (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { + tflog.Info(ctx, "Record set already deleted") + return + } + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error deleting record set", + fmt.Sprintf("Calling API: %v", err), + ) + return } - _, err = wait.DeleteRecordSetWaitHandler(ctx, r.client, projectId, zoneId, recordSetId).WaitWithContext(ctx) + + if !utils.ShouldWait() { + tflog.Info(ctx, "Skipping wait; async mode for Crossplane/Upjet") + return + } + + _, err = wait.DeleteRecordSetWaitHandler(ctx, r.client, projectId, zoneId, recordSetId). + WaitWithContext(ctx) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting record set", fmt.Sprintf("Instance deletion waiting: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error deleting record set", + fmt.Sprintf( + "Record set deletion waiting: %v. The record set deletion was triggered but confirmation timed out. The record set may still be deleting. Check the STACKIT Portal or retry the operation.", + err, + ), + ) return } tflog.Info(ctx, "DNS record set deleted") @@ -370,12 +515,21 @@ func (r *recordSetResource) Delete(ctx context.Context, req resource.DeleteReque // ImportState imports a resource into the Terraform state on success. // The expected format of the resource import identifier is: project_id,zone_id,record_set_id -func (r *recordSetResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { +func (r *recordSetResource) ImportState( + ctx context.Context, + req resource.ImportStateRequest, + resp *resource.ImportStateResponse, +) { idParts := strings.Split(req.ID, core.Separator) if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" { - core.LogAndAddError(ctx, &resp.Diagnostics, + core.LogAndAddError( + ctx, + &resp.Diagnostics, "Error importing record set", - fmt.Sprintf("Expected import identifier with format [project_id],[zone_id],[record_set_id], got %q", req.ID), + fmt.Sprintf( + "Expected import identifier with format [project_id],[zone_id],[record_set_id], got %q", + req.ID, + ), ) return } @@ -455,7 +609,12 @@ func toCreatePayload(model *Model) (*dns.CreateRecordSetPayload, error) { for i, record := range model.Records.Elements() { recordString, ok := record.(types.String) if !ok { - return nil, fmt.Errorf("expected record at index %d to be of type %T, got %T", i, types.String{}, record) + return nil, fmt.Errorf( + "expected record at index %d to be of type %T, got %T", + i, + types.String{}, + record, + ) } records = append(records, dns.RecordPayload{ Content: conversion.StringValueToPointer(recordString), @@ -467,7 +626,9 @@ func toCreatePayload(model *Model) (*dns.CreateRecordSetPayload, error) { Name: conversion.StringValueToPointer(model.Name), Records: &records, Ttl: conversion.Int64ValueToPointer(model.TTL), - Type: dns.CreateRecordSetPayloadGetTypeAttributeType(conversion.StringValueToPointer(model.Type)), + Type: dns.CreateRecordSetPayloadGetTypeAttributeType( + conversion.StringValueToPointer(model.Type), + ), }, nil } @@ -480,7 +641,12 @@ func toUpdatePayload(model *Model) (*dns.PartialUpdateRecordSetPayload, error) { for i, record := range model.Records.Elements() { recordString, ok := record.(types.String) if !ok { - return nil, fmt.Errorf("expected record at index %d to be of type %T, got %T", i, types.String{}, record) + return nil, fmt.Errorf( + "expected record at index %d to be of type %T, got %T", + i, + types.String{}, + record, + ) } records = append(records, dns.RecordPayload{ Content: conversion.StringValueToPointer(recordString), diff --git a/stackit/internal/services/dns/zone/resource.go b/stackit/internal/services/dns/zone/resource.go index 4b05814cf..7906ef298 100644 --- a/stackit/internal/services/dns/zone/resource.go +++ b/stackit/internal/services/dns/zone/resource.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math" + "net/http" "strings" dnsUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/utils" @@ -21,6 +22,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" "github.com/stackitcloud/stackit-sdk-go/services/dns" "github.com/stackitcloud/stackit-sdk-go/services/dns/wait" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" @@ -72,12 +74,20 @@ type zoneResource struct { } // Metadata returns the resource type name. -func (r *zoneResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { +func (r *zoneResource) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { resp.TypeName = req.ProviderTypeName + "_dns_zone" } // Configure adds the provider configured client to the resource. -func (r *zoneResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { +func (r *zoneResource) Configure( + ctx context.Context, + req resource.ConfigureRequest, + resp *resource.ConfigureResponse, +) { providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) if !ok { return @@ -92,7 +102,11 @@ func (r *zoneResource) Configure(ctx context.Context, req resource.ConfigureRequ } // Schema defines the schema for the resource. -func (r *zoneResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { +func (r *zoneResource) Schema( + _ context.Context, + _ resource.SchemaRequest, + resp *resource.SchemaResponse, +) { primaryOptions := []string{"primary", "secondary"} resp.Schema = schema.Schema{ @@ -235,10 +249,12 @@ func (r *zoneResource) Schema(_ context.Context, _ resource.SchemaRequest, resp }, }, "type": schema.StringAttribute{ - Description: "Zone type. Defaults to `primary`. " + utils.SupportedValuesDocumentation(primaryOptions), - Optional: true, - Computed: true, - Default: stringdefault.StaticString("primary"), + Description: "Zone type. Defaults to `primary`. " + utils.SupportedValuesDocumentation( + primaryOptions, + ), + Optional: true, + Computed: true, + Default: stringdefault.StaticString("primary"), Validators: []validator.String{ stringvalidator.OneOf(primaryOptions...), }, @@ -276,7 +292,11 @@ func (r *zoneResource) Schema(_ context.Context, _ resource.SchemaRequest, resp } // Create creates the resource and sets the initial Terraform state. -func (r *zoneResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform +func (r *zoneResource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { // nolint:gocritic // function signature required by Terraform // Retrieve values from plan var model Model resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...) @@ -290,36 +310,72 @@ func (r *zoneResource) Create(ctx context.Context, req resource.CreateRequest, r // Generate API request body from model payload, err := toCreatePayload(&model) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating zone", fmt.Sprintf("Creating API payload: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error creating zone", + fmt.Sprintf("Creating API payload: %v", err), + ) return } // Create new zone createResp, err := r.client.CreateZone(ctx, projectId).CreateZonePayload(*payload).Execute() if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating zone", fmt.Sprintf("Calling API: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error creating zone", + fmt.Sprintf("Calling API: %v", err), + ) return } - // Write id attributes to state before polling via the wait handler - just in case anything goes wrong during the wait handler + // Save minimal state immediately after API call succeeds to ensure idempotency zoneId := *createResp.Zone.Id - utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]interface{}{ - "project_id": projectId, - "zone_id": zoneId, - }) - if resp.Diagnostics.HasError() { + model.ZoneId = types.StringValue(zoneId) + model.Id = utils.BuildInternalTerraformId(projectId, zoneId) + + // Set all unknown/null fields to null before saving state + if err := utils.SetModelFieldsToNull(ctx, &model); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating zone", fmt.Sprintf("Setting model fields to null: %v", err)) + return + } + + diags := resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + + if !utils.ShouldWait() { + tflog.Info(ctx, "Skipping wait; async mode for Crossplane/Upjet") return } - waitResp, err := wait.CreateZoneWaitHandler(ctx, r.client, projectId, zoneId).WaitWithContext(ctx) + waitResp, err := wait.CreateZoneWaitHandler(ctx, r.client, projectId, zoneId). + WaitWithContext(ctx) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating zone", fmt.Sprintf("Zone creation waiting: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error creating zone", + fmt.Sprintf( + "Zone creation waiting: %v. The zone was created but is not yet ready. You can check its status in the STACKIT Portal or run 'terraform refresh' to update the state once it's ready.", + err, + ), + ) return } // Map response body to schema err = mapFields(ctx, waitResp, &model) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating zone", fmt.Sprintf("Processing API payload: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error creating zone", + fmt.Sprintf("Processing API payload: %v", err), + ) return } // Set state to fully populated data @@ -331,7 +387,11 @@ func (r *zoneResource) Create(ctx context.Context, req resource.CreateRequest, r } // Read refreshes the Terraform state with the latest data. -func (r *zoneResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform +func (r *zoneResource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { // nolint:gocritic // function signature required by Terraform var model Model diags := req.State.Get(ctx, &model) resp.Diagnostics.Append(diags...) @@ -343,12 +403,29 @@ func (r *zoneResource) Read(ctx context.Context, req resource.ReadRequest, resp ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "zone_id", zoneId) + if zoneId == "" { + tflog.Info(ctx, "Zone ID is empty, removing resource") + resp.State.RemoveResource(ctx) + return + } + zoneResp, err := r.client.GetZone(ctx, projectId, zoneId).Execute() if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading zone", fmt.Sprintf("Calling API: %v", err)) + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error reading zone", + fmt.Sprintf("Calling API: %v", err), + ) return } - if zoneResp != nil && zoneResp.Zone.State != nil && *zoneResp.Zone.State == dns.ZONESTATE_DELETE_SUCCEEDED { + if zoneResp != nil && zoneResp.Zone.State != nil && + *zoneResp.Zone.State == dns.ZONESTATE_DELETE_SUCCEEDED { resp.State.RemoveResource(ctx) return } @@ -356,7 +433,12 @@ func (r *zoneResource) Read(ctx context.Context, req resource.ReadRequest, resp // Map response body to schema err = mapFields(ctx, zoneResp, &model) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading zone", fmt.Sprintf("Processing API payload: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error reading zone", + fmt.Sprintf("Processing API payload: %v", err), + ) return } // Set refreshed state @@ -369,7 +451,11 @@ func (r *zoneResource) Read(ctx context.Context, req resource.ReadRequest, resp } // Update updates the resource and sets the updated Terraform state on success. -func (r *zoneResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform +func (r *zoneResource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { // nolint:gocritic // function signature required by Terraform // Retrieve values from plan var model Model diags := req.Plan.Get(ctx, &model) @@ -385,24 +471,56 @@ func (r *zoneResource) Update(ctx context.Context, req resource.UpdateRequest, r // Generate API request body from model payload, err := toUpdatePayload(&model) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", fmt.Sprintf("Creating API payload: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error updating zone", + fmt.Sprintf("Creating API payload: %v", err), + ) return } // Update existing zone - _, err = r.client.PartialUpdateZone(ctx, projectId, zoneId).PartialUpdateZonePayload(*payload).Execute() + _, err = r.client.PartialUpdateZone(ctx, projectId, zoneId). + PartialUpdateZonePayload(*payload). + Execute() if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", fmt.Sprintf("Calling API: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error updating zone", + fmt.Sprintf("Calling API: %v", err), + ) return } - waitResp, err := wait.PartialUpdateZoneWaitHandler(ctx, r.client, projectId, zoneId).WaitWithContext(ctx) + + if !utils.ShouldWait() { + tflog.Info(ctx, "Skipping wait; async mode for Crossplane/Upjet") + return + } + + waitResp, err := wait.PartialUpdateZoneWaitHandler(ctx, r.client, projectId, zoneId). + WaitWithContext(ctx) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", fmt.Sprintf("Zone update waiting: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error updating zone", + fmt.Sprintf( + "Zone update waiting: %v. The update was triggered but may not be complete. Run 'terraform refresh' to check the current state.", + err, + ), + ) return } err = mapFields(ctx, waitResp, &model) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", fmt.Sprintf("Processing API payload: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error updating zone", + fmt.Sprintf("Processing API payload: %v", err), + ) return } diags = resp.State.Set(ctx, model) @@ -414,7 +532,11 @@ func (r *zoneResource) Update(ctx context.Context, req resource.UpdateRequest, r } // Delete deletes the resource and removes the Terraform state on success. -func (r *zoneResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform +func (r *zoneResource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { // nolint:gocritic // function signature required by Terraform // Retrieve values from state var model Model diags := req.State.Get(ctx, &model) @@ -431,12 +553,38 @@ func (r *zoneResource) Delete(ctx context.Context, req resource.DeleteRequest, r // Delete existing zone _, err := r.client.DeleteZone(ctx, projectId, zoneId).Execute() if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting zone", fmt.Sprintf("Calling API: %v", err)) + // If resource is already gone (404 or 410), treat as success for idempotency + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && + (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { + tflog.Info(ctx, "DNS zone already deleted") + return + } + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error deleting zone", + fmt.Sprintf("Calling API: %v", err), + ) + return + } + + if !utils.ShouldWait() { + tflog.Info(ctx, "Skipping wait; async mode for Crossplane/Upjet") return } + _, err = wait.DeleteZoneWaitHandler(ctx, r.client, projectId, zoneId).WaitWithContext(ctx) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting zone", fmt.Sprintf("Zone deletion waiting: %v", err)) + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error deleting zone", + fmt.Sprintf( + "Zone deletion waiting: %v. The zone deletion was triggered but confirmation timed out. The zone may still be deleting. Check the STACKIT Portal or retry the operation.", + err, + ), + ) return } @@ -445,13 +593,22 @@ func (r *zoneResource) Delete(ctx context.Context, req resource.DeleteRequest, r // ImportState imports a resource into the Terraform state on success. // The expected format of the resource import identifier is: project_id,zone_id -func (r *zoneResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { +func (r *zoneResource) ImportState( + ctx context.Context, + req resource.ImportStateRequest, + resp *resource.ImportStateResponse, +) { idParts := strings.Split(req.ID, core.Separator) if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" { - core.LogAndAddError(ctx, &resp.Diagnostics, + core.LogAndAddError( + ctx, + &resp.Diagnostics, "Error importing zone", - fmt.Sprintf("Expected import identifier with format: [project_id],[zone_id] Got: %q", req.ID), + fmt.Sprintf( + "Expected import identifier with format: [project_id],[zone_id] Got: %q", + req.ID, + ), ) return } @@ -546,12 +703,14 @@ func toCreatePayload(model *Model) (*dns.CreateZonePayload, error) { modelPrimaries = append(modelPrimaries, primaryString.ValueString()) } return &dns.CreateZonePayload{ - Name: conversion.StringValueToPointer(model.Name), - DnsName: conversion.StringValueToPointer(model.DnsName), - ContactEmail: conversion.StringValueToPointer(model.ContactEmail), - Description: conversion.StringValueToPointer(model.Description), - Acl: conversion.StringValueToPointer(model.Acl), - Type: dns.CreateZonePayloadGetTypeAttributeType(conversion.StringValueToPointer(model.Type)), + Name: conversion.StringValueToPointer(model.Name), + DnsName: conversion.StringValueToPointer(model.DnsName), + ContactEmail: conversion.StringValueToPointer(model.ContactEmail), + Description: conversion.StringValueToPointer(model.Description), + Acl: conversion.StringValueToPointer(model.Acl), + Type: dns.CreateZonePayloadGetTypeAttributeType( + conversion.StringValueToPointer(model.Type), + ), DefaultTTL: conversion.Int64ValueToPointer(model.DefaultTTL), ExpireTime: conversion.Int64ValueToPointer(model.ExpireTime), RefreshTime: conversion.Int64ValueToPointer(model.RefreshTime), diff --git a/stackit/internal/utils/utils.go b/stackit/internal/utils/utils.go index 5190660f6..1633fd001 100644 --- a/stackit/internal/utils/utils.go +++ b/stackit/internal/utils/utils.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "os" + "reflect" "regexp" "strings" @@ -196,3 +198,110 @@ func SetAndLogStateFields(ctx context.Context, diags *diag.Diagnostics, state *t diags.Append(state.SetAttribute(ctx, path.Root(key), val)...) } } + +// SetModelFieldsToNull sets all Unknown or Null fields in a model struct to their appropriate Null values. +// This is useful when saving minimal state after API calls to ensure idempotency. +// The model parameter must be a pointer to a struct containing Terraform framework types. +func SetModelFieldsToNull(ctx context.Context, model any) error { + if model == nil { + return fmt.Errorf("model cannot be nil") + } + + v := reflect.ValueOf(model) + if v.Kind() != reflect.Ptr { + return fmt.Errorf("model must be a pointer, got %v", v.Kind()) + } + + v = v.Elem() + if !v.IsValid() || v.Kind() != reflect.Struct { + return fmt.Errorf("model must point to a struct, got %v", v.Kind()) + } + + t := v.Type() + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + fieldType := t.Field(i) + + if !field.CanInterface() || !field.CanSet() { + continue + } + + fieldValue := field.Interface() + + // Check if the field implements IsUnknown and IsNull + isUnknownMethod := field.MethodByName("IsUnknown") + isNullMethod := field.MethodByName("IsNull") + + if !isUnknownMethod.IsValid() || !isNullMethod.IsValid() { + continue + } + + // Call IsUnknown() and IsNull() + isUnknownResult := isUnknownMethod.Call(nil) + isNullResult := isNullMethod.Call(nil) + + if len(isUnknownResult) == 0 || len(isNullResult) == 0 { + continue + } + + isUnknown := isUnknownResult[0].Bool() + isNull := isNullResult[0].Bool() + + if !isUnknown && !isNull { + continue + } + + // Determine the type and set to appropriate Null value + switch fieldValue.(type) { + case basetypes.StringValue: + field.Set(reflect.ValueOf(types.StringNull())) + + case basetypes.BoolValue: + field.Set(reflect.ValueOf(types.BoolNull())) + + case basetypes.Int64Value: + field.Set(reflect.ValueOf(types.Int64Null())) + + case basetypes.Float64Value: + field.Set(reflect.ValueOf(types.Float64Null())) + + case basetypes.NumberValue: + field.Set(reflect.ValueOf(types.NumberNull())) + + case basetypes.ListValue: + listVal := fieldValue.(basetypes.ListValue) + elemType := listVal.ElementType(ctx) + field.Set(reflect.ValueOf(types.ListNull(elemType))) + + case basetypes.SetValue: + setVal := fieldValue.(basetypes.SetValue) + elemType := setVal.ElementType(ctx) + field.Set(reflect.ValueOf(types.SetNull(elemType))) + + case basetypes.MapValue: + mapVal := fieldValue.(basetypes.MapValue) + elemType := mapVal.ElementType(ctx) + field.Set(reflect.ValueOf(types.MapNull(elemType))) + + case basetypes.ObjectValue: + objVal := fieldValue.(basetypes.ObjectValue) + attrTypes := objVal.AttributeTypes(ctx) + field.Set(reflect.ValueOf(types.ObjectNull(attrTypes))) + + default: + tflog.Debug(ctx, fmt.Sprintf("SetModelFieldsToNull: skipping field %s of unsupported type %T", fieldType.Name, fieldValue)) + } + } + + return nil +} + +// ShouldWait checks the STACKIT_TF_WAIT_FOR_READY environment variable to determine +// if the provider should wait for resources to be ready after creation/update. +// Returns true if the variable is unset or set to "true" (case-insensitive). +// Returns false if the variable is set to any other value. +// This is typically used to skip waiting in async mode for Crossplane/Upjet. +func ShouldWait() bool { + v := os.Getenv("STACKIT_TF_WAIT_FOR_READY") + return v == "" || strings.EqualFold(v, "true") +} diff --git a/stackit/internal/utils/utils_test.go b/stackit/internal/utils/utils_test.go index 8d6851aae..270be3c74 100644 --- a/stackit/internal/utils/utils_test.go +++ b/stackit/internal/utils/utils_test.go @@ -3,7 +3,9 @@ package utils import ( "context" "fmt" + "os" "reflect" + "strings" "testing" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -643,3 +645,299 @@ func TestSetAndLogStateFields(t *testing.T) { }) } } + +func TestSetModelFieldsToNull(t *testing.T) { + ctx := context.Background() + + type TestModel struct { + StringField types.String `tfsdk:"string_field"` + BoolField types.Bool `tfsdk:"bool_field"` + Int64Field types.Int64 `tfsdk:"int64_field"` + Float64Field types.Float64 `tfsdk:"float64_field"` + ListField types.List `tfsdk:"list_field"` + SetField types.Set `tfsdk:"set_field"` + MapField types.Map `tfsdk:"map_field"` + ObjectField types.Object `tfsdk:"object_field"` + } + + tests := []struct { + name string + input *TestModel + expected *TestModel + expectError bool + }{ + { + name: "all unknown fields should be set to null", + input: &TestModel{ + StringField: types.StringUnknown(), + BoolField: types.BoolUnknown(), + Int64Field: types.Int64Unknown(), + Float64Field: types.Float64Unknown(), + ListField: types.ListUnknown(types.StringType), + SetField: types.SetUnknown(types.StringType), + MapField: types.MapUnknown(types.StringType), + ObjectField: types.ObjectUnknown(map[string]attr.Type{"field1": types.StringType}), + }, + expected: &TestModel{ + StringField: types.StringNull(), + BoolField: types.BoolNull(), + Int64Field: types.Int64Null(), + Float64Field: types.Float64Null(), + ListField: types.ListNull(types.StringType), + SetField: types.SetNull(types.StringType), + MapField: types.MapNull(types.StringType), + ObjectField: types.ObjectNull(map[string]attr.Type{"field1": types.StringType}), + }, + expectError: false, + }, + { + name: "all null fields should remain null", + input: &TestModel{ + StringField: types.StringNull(), + BoolField: types.BoolNull(), + Int64Field: types.Int64Null(), + Float64Field: types.Float64Null(), + ListField: types.ListNull(types.StringType), + SetField: types.SetNull(types.StringType), + MapField: types.MapNull(types.StringType), + ObjectField: types.ObjectNull(map[string]attr.Type{"field1": types.StringType}), + }, + expected: &TestModel{ + StringField: types.StringNull(), + BoolField: types.BoolNull(), + Int64Field: types.Int64Null(), + Float64Field: types.Float64Null(), + ListField: types.ListNull(types.StringType), + SetField: types.SetNull(types.StringType), + MapField: types.MapNull(types.StringType), + ObjectField: types.ObjectNull(map[string]attr.Type{"field1": types.StringType}), + }, + expectError: false, + }, + { + name: "known fields should not be modified", + input: &TestModel{ + StringField: types.StringValue("test"), + BoolField: types.BoolValue(true), + Int64Field: types.Int64Value(42), + Float64Field: types.Float64Value(3.14), + ListField: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("item")}), + SetField: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("item")}), + MapField: types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + ObjectField: types.ObjectValueMust(map[string]attr.Type{"field1": types.StringType}, map[string]attr.Value{"field1": types.StringValue("value")}), + }, + expected: &TestModel{ + StringField: types.StringValue("test"), + BoolField: types.BoolValue(true), + Int64Field: types.Int64Value(42), + Float64Field: types.Float64Value(3.14), + ListField: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("item")}), + SetField: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("item")}), + MapField: types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + ObjectField: types.ObjectValueMust(map[string]attr.Type{"field1": types.StringType}, map[string]attr.Value{"field1": types.StringValue("value")}), + }, + expectError: false, + }, + { + name: "mixed fields - some unknown, some known", + input: &TestModel{ + StringField: types.StringUnknown(), + BoolField: types.BoolValue(true), + Int64Field: types.Int64Unknown(), + Float64Field: types.Float64Value(2.71), + ListField: types.ListNull(types.StringType), + SetField: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("item")}), + MapField: types.MapUnknown(types.StringType), + ObjectField: types.ObjectValueMust(map[string]attr.Type{"field1": types.StringType}, map[string]attr.Value{"field1": types.StringValue("value")}), + }, + expected: &TestModel{ + StringField: types.StringNull(), + BoolField: types.BoolValue(true), + Int64Field: types.Int64Null(), + Float64Field: types.Float64Value(2.71), + ListField: types.ListNull(types.StringType), + SetField: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("item")}), + MapField: types.MapNull(types.StringType), + ObjectField: types.ObjectValueMust(map[string]attr.Type{"field1": types.StringType}, map[string]attr.Value{"field1": types.StringValue("value")}), + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := SetModelFieldsToNull(ctx, tt.input) + + if tt.expectError { + if err == nil { + t.Fatal("expected error but got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Compare each field + if diff := cmp.Diff(tt.input.StringField, tt.expected.StringField); diff != "" { + t.Errorf("StringField mismatch (-got +want):\n%s", diff) + } + if diff := cmp.Diff(tt.input.BoolField, tt.expected.BoolField); diff != "" { + t.Errorf("BoolField mismatch (-got +want):\n%s", diff) + } + if diff := cmp.Diff(tt.input.Int64Field, tt.expected.Int64Field); diff != "" { + t.Errorf("Int64Field mismatch (-got +want):\n%s", diff) + } + if diff := cmp.Diff(tt.input.Float64Field, tt.expected.Float64Field); diff != "" { + t.Errorf("Float64Field mismatch (-got +want):\n%s", diff) + } + if diff := cmp.Diff(tt.input.ListField, tt.expected.ListField); diff != "" { + t.Errorf("ListField mismatch (-got +want):\n%s", diff) + } + if diff := cmp.Diff(tt.input.SetField, tt.expected.SetField); diff != "" { + t.Errorf("SetField mismatch (-got +want):\n%s", diff) + } + if diff := cmp.Diff(tt.input.MapField, tt.expected.MapField); diff != "" { + t.Errorf("MapField mismatch (-got +want):\n%s", diff) + } + if diff := cmp.Diff(tt.input.ObjectField, tt.expected.ObjectField); diff != "" { + t.Errorf("ObjectField mismatch (-got +want):\n%s", diff) + } + }) + } +} + +func TestSetModelFieldsToNull_Errors(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + input any + wantError string + }{ + { + name: "nil model", + input: nil, + wantError: "model cannot be nil", + }, + { + name: "non-pointer", + input: struct{}{}, + wantError: "model must be a pointer", + }, + { + name: "pointer to non-struct", + input: func() *string { s := "test"; return &s }(), + wantError: "model must point to a struct", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := SetModelFieldsToNull(ctx, tt.input) + if err == nil { + t.Fatal("expected error but got nil") + } + if !strings.Contains(err.Error(), tt.wantError) { + t.Errorf("expected error containing %q, got %q", tt.wantError, err.Error()) + } + }) + } +} + +func TestShouldWait(t *testing.T) { + tests := []struct { + name string + envValue string + setEnv bool + expected bool + }{ + { + name: "env not set - should wait", + setEnv: false, + expected: true, + }, + { + name: "env set to empty string - should wait", + envValue: "", + setEnv: true, + expected: true, + }, + { + name: "env set to 'true' - should wait", + envValue: "true", + setEnv: true, + expected: true, + }, + { + name: "env set to 'TRUE' - should wait (case insensitive)", + envValue: "TRUE", + setEnv: true, + expected: true, + }, + { + name: "env set to 'True' - should wait (case insensitive)", + envValue: "True", + setEnv: true, + expected: true, + }, + { + name: "env set to 'false' - should not wait", + envValue: "false", + setEnv: true, + expected: false, + }, + { + name: "env set to 'FALSE' - should not wait", + envValue: "FALSE", + setEnv: true, + expected: false, + }, + { + name: "env set to '0' - should not wait", + envValue: "0", + setEnv: true, + expected: false, + }, + { + name: "env set to 'no' - should not wait", + envValue: "no", + setEnv: true, + expected: false, + }, + { + name: "env set to random value - should not wait", + envValue: "random", + setEnv: true, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save original env value + originalValue, wasSet := os.LookupEnv("STACKIT_TF_WAIT_FOR_READY") + defer func() { + if wasSet { + os.Setenv("STACKIT_TF_WAIT_FOR_READY", originalValue) + } else { + os.Unsetenv("STACKIT_TF_WAIT_FOR_READY") + } + }() + + // Set up test environment + if tt.setEnv { + os.Setenv("STACKIT_TF_WAIT_FOR_READY", tt.envValue) + } else { + os.Unsetenv("STACKIT_TF_WAIT_FOR_READY") + } + + // Test + result := ShouldWait() + if result != tt.expected { + t.Errorf("ShouldWait() = %v, want %v", result, tt.expected) + } + }) + } +} From 76fc503b51e938cf9c5c334d101105b6eb660b09 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Mon, 10 Nov 2025 22:39:28 +0100 Subject: [PATCH 11/20] fix linting errors --- Makefile | 13 +++++++++++-- scripts/lint-golangci-lint.sh | 13 +++++++------ .../internal/services/dns/recordset/resource.go | 16 ++++++++-------- stackit/internal/services/dns/zone/resource.go | 16 ++++++++-------- stackit/internal/utils/utils.go | 14 +++++--------- stackit/internal/utils/utils_test.go | 8 ++++---- 6 files changed, 43 insertions(+), 37 deletions(-) diff --git a/Makefile b/Makefile index 38b7abfdc..5f0ddda2f 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,10 @@ ROOT_DIR ?= $(shell git rev-parse --show-toplevel) SCRIPTS_BASE ?= $(ROOT_DIR)/scripts +# https://github.com/golangci/golangci-lint/releases +GOLANGCI_VERSION = 1.64.8 +GOLANGCI_LINT = bin/golangci-lint-$(GOLANGCI_VERSION) + # SETUP AND TOOL INITIALIZATION TASKS project-help: @$(SCRIPTS_BASE)/project.sh help @@ -8,10 +12,15 @@ project-help: project-tools: @$(SCRIPTS_BASE)/project.sh tools +# GOLANGCI-LINT INSTALLATION +$(GOLANGCI_LINT): + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | bash -s -- -b bin v$(GOLANGCI_VERSION) + @mv bin/golangci-lint "$(@)" + # LINT -lint-golangci-lint: +lint-golangci-lint: $(GOLANGCI_LINT) @echo "Linting with golangci-lint" - @$(SCRIPTS_BASE)/lint-golangci-lint.sh + @$(SCRIPTS_BASE)/lint-golangci-lint.sh $(GOLANGCI_LINT) lint-tf: @echo "Linting terraform files" diff --git a/scripts/lint-golangci-lint.sh b/scripts/lint-golangci-lint.sh index c2ffd78fe..c2930464f 100755 --- a/scripts/lint-golangci-lint.sh +++ b/scripts/lint-golangci-lint.sh @@ -1,18 +1,19 @@ #!/usr/bin/env bash # This script lints the SDK modules and the internal examples -# Pre-requisites: golangci-lint +# Pre-requisites: golangci-lint (provided by Makefile or system) set -eo pipefail ROOT_DIR=$(git rev-parse --show-toplevel) GOLANG_CI_YAML_PATH="${ROOT_DIR}/golang-ci.yaml" GOLANG_CI_ARGS="--allow-parallel-runners --timeout=5m --config=${GOLANG_CI_YAML_PATH}" -if type -p golangci-lint >/dev/null; then - : -else - echo "golangci-lint not installed, unable to proceed." +# Use provided golangci-lint binary or fallback to system installation +GOLANGCI_LINT_BIN="${1:-golangci-lint}" + +if [ ! -x "${GOLANGCI_LINT_BIN}" ] && ! type -p "${GOLANGCI_LINT_BIN}" >/dev/null; then + echo "golangci-lint not found at ${GOLANGCI_LINT_BIN} and not installed in PATH, unable to proceed." exit 1 fi cd ${ROOT_DIR} -golangci-lint run ${GOLANG_CI_ARGS} +${GOLANGCI_LINT_BIN} run ${GOLANG_CI_ARGS} diff --git a/stackit/internal/services/dns/recordset/resource.go b/stackit/internal/services/dns/recordset/resource.go index 52f0cced0..ea48b29c9 100644 --- a/stackit/internal/services/dns/recordset/resource.go +++ b/stackit/internal/services/dns/recordset/resource.go @@ -207,9 +207,9 @@ func (r *recordSetResource) Schema( // Create creates the resource and sets the initial Terraform state. func (r *recordSetResource) Create( ctx context.Context, - req resource.CreateRequest, + req resource.CreateRequest, // nolint:gocritic // function signature required by Terraform resp *resource.CreateResponse, -) { // nolint:gocritic // function signature required by Terraform +) { // Retrieve values from plan var model Model diags := req.Plan.Get(ctx, &model) @@ -308,9 +308,9 @@ func (r *recordSetResource) Create( // Read refreshes the Terraform state with the latest data. func (r *recordSetResource) Read( ctx context.Context, - req resource.ReadRequest, + req resource.ReadRequest, // nolint:gocritic // function signature required by Terraform resp *resource.ReadResponse, -) { // nolint:gocritic // function signature required by Terraform +) { var model Model diags := req.State.Get(ctx, &model) resp.Diagnostics.Append(diags...) @@ -375,9 +375,9 @@ func (r *recordSetResource) Read( // Update updates the resource and sets the updated Terraform state on success. func (r *recordSetResource) Update( ctx context.Context, - req resource.UpdateRequest, + req resource.UpdateRequest, // nolint:gocritic // function signature required by Terraform resp *resource.UpdateResponse, -) { // nolint:gocritic // function signature required by Terraform +) { // Retrieve values from plan var model Model diags := req.Plan.Get(ctx, &model) @@ -454,9 +454,9 @@ func (r *recordSetResource) Update( // Delete deletes the resource and removes the Terraform state on success. func (r *recordSetResource) Delete( ctx context.Context, - req resource.DeleteRequest, + req resource.DeleteRequest, // nolint:gocritic // function signature required by Terraform resp *resource.DeleteResponse, -) { // nolint:gocritic // function signature required by Terraform +) { // Retrieve values from plan var model Model diags := req.State.Get(ctx, &model) diff --git a/stackit/internal/services/dns/zone/resource.go b/stackit/internal/services/dns/zone/resource.go index 7906ef298..f50784bae 100644 --- a/stackit/internal/services/dns/zone/resource.go +++ b/stackit/internal/services/dns/zone/resource.go @@ -294,9 +294,9 @@ func (r *zoneResource) Schema( // Create creates the resource and sets the initial Terraform state. func (r *zoneResource) Create( ctx context.Context, - req resource.CreateRequest, + req resource.CreateRequest, // nolint:gocritic // function signature required by Terraform resp *resource.CreateResponse, -) { // nolint:gocritic // function signature required by Terraform +) { // Retrieve values from plan var model Model resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...) @@ -389,9 +389,9 @@ func (r *zoneResource) Create( // Read refreshes the Terraform state with the latest data. func (r *zoneResource) Read( ctx context.Context, - req resource.ReadRequest, + req resource.ReadRequest, // nolint:gocritic // function signature required by Terraform resp *resource.ReadResponse, -) { // nolint:gocritic // function signature required by Terraform +) { var model Model diags := req.State.Get(ctx, &model) resp.Diagnostics.Append(diags...) @@ -453,9 +453,9 @@ func (r *zoneResource) Read( // Update updates the resource and sets the updated Terraform state on success. func (r *zoneResource) Update( ctx context.Context, - req resource.UpdateRequest, + req resource.UpdateRequest, // nolint:gocritic // function signature required by Terraform resp *resource.UpdateResponse, -) { // nolint:gocritic // function signature required by Terraform +) { // Retrieve values from plan var model Model diags := req.Plan.Get(ctx, &model) @@ -534,9 +534,9 @@ func (r *zoneResource) Update( // Delete deletes the resource and removes the Terraform state on success. func (r *zoneResource) Delete( ctx context.Context, - req resource.DeleteRequest, + req resource.DeleteRequest, // nolint:gocritic // function signature required by Terraform resp *resource.DeleteResponse, -) { // nolint:gocritic // function signature required by Terraform +) { // Retrieve values from state var model Model diags := req.State.Get(ctx, &model) diff --git a/stackit/internal/utils/utils.go b/stackit/internal/utils/utils.go index 1633fd001..65e2a579e 100644 --- a/stackit/internal/utils/utils.go +++ b/stackit/internal/utils/utils.go @@ -252,7 +252,7 @@ func SetModelFieldsToNull(ctx context.Context, model any) error { } // Determine the type and set to appropriate Null value - switch fieldValue.(type) { + switch v := fieldValue.(type) { case basetypes.StringValue: field.Set(reflect.ValueOf(types.StringNull())) @@ -269,23 +269,19 @@ func SetModelFieldsToNull(ctx context.Context, model any) error { field.Set(reflect.ValueOf(types.NumberNull())) case basetypes.ListValue: - listVal := fieldValue.(basetypes.ListValue) - elemType := listVal.ElementType(ctx) + elemType := v.ElementType(ctx) field.Set(reflect.ValueOf(types.ListNull(elemType))) case basetypes.SetValue: - setVal := fieldValue.(basetypes.SetValue) - elemType := setVal.ElementType(ctx) + elemType := v.ElementType(ctx) field.Set(reflect.ValueOf(types.SetNull(elemType))) case basetypes.MapValue: - mapVal := fieldValue.(basetypes.MapValue) - elemType := mapVal.ElementType(ctx) + elemType := v.ElementType(ctx) field.Set(reflect.ValueOf(types.MapNull(elemType))) case basetypes.ObjectValue: - objVal := fieldValue.(basetypes.ObjectValue) - attrTypes := objVal.AttributeTypes(ctx) + attrTypes := v.AttributeTypes(ctx) field.Set(reflect.ValueOf(types.ObjectNull(attrTypes))) default: diff --git a/stackit/internal/utils/utils_test.go b/stackit/internal/utils/utils_test.go index 270be3c74..ac21f198d 100644 --- a/stackit/internal/utils/utils_test.go +++ b/stackit/internal/utils/utils_test.go @@ -920,17 +920,17 @@ func TestShouldWait(t *testing.T) { originalValue, wasSet := os.LookupEnv("STACKIT_TF_WAIT_FOR_READY") defer func() { if wasSet { - os.Setenv("STACKIT_TF_WAIT_FOR_READY", originalValue) + _ = os.Setenv("STACKIT_TF_WAIT_FOR_READY", originalValue) } else { - os.Unsetenv("STACKIT_TF_WAIT_FOR_READY") + _ = os.Unsetenv("STACKIT_TF_WAIT_FOR_READY") } }() // Set up test environment if tt.setEnv { - os.Setenv("STACKIT_TF_WAIT_FOR_READY", tt.envValue) + _ = os.Setenv("STACKIT_TF_WAIT_FOR_READY", tt.envValue) } else { - os.Unsetenv("STACKIT_TF_WAIT_FOR_READY") + _ = os.Unsetenv("STACKIT_TF_WAIT_FOR_READY") } // Test From de09817b0275873115c8f7f186efac8f53317c27 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Tue, 11 Nov 2025 10:49:00 +0100 Subject: [PATCH 12/20] revert formatting --- .../services/dns/recordset/resource.go | 174 +++-------------- .../internal/services/dns/zone/resource.go | 181 +++++------------- 2 files changed, 75 insertions(+), 280 deletions(-) diff --git a/stackit/internal/services/dns/recordset/resource.go b/stackit/internal/services/dns/recordset/resource.go index ea48b29c9..d1637ad0b 100644 --- a/stackit/internal/services/dns/recordset/resource.go +++ b/stackit/internal/services/dns/recordset/resource.go @@ -61,20 +61,12 @@ type recordSetResource struct { } // Metadata returns the resource type name. -func (r *recordSetResource) Metadata( - _ context.Context, - req resource.MetadataRequest, - resp *resource.MetadataResponse, -) { +func (r *recordSetResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_dns_record_set" } // Configure adds the provider configured client to the resource. -func (r *recordSetResource) Configure( - ctx context.Context, - req resource.ConfigureRequest, - resp *resource.ConfigureResponse, -) { +func (r *recordSetResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) if !ok { return @@ -89,11 +81,7 @@ func (r *recordSetResource) Configure( } // Schema defines the schema for the resource. -func (r *recordSetResource) Schema( - _ context.Context, - _ resource.SchemaRequest, - resp *resource.SchemaResponse, -) { +func (r *recordSetResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Description: "DNS Record Set Resource schema.", Attributes: map[string]schema.Attribute{ @@ -205,11 +193,7 @@ func (r *recordSetResource) Schema( } // Create creates the resource and sets the initial Terraform state. -func (r *recordSetResource) Create( - ctx context.Context, - req resource.CreateRequest, // nolint:gocritic // function signature required by Terraform - resp *resource.CreateResponse, -) { +func (r *recordSetResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform // Retrieve values from plan var model Model diags := req.Plan.Get(ctx, &model) @@ -226,25 +210,13 @@ func (r *recordSetResource) Create( // Generate API request body from model payload, err := toCreatePayload(&model) if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error creating record set", - fmt.Sprintf("Creating API payload: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Creating API payload: %v", err)) return } // Create new recordset - recordSetResp, err := r.client.CreateRecordSet(ctx, projectId, zoneId). - CreateRecordSetPayload(*payload). - Execute() + recordSetResp, err := r.client.CreateRecordSet(ctx, projectId, zoneId).CreateRecordSetPayload(*payload).Execute() if err != nil || recordSetResp.Rrset == nil || recordSetResp.Rrset.Id == nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error creating record set", - fmt.Sprintf("Calling API: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Calling API: %v", err)) return } @@ -270,30 +242,16 @@ func (r *recordSetResource) Create( return } - waitResp, err := wait.CreateRecordSetWaitHandler(ctx, r.client, projectId, zoneId, recordSetId). - WaitWithContext(ctx) + waitResp, err := wait.CreateRecordSetWaitHandler(ctx, r.client, projectId, zoneId, *recordSetResp.Rrset.Id).WaitWithContext(ctx) if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error creating record set", - fmt.Sprintf( - "Record set creation waiting: %v. The record set was created but is not yet ready. You can check its status in the STACKIT Portal or run 'terraform refresh' to update the state once it's ready.", - err, - ), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Instance creation waiting: %v", err)) return } // Map response body to schema err = mapFields(ctx, waitResp, &model) if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error creating record set", - fmt.Sprintf("Processing API payload: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Processing API payload: %v", err)) return } // Set state to fully populated data @@ -306,11 +264,7 @@ func (r *recordSetResource) Create( } // Read refreshes the Terraform state with the latest data. -func (r *recordSetResource) Read( - ctx context.Context, - req resource.ReadRequest, // nolint:gocritic // function signature required by Terraform - resp *resource.ReadResponse, -) { +func (r *recordSetResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform var model Model diags := req.State.Get(ctx, &model) resp.Diagnostics.Append(diags...) @@ -337,16 +291,10 @@ func (r *recordSetResource) Read( resp.State.RemoveResource(ctx) return } - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error reading record set", - fmt.Sprintf("Calling API: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading record set", fmt.Sprintf("Calling API: %v", err)) return } - if recordSetResp != nil && recordSetResp.Rrset.State != nil && - *recordSetResp.Rrset.State == dns.RECORDSETSTATE_DELETE_SUCCEEDED { + if recordSetResp != nil && recordSetResp.Rrset.State != nil && *recordSetResp.Rrset.State == dns.RECORDSETSTATE_DELETE_SUCCEEDED { resp.State.RemoveResource(ctx) return } @@ -373,11 +321,7 @@ func (r *recordSetResource) Read( } // Update updates the resource and sets the updated Terraform state on success. -func (r *recordSetResource) Update( - ctx context.Context, - req resource.UpdateRequest, // nolint:gocritic // function signature required by Terraform - resp *resource.UpdateResponse, -) { +func (r *recordSetResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform // Retrieve values from plan var model Model diags := req.Plan.Get(ctx, &model) @@ -396,18 +340,11 @@ func (r *recordSetResource) Update( // Generate API request body from model payload, err := toUpdatePayload(&model) if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error updating record set", - fmt.Sprintf("Creating API payload: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating record set", fmt.Sprintf("Creating API payload: %v", err)) return } // Update recordset - _, err = r.client.PartialUpdateRecordSet(ctx, projectId, zoneId, recordSetId). - PartialUpdateRecordSetPayload(*payload). - Execute() + _, err = r.client.PartialUpdateRecordSet(ctx, projectId, zoneId, recordSetId).PartialUpdateRecordSetPayload(*payload).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating record set", err.Error()) return @@ -418,29 +355,15 @@ func (r *recordSetResource) Update( return } - waitResp, err := wait.PartialUpdateRecordSetWaitHandler(ctx, r.client, projectId, zoneId, recordSetId). - WaitWithContext(ctx) + waitResp, err := wait.PartialUpdateRecordSetWaitHandler(ctx, r.client, projectId, zoneId, recordSetId).WaitWithContext(ctx) if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error updating record set", - fmt.Sprintf( - "Record set update waiting: %v. The update was triggered but may not be complete. Run 'terraform refresh' to check the current state.", - err, - ), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating record set", fmt.Sprintf("Instance update waiting: %v", err)) return } err = mapFields(ctx, waitResp, &model) if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error updating record set", - fmt.Sprintf("Processing API payload: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating record set", fmt.Sprintf("Processing API payload: %v", err)) return } diags = resp.State.Set(ctx, model) @@ -452,11 +375,7 @@ func (r *recordSetResource) Update( } // Delete deletes the resource and removes the Terraform state on success. -func (r *recordSetResource) Delete( - ctx context.Context, - req resource.DeleteRequest, // nolint:gocritic // function signature required by Terraform - resp *resource.DeleteResponse, -) { +func (r *recordSetResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform // Retrieve values from plan var model Model diags := req.State.Get(ctx, &model) @@ -482,12 +401,7 @@ func (r *recordSetResource) Delete( tflog.Info(ctx, "Record set already deleted") return } - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error deleting record set", - fmt.Sprintf("Calling API: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting record set", fmt.Sprintf("Calling API: %v", err)) return } @@ -496,18 +410,9 @@ func (r *recordSetResource) Delete( return } - _, err = wait.DeleteRecordSetWaitHandler(ctx, r.client, projectId, zoneId, recordSetId). - WaitWithContext(ctx) + _, err = wait.DeleteRecordSetWaitHandler(ctx, r.client, projectId, zoneId, recordSetId).WaitWithContext(ctx) if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error deleting record set", - fmt.Sprintf( - "Record set deletion waiting: %v. The record set deletion was triggered but confirmation timed out. The record set may still be deleting. Check the STACKIT Portal or retry the operation.", - err, - ), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting record set", fmt.Sprintf("Instance deletion waiting: %v", err)) return } tflog.Info(ctx, "DNS record set deleted") @@ -515,21 +420,12 @@ func (r *recordSetResource) Delete( // ImportState imports a resource into the Terraform state on success. // The expected format of the resource import identifier is: project_id,zone_id,record_set_id -func (r *recordSetResource) ImportState( - ctx context.Context, - req resource.ImportStateRequest, - resp *resource.ImportStateResponse, -) { +func (r *recordSetResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { idParts := strings.Split(req.ID, core.Separator) if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" { - core.LogAndAddError( - ctx, - &resp.Diagnostics, + core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing record set", - fmt.Sprintf( - "Expected import identifier with format [project_id],[zone_id],[record_set_id], got %q", - req.ID, - ), + fmt.Sprintf("Expected import identifier with format [project_id],[zone_id],[record_set_id], got %q", req.ID), ) return } @@ -609,12 +505,7 @@ func toCreatePayload(model *Model) (*dns.CreateRecordSetPayload, error) { for i, record := range model.Records.Elements() { recordString, ok := record.(types.String) if !ok { - return nil, fmt.Errorf( - "expected record at index %d to be of type %T, got %T", - i, - types.String{}, - record, - ) + return nil, fmt.Errorf("expected record at index %d to be of type %T, got %T", i, types.String{}, record) } records = append(records, dns.RecordPayload{ Content: conversion.StringValueToPointer(recordString), @@ -626,9 +517,7 @@ func toCreatePayload(model *Model) (*dns.CreateRecordSetPayload, error) { Name: conversion.StringValueToPointer(model.Name), Records: &records, Ttl: conversion.Int64ValueToPointer(model.TTL), - Type: dns.CreateRecordSetPayloadGetTypeAttributeType( - conversion.StringValueToPointer(model.Type), - ), + Type: dns.CreateRecordSetPayloadGetTypeAttributeType(conversion.StringValueToPointer(model.Type)), }, nil } @@ -641,12 +530,7 @@ func toUpdatePayload(model *Model) (*dns.PartialUpdateRecordSetPayload, error) { for i, record := range model.Records.Elements() { recordString, ok := record.(types.String) if !ok { - return nil, fmt.Errorf( - "expected record at index %d to be of type %T, got %T", - i, - types.String{}, - record, - ) + return nil, fmt.Errorf("expected record at index %d to be of type %T, got %T", i, types.String{}, record) } records = append(records, dns.RecordPayload{ Content: conversion.StringValueToPointer(recordString), diff --git a/stackit/internal/services/dns/zone/resource.go b/stackit/internal/services/dns/zone/resource.go index f50784bae..3f8f7b310 100644 --- a/stackit/internal/services/dns/zone/resource.go +++ b/stackit/internal/services/dns/zone/resource.go @@ -74,20 +74,12 @@ type zoneResource struct { } // Metadata returns the resource type name. -func (r *zoneResource) Metadata( - _ context.Context, - req resource.MetadataRequest, - resp *resource.MetadataResponse, -) { +func (r *zoneResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_dns_zone" } // Configure adds the provider configured client to the resource. -func (r *zoneResource) Configure( - ctx context.Context, - req resource.ConfigureRequest, - resp *resource.ConfigureResponse, -) { +func (r *zoneResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) if !ok { return @@ -102,11 +94,7 @@ func (r *zoneResource) Configure( } // Schema defines the schema for the resource. -func (r *zoneResource) Schema( - _ context.Context, - _ resource.SchemaRequest, - resp *resource.SchemaResponse, -) { +func (r *zoneResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { primaryOptions := []string{"primary", "secondary"} resp.Schema = schema.Schema{ @@ -249,12 +237,10 @@ func (r *zoneResource) Schema( }, }, "type": schema.StringAttribute{ - Description: "Zone type. Defaults to `primary`. " + utils.SupportedValuesDocumentation( - primaryOptions, - ), - Optional: true, - Computed: true, - Default: stringdefault.StaticString("primary"), + Description: "Zone type. Defaults to `primary`. " + utils.SupportedValuesDocumentation(primaryOptions), + Optional: true, + Computed: true, + Default: stringdefault.StaticString("primary"), Validators: []validator.String{ stringvalidator.OneOf(primaryOptions...), }, @@ -292,11 +278,7 @@ func (r *zoneResource) Schema( } // Create creates the resource and sets the initial Terraform state. -func (r *zoneResource) Create( - ctx context.Context, - req resource.CreateRequest, // nolint:gocritic // function signature required by Terraform - resp *resource.CreateResponse, -) { +func (r *zoneResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform // Retrieve values from plan var model Model resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...) @@ -310,12 +292,7 @@ func (r *zoneResource) Create( // Generate API request body from model payload, err := toCreatePayload(&model) if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error creating zone", - fmt.Sprintf("Creating API payload: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating zone", fmt.Sprintf("Calling API: %v", err)) return } // Create new zone @@ -352,30 +329,16 @@ func (r *zoneResource) Create( return } - waitResp, err := wait.CreateZoneWaitHandler(ctx, r.client, projectId, zoneId). - WaitWithContext(ctx) + waitResp, err := wait.CreateZoneWaitHandler(ctx, r.client, projectId, zoneId).WaitWithContext(ctx) if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error creating zone", - fmt.Sprintf( - "Zone creation waiting: %v. The zone was created but is not yet ready. You can check its status in the STACKIT Portal or run 'terraform refresh' to update the state once it's ready.", - err, - ), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating zone", fmt.Sprintf("Zone creation waiting: %v", err)) return } // Map response body to schema err = mapFields(ctx, waitResp, &model) if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error creating zone", - fmt.Sprintf("Processing API payload: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating zone", fmt.Sprintf("Processing API payload: %v", err)) return } // Set state to fully populated data @@ -387,11 +350,7 @@ func (r *zoneResource) Create( } // Read refreshes the Terraform state with the latest data. -func (r *zoneResource) Read( - ctx context.Context, - req resource.ReadRequest, // nolint:gocritic // function signature required by Terraform - resp *resource.ReadResponse, -) { +func (r *zoneResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform var model Model diags := req.State.Get(ctx, &model) resp.Diagnostics.Append(diags...) @@ -416,12 +375,7 @@ func (r *zoneResource) Read( resp.State.RemoveResource(ctx) return } - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error reading zone", - fmt.Sprintf("Calling API: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading zone", fmt.Sprintf("Calling API: %v", err)) return } if zoneResp != nil && zoneResp.Zone.State != nil && @@ -433,12 +387,7 @@ func (r *zoneResource) Read( // Map response body to schema err = mapFields(ctx, zoneResp, &model) if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error reading zone", - fmt.Sprintf("Processing API payload: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading zone", fmt.Sprintf("Processing API payload: %v", err)) return } // Set refreshed state @@ -451,11 +400,7 @@ func (r *zoneResource) Read( } // Update updates the resource and sets the updated Terraform state on success. -func (r *zoneResource) Update( - ctx context.Context, - req resource.UpdateRequest, // nolint:gocritic // function signature required by Terraform - resp *resource.UpdateResponse, -) { +func (r *zoneResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform // Retrieve values from plan var model Model diags := req.Plan.Get(ctx, &model) @@ -471,18 +416,11 @@ func (r *zoneResource) Update( // Generate API request body from model payload, err := toUpdatePayload(&model) if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error updating zone", - fmt.Sprintf("Creating API payload: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", fmt.Sprintf("Creating API payload: %v", err)) return } // Update existing zone - _, err = r.client.PartialUpdateZone(ctx, projectId, zoneId). - PartialUpdateZonePayload(*payload). - Execute() + _, err = r.client.PartialUpdateZone(ctx, projectId, zoneId).PartialUpdateZonePayload(*payload).Execute() if err != nil { core.LogAndAddError( ctx, @@ -498,18 +436,9 @@ func (r *zoneResource) Update( return } - waitResp, err := wait.PartialUpdateZoneWaitHandler(ctx, r.client, projectId, zoneId). - WaitWithContext(ctx) + waitResp, err := wait.PartialUpdateZoneWaitHandler(ctx, r.client, projectId, zoneId).WaitWithContext(ctx) if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error updating zone", - fmt.Sprintf( - "Zone update waiting: %v. The update was triggered but may not be complete. Run 'terraform refresh' to check the current state.", - err, - ), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", fmt.Sprintf("Zone update waiting: %v", err)) return } @@ -532,11 +461,7 @@ func (r *zoneResource) Update( } // Delete deletes the resource and removes the Terraform state on success. -func (r *zoneResource) Delete( - ctx context.Context, - req resource.DeleteRequest, // nolint:gocritic // function signature required by Terraform - resp *resource.DeleteResponse, -) { +func (r *zoneResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform // Retrieve values from state var model Model diags := req.State.Get(ctx, &model) @@ -560,12 +485,7 @@ func (r *zoneResource) Delete( tflog.Info(ctx, "DNS zone already deleted") return } - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error deleting zone", - fmt.Sprintf("Calling API: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting zone", fmt.Sprintf("Calling API: %v", err)) return } @@ -576,15 +496,7 @@ func (r *zoneResource) Delete( _, err = wait.DeleteZoneWaitHandler(ctx, r.client, projectId, zoneId).WaitWithContext(ctx) if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error deleting zone", - fmt.Sprintf( - "Zone deletion waiting: %v. The zone deletion was triggered but confirmation timed out. The zone may still be deleting. Check the STACKIT Portal or retry the operation.", - err, - ), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting zone", fmt.Sprintf("Zone deletion waiting: %v", err)) return } @@ -593,30 +505,31 @@ func (r *zoneResource) Delete( // ImportState imports a resource into the Terraform state on success. // The expected format of the resource import identifier is: project_id,zone_id -func (r *zoneResource) ImportState( - ctx context.Context, - req resource.ImportStateRequest, - resp *resource.ImportStateResponse, -) { +func (r *zoneResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { idParts := strings.Split(req.ID, core.Separator) if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" { - core.LogAndAddError( - ctx, - &resp.Diagnostics, + core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing zone", - fmt.Sprintf( - "Expected import identifier with format: [project_id],[zone_id] Got: %q", - req.ID, - ), + fmt.Sprintf("Expected import identifier with format: [project_id],[zone_id] Got: %q", req.ID), ) return } - utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]interface{}{ - "project_id": idParts[0], - "zone_id": idParts[1], - }) + var model Model + model.ProjectId = types.StringValue(idParts[0]) + model.ZoneId = types.StringValue(idParts[1]) + + if err := utils.SetModelFieldsToNull(ctx, &model); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing zone", fmt.Sprintf("Setting model fields to null: %v", err)) + return + } + + diags := resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } tflog.Info(ctx, "DNS zone state imported") } @@ -703,14 +616,12 @@ func toCreatePayload(model *Model) (*dns.CreateZonePayload, error) { modelPrimaries = append(modelPrimaries, primaryString.ValueString()) } return &dns.CreateZonePayload{ - Name: conversion.StringValueToPointer(model.Name), - DnsName: conversion.StringValueToPointer(model.DnsName), - ContactEmail: conversion.StringValueToPointer(model.ContactEmail), - Description: conversion.StringValueToPointer(model.Description), - Acl: conversion.StringValueToPointer(model.Acl), - Type: dns.CreateZonePayloadGetTypeAttributeType( - conversion.StringValueToPointer(model.Type), - ), + Name: conversion.StringValueToPointer(model.Name), + DnsName: conversion.StringValueToPointer(model.DnsName), + ContactEmail: conversion.StringValueToPointer(model.ContactEmail), + Description: conversion.StringValueToPointer(model.Description), + Acl: conversion.StringValueToPointer(model.Acl), + Type: dns.CreateZonePayloadGetTypeAttributeType(conversion.StringValueToPointer(model.Type)), DefaultTTL: conversion.Int64ValueToPointer(model.DefaultTTL), ExpireTime: conversion.Int64ValueToPointer(model.ExpireTime), RefreshTime: conversion.Int64ValueToPointer(model.RefreshTime), From e7649c226b82fa56c07057bdb6985732001c8825 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Tue, 11 Nov 2025 10:53:05 +0100 Subject: [PATCH 13/20] revert formatting --- .../services/dns/recordset/resource.go | 7 +----- .../internal/services/dns/zone/resource.go | 23 ++++--------------- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/stackit/internal/services/dns/recordset/resource.go b/stackit/internal/services/dns/recordset/resource.go index d1637ad0b..731e3d458 100644 --- a/stackit/internal/services/dns/recordset/resource.go +++ b/stackit/internal/services/dns/recordset/resource.go @@ -302,12 +302,7 @@ func (r *recordSetResource) Read(ctx context.Context, req resource.ReadRequest, // Map response body to schema err = mapFields(ctx, recordSetResp, &model) if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error reading record set", - fmt.Sprintf("Processing API payload: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading record set", fmt.Sprintf("Processing API payload: %v", err)) return } diff --git a/stackit/internal/services/dns/zone/resource.go b/stackit/internal/services/dns/zone/resource.go index 3f8f7b310..23b5a86ed 100644 --- a/stackit/internal/services/dns/zone/resource.go +++ b/stackit/internal/services/dns/zone/resource.go @@ -292,18 +292,13 @@ func (r *zoneResource) Create(ctx context.Context, req resource.CreateRequest, r // Generate API request body from model payload, err := toCreatePayload(&model) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating zone", fmt.Sprintf("Calling API: %v", err)) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating zone", fmt.Sprintf("Creating API payload: %v", err)) return } // Create new zone createResp, err := r.client.CreateZone(ctx, projectId).CreateZonePayload(*payload).Execute() if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error creating zone", - fmt.Sprintf("Calling API: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating zone", fmt.Sprintf("Calling API: %v", err)) return } @@ -422,12 +417,7 @@ func (r *zoneResource) Update(ctx context.Context, req resource.UpdateRequest, r // Update existing zone _, err = r.client.PartialUpdateZone(ctx, projectId, zoneId).PartialUpdateZonePayload(*payload).Execute() if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error updating zone", - fmt.Sprintf("Calling API: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", fmt.Sprintf("Calling API: %v", err)) return } @@ -444,12 +434,7 @@ func (r *zoneResource) Update(ctx context.Context, req resource.UpdateRequest, r err = mapFields(ctx, waitResp, &model) if err != nil { - core.LogAndAddError( - ctx, - &resp.Diagnostics, - "Error updating zone", - fmt.Sprintf("Processing API payload: %v", err), - ) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", fmt.Sprintf("Processing API payload: %v", err)) return } diags = resp.State.Set(ctx, model) From 037cecedd993b6cc397e5209a3c3d5723a1ee6c4 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Tue, 11 Nov 2025 12:50:02 +0100 Subject: [PATCH 14/20] import state --- .../services/dns/recordset/resource.go | 22 ++++++++++++++----- .../internal/services/dns/zone/resource.go | 1 + 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/stackit/internal/services/dns/recordset/resource.go b/stackit/internal/services/dns/recordset/resource.go index 731e3d458..a9262fe37 100644 --- a/stackit/internal/services/dns/recordset/resource.go +++ b/stackit/internal/services/dns/recordset/resource.go @@ -425,11 +425,23 @@ func (r *recordSetResource) ImportState(ctx context.Context, req resource.Import return } - utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]interface{}{ - "project_id": idParts[0], - "zone_id": idParts[1], - "record_set_id": idParts[2], - }) + var model Model + model.ProjectId = types.StringValue(idParts[0]) + model.ZoneId = types.StringValue(idParts[1]) + model.RecordSetId = types.StringValue(idParts[2]) + model.Id = utils.BuildInternalTerraformId(idParts[0], idParts[1], idParts[2]) + + if err := utils.SetModelFieldsToNull(ctx, &model); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing zone", fmt.Sprintf("Setting model fields to null: %v", err)) + return + } + + diags := resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + tflog.Info(ctx, "DNS record set state imported") } diff --git a/stackit/internal/services/dns/zone/resource.go b/stackit/internal/services/dns/zone/resource.go index 23b5a86ed..50aaed1c1 100644 --- a/stackit/internal/services/dns/zone/resource.go +++ b/stackit/internal/services/dns/zone/resource.go @@ -504,6 +504,7 @@ func (r *zoneResource) ImportState(ctx context.Context, req resource.ImportState var model Model model.ProjectId = types.StringValue(idParts[0]) model.ZoneId = types.StringValue(idParts[1]) + model.Id = utils.BuildInternalTerraformId(idParts[0], idParts[1]) if err := utils.SetModelFieldsToNull(ctx, &model); err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing zone", fmt.Sprintf("Setting model fields to null: %v", err)) From 265836f7841ec90dbd5ccdf2b63ce6af02b48cf9 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Wed, 12 Nov 2025 21:20:08 +0100 Subject: [PATCH 15/20] downlint lint from releases + remove read id check --- Makefile | 8 ++-- scripts/install-golangci-lint.sh | 42 +++++++++++++++++ scripts/utility.sh | 46 +++++++++++++++++++ .../services/dns/recordset/resource.go | 16 +++---- .../internal/services/dns/zone/resource.go | 13 ++---- 5 files changed, 103 insertions(+), 22 deletions(-) create mode 100755 scripts/install-golangci-lint.sh create mode 100755 scripts/utility.sh diff --git a/Makefile b/Makefile index 5f0ddda2f..6cb0bc140 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ ROOT_DIR ?= $(shell git rev-parse --show-toplevel) SCRIPTS_BASE ?= $(ROOT_DIR)/scripts +BIN_DIR ?= $(ROOT_DIR)/bin # https://github.com/golangci/golangci-lint/releases -GOLANGCI_VERSION = 1.64.8 -GOLANGCI_LINT = bin/golangci-lint-$(GOLANGCI_VERSION) +GOLANGCI_LINT_VERSION = 1.64.8 +GOLANGCI_LINT = $(BIN_DIR)/golangci-lint # SETUP AND TOOL INITIALIZATION TASKS project-help: @@ -14,8 +15,7 @@ project-tools: # GOLANGCI-LINT INSTALLATION $(GOLANGCI_LINT): - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | bash -s -- -b bin v$(GOLANGCI_VERSION) - @mv bin/golangci-lint "$(@)" + @GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) $(SCRIPTS_BASE)/install-golangci-lint.sh # LINT lint-golangci-lint: $(GOLANGCI_LINT) diff --git a/scripts/install-golangci-lint.sh b/scripts/install-golangci-lint.sh new file mode 100755 index 000000000..c70a1e6fe --- /dev/null +++ b/scripts/install-golangci-lint.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -e +. $(dirname ${0})/utility.sh + +BINARY_NAME=golangci-lint +INSTALL_TO=${BIN_DIR}/${BINARY_NAME} + +install() { + echo " installing ${BINARY_NAME} ${GOLANGCI_LINT_VERSION}" + + TYPE=windows + if [[ "${OSTYPE}" == linux* ]]; then + TYPE=linux + elif [[ "${OSTYPE}" == darwin* ]]; then + TYPE=darwin + fi + + case $(uname -m) in + arm64|aarch64) + ARCH=arm64 + ;; + *) + ARCH=amd64 + ;; + esac + + BASE_URL=https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION} + URL=${BASE_URL}/golangci-lint-${GOLANGCI_LINT_VERSION}-${TYPE}-${ARCH}.tar.gz + echo " Downloading: ${URL}" + download ${URL} | tar --extract --gzip --strip-components 1 --preserve-permissions -C ${BIN_DIR} -f- + + # Ensure the binary has the correct name + if [ -f "${BIN_DIR}/golangci-lint" ]; then + mv "${BIN_DIR}/golangci-lint" "${INSTALL_TO}" + fi +} + +get_version() { + ${INSTALL_TO} version 2>/dev/null | awk '{print $4}' +} + +update_if_necessary ${GOLANGCI_LINT_VERSION} diff --git a/scripts/utility.sh b/scripts/utility.sh new file mode 100755 index 000000000..c46fcb8d8 --- /dev/null +++ b/scripts/utility.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Common utility functions for tool installation scripts + +ROOT_DIR=$(git rev-parse --show-toplevel) +BIN_DIR="${ROOT_DIR}/bin" + +# Ensure bin directory exists +mkdir -p "${BIN_DIR}" + +# Download function using curl +download() { + local URL=$1 + if command -v curl &> /dev/null; then + curl -sSfL "${URL}" + elif command -v wget &> /dev/null; then + wget -qO- "${URL}" + else + echo "Error: Neither curl nor wget found. Please install one of them." + exit 1 + fi +} + +# Update tool if necessary +update_if_necessary() { + local EXPECTED_VERSION=$1 + + if [ -x "${INSTALL_TO}" ]; then + CURRENT_VERSION=$(get_version 2>/dev/null || echo "") + if [ "${CURRENT_VERSION}" = "${EXPECTED_VERSION}" ]; then + echo " ${BINARY_NAME} ${EXPECTED_VERSION} already installed" + return 0 + else + echo " ${BINARY_NAME} version mismatch (current: ${CURRENT_VERSION}, expected: ${EXPECTED_VERSION})" + echo " updating to ${EXPECTED_VERSION}..." + fi + fi + + install + + INSTALLED_VERSION=$(get_version 2>/dev/null || echo "unknown") + if [ "${INSTALLED_VERSION}" = "${EXPECTED_VERSION}" ]; then + echo " ${BINARY_NAME} ${EXPECTED_VERSION} installed successfully" + else + echo " Warning: installed version (${INSTALLED_VERSION}) does not match expected version (${EXPECTED_VERSION})" + fi +} diff --git a/stackit/internal/services/dns/recordset/resource.go b/stackit/internal/services/dns/recordset/resource.go index a9262fe37..5989f4737 100644 --- a/stackit/internal/services/dns/recordset/resource.go +++ b/stackit/internal/services/dns/recordset/resource.go @@ -2,6 +2,7 @@ package dns import ( "context" + "errors" "fmt" "net/http" "strings" @@ -278,15 +279,10 @@ func (r *recordSetResource) Read(ctx context.Context, req resource.ReadRequest, ctx = tflog.SetField(ctx, "zone_id", zoneId) ctx = tflog.SetField(ctx, "record_set_id", recordSetId) - if recordSetId == "" || zoneId == "" || projectId == "" { - tflog.Info(ctx, "Record set ID, zone ID, or project ID is empty, removing resource") - resp.State.RemoveResource(ctx) - return - } - recordSetResp, err := r.client.GetRecordSet(ctx, projectId, zoneId, recordSetId).Execute() if err != nil { - oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) if ok && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { resp.State.RemoveResource(ctx) return @@ -390,9 +386,9 @@ func (r *recordSetResource) Delete(ctx context.Context, req resource.DeleteReque _, err := r.client.DeleteRecordSet(ctx, projectId, zoneId, recordSetId).Execute() if err != nil { // If resource is already gone (404 or 410), treat as success for idempotency - oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped - if ok && - (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) + if ok && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { tflog.Info(ctx, "Record set already deleted") return } diff --git a/stackit/internal/services/dns/zone/resource.go b/stackit/internal/services/dns/zone/resource.go index 50aaed1c1..d7adb2818 100644 --- a/stackit/internal/services/dns/zone/resource.go +++ b/stackit/internal/services/dns/zone/resource.go @@ -2,6 +2,7 @@ package dns import ( "context" + "errors" "fmt" "math" "net/http" @@ -357,15 +358,10 @@ func (r *zoneResource) Read(ctx context.Context, req resource.ReadRequest, resp ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "zone_id", zoneId) - if zoneId == "" { - tflog.Info(ctx, "Zone ID is empty, removing resource") - resp.State.RemoveResource(ctx) - return - } - zoneResp, err := r.client.GetZone(ctx, projectId, zoneId).Execute() if err != nil { - oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) if ok && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { resp.State.RemoveResource(ctx) return @@ -464,7 +460,8 @@ func (r *zoneResource) Delete(ctx context.Context, req resource.DeleteRequest, r _, err := r.client.DeleteZone(ctx, projectId, zoneId).Execute() if err != nil { // If resource is already gone (404 or 410), treat as success for idempotency - oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + var oapiErr *oapierror.GenericOpenAPIError + ok := errors.As(err, &oapiErr) if ok && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { tflog.Info(ctx, "DNS zone already deleted") From 1196efb264f558b2fb024b410f4cbf4fa2591c51 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Wed, 12 Nov 2025 22:16:57 +0100 Subject: [PATCH 16/20] fix pipeline linting --- scripts/install-golangci-lint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install-golangci-lint.sh b/scripts/install-golangci-lint.sh index c70a1e6fe..725a25309 100755 --- a/scripts/install-golangci-lint.sh +++ b/scripts/install-golangci-lint.sh @@ -30,7 +30,7 @@ install() { download ${URL} | tar --extract --gzip --strip-components 1 --preserve-permissions -C ${BIN_DIR} -f- # Ensure the binary has the correct name - if [ -f "${BIN_DIR}/golangci-lint" ]; then + if [ -f "${BIN_DIR}/golangci-lint" ] && [ "${BIN_DIR}/golangci-lint" != "${INSTALL_TO}" ]; then mv "${BIN_DIR}/golangci-lint" "${INSTALL_TO}" fi } From 50f1f372258d82559b68cb9e2c437de1d6885240 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Thu, 13 Nov 2025 21:39:20 +0100 Subject: [PATCH 17/20] adjust SetModelFieldsToNull to handle complex objects and lists --- stackit/internal/utils/utils.go | 518 +++++++++++++++++++++++-- stackit/internal/utils/utils_test.go | 555 +++++++++++++++++++++++++++ 2 files changed, 1046 insertions(+), 27 deletions(-) diff --git a/stackit/internal/utils/utils.go b/stackit/internal/utils/utils.go index 305b65c57..7d2a3ef26 100644 --- a/stackit/internal/utils/utils.go +++ b/stackit/internal/utils/utils.go @@ -188,6 +188,7 @@ func SetAndLogStateFields(ctx context.Context, diags *diag.Diagnostics, state *t // SetModelFieldsToNull sets all Unknown or Null fields in a model struct to their appropriate Null values. // This is useful when saving minimal state after API calls to ensure idempotency. // The model parameter must be a pointer to a struct containing Terraform framework types. +// This function recursively processes nested objects, lists, sets, and maps. func SetModelFieldsToNull(ctx context.Context, model any) error { if model == nil { return fmt.Errorf("model cannot be nil") @@ -233,51 +234,514 @@ func SetModelFieldsToNull(ctx context.Context, model any) error { isUnknown := isUnknownResult[0].Bool() isNull := isNullResult[0].Bool() - if !isUnknown && !isNull { + // If the field is Unknown or Null at the top level, convert it to Null + if isUnknown || isNull { + if err := setFieldToNull(ctx, field, fieldValue, fieldType); err != nil { + return err + } continue } - // Determine the type and set to appropriate Null value - switch v := fieldValue.(type) { - case basetypes.StringValue: - field.Set(reflect.ValueOf(types.StringNull())) + // If the field is Known and not Null, recursively process it + if err := processKnownField(ctx, field, fieldValue, fieldType); err != nil { + return err + } + } + + return nil +} + +// setFieldToNull sets a field to its appropriate Null value based on type +func setFieldToNull(ctx context.Context, field reflect.Value, fieldValue any, fieldType reflect.StructField) error { + switch v := fieldValue.(type) { + case basetypes.StringValue: + field.Set(reflect.ValueOf(types.StringNull())) + + case basetypes.BoolValue: + field.Set(reflect.ValueOf(types.BoolNull())) + + case basetypes.Int64Value: + field.Set(reflect.ValueOf(types.Int64Null())) + + case basetypes.Float64Value: + field.Set(reflect.ValueOf(types.Float64Null())) + + case basetypes.NumberValue: + field.Set(reflect.ValueOf(types.NumberNull())) + + case basetypes.ListValue: + elemType := v.ElementType(ctx) + field.Set(reflect.ValueOf(types.ListNull(elemType))) + + case basetypes.SetValue: + elemType := v.ElementType(ctx) + field.Set(reflect.ValueOf(types.SetNull(elemType))) + + case basetypes.MapValue: + elemType := v.ElementType(ctx) + field.Set(reflect.ValueOf(types.MapNull(elemType))) + + case basetypes.ObjectValue: + attrTypes := v.AttributeTypes(ctx) + field.Set(reflect.ValueOf(types.ObjectNull(attrTypes))) + + default: + tflog.Debug(ctx, fmt.Sprintf("SetModelFieldsToNull: skipping field %s of unsupported type %T", fieldType.Name, fieldValue)) + } + return nil +} + +// processKnownField recursively processes known (non-null, non-unknown) fields +// to handle nested structures like objects within lists, maps, etc. +func processKnownField(ctx context.Context, field reflect.Value, fieldValue any, fieldType reflect.StructField) error { + switch v := fieldValue.(type) { + case basetypes.ObjectValue: + // Recursively process object fields + return processObjectValue(ctx, field, v, fieldType) + + case basetypes.ListValue: + // Recursively process list elements + return processListValue(ctx, field, v, fieldType) + + case basetypes.SetValue: + // Recursively process set elements + return processSetValue(ctx, field, v, fieldType) + + case basetypes.MapValue: + // Recursively process map values + return processMapValue(ctx, field, v, fieldType) + + default: + // Primitive types (String, Bool, Int64, etc.) don't need recursion + return nil + } +} + +// processObjectValue recursively processes fields within an ObjectValue +func processObjectValue(ctx context.Context, field reflect.Value, objValue basetypes.ObjectValue, fieldType reflect.StructField) error { + attrs := objValue.Attributes() + attrTypes := objValue.AttributeTypes(ctx) + modified := false + newAttrs := make(map[string]attr.Value, len(attrs)) + + for key, attrVal := range attrs { + // Check if the attribute has IsUnknown and IsNull methods + attrValReflect := reflect.ValueOf(attrVal) + isUnknownMethod := attrValReflect.MethodByName("IsUnknown") + isNullMethod := attrValReflect.MethodByName("IsNull") + + if !isUnknownMethod.IsValid() || !isNullMethod.IsValid() { + newAttrs[key] = attrVal + continue + } + + isUnknownResult := isUnknownMethod.Call(nil) + isNullResult := isNullMethod.Call(nil) + + if len(isUnknownResult) == 0 || len(isNullResult) == 0 { + newAttrs[key] = attrVal + continue + } + + isUnknown := isUnknownResult[0].Bool() + isNull := isNullResult[0].Bool() - case basetypes.BoolValue: - field.Set(reflect.ValueOf(types.BoolNull())) + // Convert Unknown or Null attributes to Null + if isUnknown || isNull { + nullVal := createNullValue(ctx, attrVal, attrTypes[key]) + if nullVal != nil { + newAttrs[key] = nullVal + modified = true + } else { + newAttrs[key] = attrVal + } + } else { + // Recursively process known attributes + processedVal, wasModified, err := processAttributeValueWithFlag(ctx, attrVal, attrTypes[key]) + if err != nil { + return err + } + newAttrs[key] = processedVal + if wasModified { + modified = true + } + } + } - case basetypes.Int64Value: - field.Set(reflect.ValueOf(types.Int64Null())) + // Only update the field if something changed + if modified { + newObj, diags := types.ObjectValue(attrTypes, newAttrs) + if diags.HasError() { + return fmt.Errorf("creating new object value for field %s: %v", fieldType.Name, diags.Errors()) + } + field.Set(reflect.ValueOf(newObj)) + } - case basetypes.Float64Value: - field.Set(reflect.ValueOf(types.Float64Null())) + return nil +} - case basetypes.NumberValue: - field.Set(reflect.ValueOf(types.NumberNull())) +// processListValue recursively processes elements within a ListValue +func processListValue(ctx context.Context, field reflect.Value, listValue basetypes.ListValue, fieldType reflect.StructField) error { + elements := listValue.Elements() + if len(elements) == 0 { + return nil + } - case basetypes.ListValue: - elemType := v.ElementType(ctx) - field.Set(reflect.ValueOf(types.ListNull(elemType))) + elemType := listValue.ElementType(ctx) + modified := false + newElements := make([]attr.Value, len(elements)) - case basetypes.SetValue: - elemType := v.ElementType(ctx) - field.Set(reflect.ValueOf(types.SetNull(elemType))) + for i, elem := range elements { + // Check if element is Unknown or Null + elemReflect := reflect.ValueOf(elem) + isUnknownMethod := elemReflect.MethodByName("IsUnknown") + isNullMethod := elemReflect.MethodByName("IsNull") - case basetypes.MapValue: - elemType := v.ElementType(ctx) - field.Set(reflect.ValueOf(types.MapNull(elemType))) + if !isUnknownMethod.IsValid() || !isNullMethod.IsValid() { + newElements[i] = elem + continue + } - case basetypes.ObjectValue: - attrTypes := v.AttributeTypes(ctx) - field.Set(reflect.ValueOf(types.ObjectNull(attrTypes))) + isUnknownResult := isUnknownMethod.Call(nil) + isNullResult := isNullMethod.Call(nil) - default: - tflog.Debug(ctx, fmt.Sprintf("SetModelFieldsToNull: skipping field %s of unsupported type %T", fieldType.Name, fieldValue)) + if len(isUnknownResult) == 0 || len(isNullResult) == 0 { + newElements[i] = elem + continue + } + + isUnknown := isUnknownResult[0].Bool() + isNull := isNullResult[0].Bool() + + if isUnknown || isNull { + nullVal := createNullValue(ctx, elem, elemType) + if nullVal != nil { + newElements[i] = nullVal + modified = true + } else { + newElements[i] = elem + } + } else { + // Recursively process known elements (objects, lists, etc.) + processedElem, wasModified, err := processAttributeValueWithFlag(ctx, elem, elemType) + if err != nil { + return err + } + newElements[i] = processedElem + if wasModified { + modified = true + } } } + // Only update if something changed + if modified { + newList, diags := types.ListValue(elemType, newElements) + if diags.HasError() { + return fmt.Errorf("creating new list value for field %s: %v", fieldType.Name, diags.Errors()) + } + field.Set(reflect.ValueOf(newList)) + } + return nil } +// processSetValue recursively processes elements within a SetValue +func processSetValue(ctx context.Context, field reflect.Value, setValue basetypes.SetValue, fieldType reflect.StructField) error { + elements := setValue.Elements() + if len(elements) == 0 { + return nil + } + + elemType := setValue.ElementType(ctx) + modified := false + newElements := make([]attr.Value, len(elements)) + + for i, elem := range elements { + elemReflect := reflect.ValueOf(elem) + isUnknownMethod := elemReflect.MethodByName("IsUnknown") + isNullMethod := elemReflect.MethodByName("IsNull") + + if !isUnknownMethod.IsValid() || !isNullMethod.IsValid() { + newElements[i] = elem + continue + } + + isUnknownResult := isUnknownMethod.Call(nil) + isNullResult := isNullMethod.Call(nil) + + if len(isUnknownResult) == 0 || len(isNullResult) == 0 { + newElements[i] = elem + continue + } + + isUnknown := isUnknownResult[0].Bool() + isNull := isNullResult[0].Bool() + + if isUnknown || isNull { + nullVal := createNullValue(ctx, elem, elemType) + if nullVal != nil { + newElements[i] = nullVal + modified = true + } else { + newElements[i] = elem + } + } else { + processedElem, wasModified, err := processAttributeValueWithFlag(ctx, elem, elemType) + if err != nil { + return err + } + newElements[i] = processedElem + if wasModified { + modified = true + } + } + } + + if modified { + newSet, diags := types.SetValue(elemType, newElements) + if diags.HasError() { + return fmt.Errorf("creating new set value for field %s: %v", fieldType.Name, diags.Errors()) + } + field.Set(reflect.ValueOf(newSet)) + } + + return nil +} + +// processMapValue recursively processes values within a MapValue +func processMapValue(ctx context.Context, field reflect.Value, mapValue basetypes.MapValue, fieldType reflect.StructField) error { + elements := mapValue.Elements() + if len(elements) == 0 { + return nil + } + + elemType := mapValue.ElementType(ctx) + modified := false + newElements := make(map[string]attr.Value, len(elements)) + + for key, val := range elements { + valReflect := reflect.ValueOf(val) + isUnknownMethod := valReflect.MethodByName("IsUnknown") + isNullMethod := valReflect.MethodByName("IsNull") + + if !isUnknownMethod.IsValid() || !isNullMethod.IsValid() { + newElements[key] = val + continue + } + + isUnknownResult := isUnknownMethod.Call(nil) + isNullResult := isNullMethod.Call(nil) + + if len(isUnknownResult) == 0 || len(isNullResult) == 0 { + newElements[key] = val + continue + } + + isUnknown := isUnknownResult[0].Bool() + isNull := isNullResult[0].Bool() + + if isUnknown || isNull { + nullVal := createNullValue(ctx, val, elemType) + if nullVal != nil { + newElements[key] = nullVal + modified = true + } else { + newElements[key] = val + } + } else { + processedVal, wasModified, err := processAttributeValueWithFlag(ctx, val, elemType) + if err != nil { + return err + } + newElements[key] = processedVal + if wasModified { + modified = true + } + } + } + + if modified { + newMap, diags := types.MapValue(elemType, newElements) + if diags.HasError() { + return fmt.Errorf("creating new map value for field %s: %v", fieldType.Name, diags.Errors()) + } + field.Set(reflect.ValueOf(newMap)) + } + + return nil +} + +// processAttributeValueWithFlag recursively processes a single attribute value +// Returns the processed value, a flag indicating if it was modified, and an error +func processAttributeValueWithFlag(ctx context.Context, attrVal attr.Value, attrType attr.Type) (attr.Value, bool, error) { + switch v := attrVal.(type) { + case basetypes.ObjectValue: + // Recursively process object attributes + attrs := v.Attributes() + objType, ok := attrType.(types.ObjectType) + if !ok { + return attrVal, false, nil + } + attrTypes := objType.AttrTypes + modified := false + newAttrs := make(map[string]attr.Value, len(attrs)) + + for key, subAttr := range attrs { + subAttrReflect := reflect.ValueOf(subAttr) + isUnknownMethod := subAttrReflect.MethodByName("IsUnknown") + isNullMethod := subAttrReflect.MethodByName("IsNull") + + if !isUnknownMethod.IsValid() || !isNullMethod.IsValid() { + newAttrs[key] = subAttr + continue + } + + isUnknownResult := isUnknownMethod.Call(nil) + isNullResult := isNullMethod.Call(nil) + + if len(isUnknownResult) == 0 || len(isNullResult) == 0 { + newAttrs[key] = subAttr + continue + } + + isUnknown := isUnknownResult[0].Bool() + isNull := isNullResult[0].Bool() + + if isUnknown || isNull { + nullVal := createNullValue(ctx, subAttr, attrTypes[key]) + if nullVal != nil { + newAttrs[key] = nullVal + modified = true + } else { + newAttrs[key] = subAttr + } + } else { + processedSubAttr, wasModified, err := processAttributeValueWithFlag(ctx, subAttr, attrTypes[key]) + if err != nil { + return attrVal, false, err + } + newAttrs[key] = processedSubAttr + if wasModified { + modified = true + } + } + } + + if modified { + newObj, diags := types.ObjectValue(attrTypes, newAttrs) + if diags.HasError() { + return attrVal, false, fmt.Errorf("creating new object value: %v", diags.Errors()) + } + return newObj, true, nil + } + return attrVal, false, nil + + case basetypes.ListValue: + // Recursively process list elements + elements := v.Elements() + if len(elements) == 0 { + return attrVal, false, nil + } + + elemType := v.ElementType(ctx) + modified := false + newElements := make([]attr.Value, len(elements)) + + for i, elem := range elements { + elemReflect := reflect.ValueOf(elem) + isUnknownMethod := elemReflect.MethodByName("IsUnknown") + isNullMethod := elemReflect.MethodByName("IsNull") + + if !isUnknownMethod.IsValid() || !isNullMethod.IsValid() { + newElements[i] = elem + continue + } + + isUnknownResult := isUnknownMethod.Call(nil) + isNullResult := isNullMethod.Call(nil) + + if len(isUnknownResult) == 0 || len(isNullResult) == 0 { + newElements[i] = elem + continue + } + + isUnknown := isUnknownResult[0].Bool() + isNull := isNullResult[0].Bool() + + if isUnknown || isNull { + nullVal := createNullValue(ctx, elem, elemType) + if nullVal != nil { + newElements[i] = nullVal + modified = true + } else { + newElements[i] = elem + } + } else { + processedElem, wasModified, err := processAttributeValueWithFlag(ctx, elem, elemType) + if err != nil { + return attrVal, false, err + } + newElements[i] = processedElem + if wasModified { + modified = true + } + } + } + + if modified { + newList, diags := types.ListValue(elemType, newElements) + if diags.HasError() { + return attrVal, false, fmt.Errorf("creating new list value: %v", diags.Errors()) + } + return newList, true, nil + } + return attrVal, false, nil + + default: + // Primitive types don't need further processing + return attrVal, false, nil + } +} + +// createNullValue creates a null value of the appropriate type +func createNullValue(ctx context.Context, val attr.Value, attrType attr.Type) attr.Value { + switch val.(type) { + case basetypes.StringValue: + return types.StringNull() + case basetypes.BoolValue: + return types.BoolNull() + case basetypes.Int64Value: + return types.Int64Null() + case basetypes.Float64Value: + return types.Float64Null() + case basetypes.NumberValue: + return types.NumberNull() + case basetypes.ListValue: + if listType, ok := attrType.(types.ListType); ok { + return types.ListNull(listType.ElemType) + } + return nil + case basetypes.SetValue: + if setType, ok := attrType.(types.SetType); ok { + return types.SetNull(setType.ElemType) + } + return nil + case basetypes.MapValue: + if mapType, ok := attrType.(types.MapType); ok { + return types.MapNull(mapType.ElemType) + } + return nil + case basetypes.ObjectValue: + if objType, ok := attrType.(types.ObjectType); ok { + return types.ObjectNull(objType.AttrTypes) + } + return nil + default: + return nil + } +} + // ShouldWait checks the STACKIT_TF_WAIT_FOR_READY environment variable to determine // if the provider should wait for resources to be ready after creation/update. // Returns true if the variable is unset or set to "true" (case-insensitive). diff --git a/stackit/internal/utils/utils_test.go b/stackit/internal/utils/utils_test.go index 23ed75063..56df482ca 100644 --- a/stackit/internal/utils/utils_test.go +++ b/stackit/internal/utils/utils_test.go @@ -813,6 +813,561 @@ func TestSetModelFieldsToNull_Errors(t *testing.T) { } } +func TestSetModelFieldsToNull_ComplexStructures(t *testing.T) { + ctx := context.Background() + + // Test nested objects + t.Run("object with unknown fields inside known object", func(t *testing.T) { + type NestedModel struct { + NestedObject types.Object `tfsdk:"nested_object"` + } + + input := &NestedModel{ + NestedObject: types.ObjectValueMust( + map[string]attr.Type{ + "field1": types.StringType, + "field2": types.Int64Type, + }, + map[string]attr.Value{ + "field1": types.StringUnknown(), + "field2": types.Int64Value(42), + }, + ), + } + + err := SetModelFieldsToNull(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify the object was modified + attrs := input.NestedObject.Attributes() + if !attrs["field1"].IsNull() { + t.Error("field1 should be null after processing unknown field in nested object") + } + if attrs["field2"].IsNull() { + t.Error("field2 should remain non-null") + } + }) + + // Test list with unknown elements + t.Run("list with unknown and null elements", func(t *testing.T) { + type ListModel struct { + MyList types.List `tfsdk:"my_list"` + } + + input := &ListModel{ + MyList: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("known"), + types.StringUnknown(), + types.StringNull(), + types.StringValue("another_known"), + }, + ), + } + + err := SetModelFieldsToNull(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + elements := input.MyList.Elements() + if len(elements) != 4 { + t.Fatalf("expected 4 elements, got %d", len(elements)) + } + + // Check that unknown was converted to null + if !elements[1].IsNull() { + t.Error("element at index 1 (was unknown) should be null") + } + // Check that null remained null + if !elements[2].IsNull() { + t.Error("element at index 2 (was null) should remain null") + } + // Check known values remain unchanged + if elements[0].IsNull() || elements[3].IsNull() { + t.Error("known elements should not be null") + } + }) + + // Test list of objects with unknown fields + t.Run("list of objects with unknown fields", func(t *testing.T) { + type ListOfObjectsModel struct { + Objects types.List `tfsdk:"objects"` + } + + objectType := types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "name": types.StringType, + "age": types.Int64Type, + }, + } + + input := &ListOfObjectsModel{ + Objects: types.ListValueMust( + objectType, + []attr.Value{ + types.ObjectValueMust( + objectType.AttrTypes, + map[string]attr.Value{ + "name": types.StringValue("Alice"), + "age": types.Int64Unknown(), + }, + ), + types.ObjectValueMust( + objectType.AttrTypes, + map[string]attr.Value{ + "name": types.StringUnknown(), + "age": types.Int64Value(30), + }, + ), + }, + ), + } + + err := SetModelFieldsToNull(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + elements := input.Objects.Elements() + if len(elements) != 2 { + t.Fatalf("expected 2 elements, got %d", len(elements)) + } + + // Check first object - age should be null + obj1 := elements[0].(types.Object) + if !obj1.Attributes()["age"].IsNull() { + t.Error("first object's age field should be null") + } + if obj1.Attributes()["name"].IsNull() { + t.Error("first object's name field should not be null") + } + + // Check second object - name should be null + obj2 := elements[1].(types.Object) + if !obj2.Attributes()["name"].IsNull() { + t.Error("second object's name field should be null") + } + if obj2.Attributes()["age"].IsNull() { + t.Error("second object's age field should not be null") + } + }) + + // Test deeply nested objects + t.Run("deeply nested objects", func(t *testing.T) { + type DeepModel struct { + Level1 types.Object `tfsdk:"level1"` + } + + level3Type := types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "deep_field": types.StringType, + }, + } + + level2Type := types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "level3": level3Type, + }, + } + + level1Type := types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "level2": level2Type, + }, + } + + input := &DeepModel{ + Level1: types.ObjectValueMust( + level1Type.AttrTypes, + map[string]attr.Value{ + "level2": types.ObjectValueMust( + level2Type.AttrTypes, + map[string]attr.Value{ + "level3": types.ObjectValueMust( + level3Type.AttrTypes, + map[string]attr.Value{ + "deep_field": types.StringUnknown(), + }, + ), + }, + ), + }, + ), + } + + err := SetModelFieldsToNull(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Navigate to the deep field + level2 := input.Level1.Attributes()["level2"].(types.Object) + level3 := level2.Attributes()["level3"].(types.Object) + deepField := level3.Attributes()["deep_field"] + + if !deepField.IsNull() { + t.Error("deep_field should be null after processing") + } + }) + + // Test list of lists (nested lists) + t.Run("list of lists with unknown elements", func(t *testing.T) { + type NestedListModel struct { + OuterList types.List `tfsdk:"outer_list"` + } + + innerListType := types.ListType{ElemType: types.StringType} + + input := &NestedListModel{ + OuterList: types.ListValueMust( + innerListType, + []attr.Value{ + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("a"), + types.StringUnknown(), + }, + ), + types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringUnknown(), + types.StringValue("b"), + }, + ), + }, + ), + } + + err := SetModelFieldsToNull(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + outerElements := input.OuterList.Elements() + + // Check first inner list + innerList1 := outerElements[0].(types.List) + innerElements1 := innerList1.Elements() + if !innerElements1[1].IsNull() { + t.Error("second element of first inner list should be null") + } + + // Check second inner list + innerList2 := outerElements[1].(types.List) + innerElements2 := innerList2.Elements() + if !innerElements2[0].IsNull() { + t.Error("first element of second inner list should be null") + } + }) + + // Test map with object values containing unknown fields + t.Run("map with object values containing unknown fields", func(t *testing.T) { + type MapModel struct { + MyMap types.Map `tfsdk:"my_map"` + } + + objectType := types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "field1": types.StringType, + "field2": types.BoolType, + }, + } + + input := &MapModel{ + MyMap: types.MapValueMust( + objectType, + map[string]attr.Value{ + "key1": types.ObjectValueMust( + objectType.AttrTypes, + map[string]attr.Value{ + "field1": types.StringValue("known"), + "field2": types.BoolUnknown(), + }, + ), + "key2": types.ObjectValueMust( + objectType.AttrTypes, + map[string]attr.Value{ + "field1": types.StringUnknown(), + "field2": types.BoolValue(true), + }, + ), + }, + ), + } + + err := SetModelFieldsToNull(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + elements := input.MyMap.Elements() + + // Check key1 object + obj1 := elements["key1"].(types.Object) + if !obj1.Attributes()["field2"].IsNull() { + t.Error("key1 object's field2 should be null") + } + if obj1.Attributes()["field1"].IsNull() { + t.Error("key1 object's field1 should not be null") + } + + // Check key2 object + obj2 := elements["key2"].(types.Object) + if !obj2.Attributes()["field1"].IsNull() { + t.Error("key2 object's field1 should be null") + } + if obj2.Attributes()["field2"].IsNull() { + t.Error("key2 object's field2 should not be null") + } + }) + + // Test set with unknown elements + t.Run("set with unknown elements", func(t *testing.T) { + type SetModel struct { + MySet types.Set `tfsdk:"my_set"` + } + + input := &SetModel{ + MySet: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("known"), + types.StringUnknown(), + types.StringNull(), + }, + ), + } + + err := SetModelFieldsToNull(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + elements := input.MySet.Elements() + + // Count null elements (should have at least 2: the original null and the converted unknown) + nullCount := 0 + for _, elem := range elements { + if elem.IsNull() { + nullCount++ + } + } + + if nullCount < 2 { + t.Errorf("expected at least 2 null elements, got %d", nullCount) + } + }) + + // Test set of objects with unknown fields + t.Run("set of objects with unknown fields", func(t *testing.T) { + type SetOfObjectsModel struct { + Objects types.Set `tfsdk:"objects"` + } + + objectType := types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "name": types.StringType, + }, + } + + input := &SetOfObjectsModel{ + Objects: types.SetValueMust( + objectType, + []attr.Value{ + types.ObjectValueMust( + objectType.AttrTypes, + map[string]attr.Value{ + "id": types.StringValue("1"), + "name": types.StringUnknown(), + }, + ), + types.ObjectValueMust( + objectType.AttrTypes, + map[string]attr.Value{ + "id": types.StringUnknown(), + "name": types.StringValue("Test"), + }, + ), + }, + ), + } + + err := SetModelFieldsToNull(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + elements := input.Objects.Elements() + if len(elements) != 2 { + t.Fatalf("expected 2 elements, got %d", len(elements)) + } + + // Check that unknown fields within objects were converted to null + for _, elem := range elements { + obj := elem.(types.Object) + attrs := obj.Attributes() + + // At least one field in each object should be null (the unknown one) + if !attrs["name"].IsNull() && !attrs["id"].IsNull() { + t.Error("expected at least one field to be null in each object") + } + } + }) + + // Test map with list values containing objects + t.Run("map with list values containing objects with unknown fields", func(t *testing.T) { + type ComplexMapModel struct { + MyMap types.Map `tfsdk:"my_map"` + } + + objectType := types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "prop": types.StringType, + }, + } + listOfObjectsType := types.ListType{ElemType: objectType} + + input := &ComplexMapModel{ + MyMap: types.MapValueMust( + listOfObjectsType, + map[string]attr.Value{ + "key1": types.ListValueMust( + objectType, + []attr.Value{ + types.ObjectValueMust( + objectType.AttrTypes, + map[string]attr.Value{ + "prop": types.StringUnknown(), + }, + ), + }, + ), + }, + ), + } + + err := SetModelFieldsToNull(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + elements := input.MyMap.Elements() + list := elements["key1"].(types.List) + listElements := list.Elements() + obj := listElements[0].(types.Object) + + if !obj.Attributes()["prop"].IsNull() { + t.Error("prop field should be null after processing") + } + }) + + // Test top-level null object (should remain null) + t.Run("top-level null object", func(t *testing.T) { + type NullObjectModel struct { + MyObject types.Object `tfsdk:"my_object"` + } + + attrTypes := map[string]attr.Type{"field": types.StringType} + input := &NullObjectModel{ + MyObject: types.ObjectNull(attrTypes), + } + + err := SetModelFieldsToNull(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !input.MyObject.IsNull() { + t.Error("top-level null object should remain null") + } + }) + + // Test top-level unknown list (should be converted to null) + t.Run("top-level unknown list", func(t *testing.T) { + type UnknownListModel struct { + MyList types.List `tfsdk:"my_list"` + } + + input := &UnknownListModel{ + MyList: types.ListUnknown(types.StringType), + } + + err := SetModelFieldsToNull(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !input.MyList.IsNull() { + t.Error("top-level unknown list should be converted to null") + } + if input.MyList.IsUnknown() { + t.Error("top-level list should no longer be unknown") + } + }) + + // Test empty list (should remain unchanged) + t.Run("empty list", func(t *testing.T) { + type EmptyListModel struct { + MyList types.List `tfsdk:"my_list"` + } + + input := &EmptyListModel{ + MyList: types.ListValueMust(types.StringType, []attr.Value{}), + } + + err := SetModelFieldsToNull(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if input.MyList.IsNull() { + t.Error("empty list should not become null") + } + if len(input.MyList.Elements()) != 0 { + t.Error("list should remain empty") + } + }) + + // Test object with all null fields + t.Run("object with all null fields", func(t *testing.T) { + type AllNullFieldsModel struct { + MyObject types.Object `tfsdk:"my_object"` + } + + attrTypes := map[string]attr.Type{ + "field1": types.StringType, + "field2": types.Int64Type, + } + + input := &AllNullFieldsModel{ + MyObject: types.ObjectValueMust( + attrTypes, + map[string]attr.Value{ + "field1": types.StringNull(), + "field2": types.Int64Null(), + }, + ), + } + + err := SetModelFieldsToNull(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + attrs := input.MyObject.Attributes() + if !attrs["field1"].IsNull() || !attrs["field2"].IsNull() { + t.Error("all fields should remain null") + } + }) +} + func TestShouldWait(t *testing.T) { tests := []struct { name string From 873f8759816e3fcefcee45ea71ff1d80697c0018 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Thu, 13 Nov 2025 21:46:48 +0100 Subject: [PATCH 18/20] fix linting --- stackit/internal/utils/utils.go | 18 +++++++++--------- stackit/internal/utils/utils_test.go | 20 ++++++++++++++++---- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/stackit/internal/utils/utils.go b/stackit/internal/utils/utils.go index 7d2a3ef26..7dc447202 100644 --- a/stackit/internal/utils/utils.go +++ b/stackit/internal/utils/utils.go @@ -236,14 +236,14 @@ func SetModelFieldsToNull(ctx context.Context, model any) error { // If the field is Unknown or Null at the top level, convert it to Null if isUnknown || isNull { - if err := setFieldToNull(ctx, field, fieldValue, fieldType); err != nil { + if err := setFieldToNull(ctx, field, fieldValue, &fieldType); err != nil { return err } continue } // If the field is Known and not Null, recursively process it - if err := processKnownField(ctx, field, fieldValue, fieldType); err != nil { + if err := processKnownField(ctx, field, fieldValue, &fieldType); err != nil { return err } } @@ -252,7 +252,7 @@ func SetModelFieldsToNull(ctx context.Context, model any) error { } // setFieldToNull sets a field to its appropriate Null value based on type -func setFieldToNull(ctx context.Context, field reflect.Value, fieldValue any, fieldType reflect.StructField) error { +func setFieldToNull(ctx context.Context, field reflect.Value, fieldValue any, fieldType *reflect.StructField) error { switch v := fieldValue.(type) { case basetypes.StringValue: field.Set(reflect.ValueOf(types.StringNull())) @@ -293,7 +293,7 @@ func setFieldToNull(ctx context.Context, field reflect.Value, fieldValue any, fi // processKnownField recursively processes known (non-null, non-unknown) fields // to handle nested structures like objects within lists, maps, etc. -func processKnownField(ctx context.Context, field reflect.Value, fieldValue any, fieldType reflect.StructField) error { +func processKnownField(ctx context.Context, field reflect.Value, fieldValue any, fieldType *reflect.StructField) error { switch v := fieldValue.(type) { case basetypes.ObjectValue: // Recursively process object fields @@ -318,7 +318,7 @@ func processKnownField(ctx context.Context, field reflect.Value, fieldValue any, } // processObjectValue recursively processes fields within an ObjectValue -func processObjectValue(ctx context.Context, field reflect.Value, objValue basetypes.ObjectValue, fieldType reflect.StructField) error { +func processObjectValue(ctx context.Context, field reflect.Value, objValue basetypes.ObjectValue, fieldType *reflect.StructField) error { attrs := objValue.Attributes() attrTypes := objValue.AttributeTypes(ctx) modified := false @@ -381,7 +381,7 @@ func processObjectValue(ctx context.Context, field reflect.Value, objValue baset } // processListValue recursively processes elements within a ListValue -func processListValue(ctx context.Context, field reflect.Value, listValue basetypes.ListValue, fieldType reflect.StructField) error { +func processListValue(ctx context.Context, field reflect.Value, listValue basetypes.ListValue, fieldType *reflect.StructField) error { elements := listValue.Elements() if len(elements) == 0 { return nil @@ -447,7 +447,7 @@ func processListValue(ctx context.Context, field reflect.Value, listValue basety } // processSetValue recursively processes elements within a SetValue -func processSetValue(ctx context.Context, field reflect.Value, setValue basetypes.SetValue, fieldType reflect.StructField) error { +func processSetValue(ctx context.Context, field reflect.Value, setValue basetypes.SetValue, fieldType *reflect.StructField) error { elements := setValue.Elements() if len(elements) == 0 { return nil @@ -510,7 +510,7 @@ func processSetValue(ctx context.Context, field reflect.Value, setValue basetype } // processMapValue recursively processes values within a MapValue -func processMapValue(ctx context.Context, field reflect.Value, mapValue basetypes.MapValue, fieldType reflect.StructField) error { +func processMapValue(ctx context.Context, field reflect.Value, mapValue basetypes.MapValue, fieldType *reflect.StructField) error { elements := mapValue.Elements() if len(elements) == 0 { return nil @@ -705,7 +705,7 @@ func processAttributeValueWithFlag(ctx context.Context, attrVal attr.Value, attr } // createNullValue creates a null value of the appropriate type -func createNullValue(ctx context.Context, val attr.Value, attrType attr.Type) attr.Value { +func createNullValue(_ context.Context, val attr.Value, attrType attr.Type) attr.Value { switch val.(type) { case basetypes.StringValue: return types.StringNull() diff --git a/stackit/internal/utils/utils_test.go b/stackit/internal/utils/utils_test.go index 56df482ca..dd8789694 100644 --- a/stackit/internal/utils/utils_test.go +++ b/stackit/internal/utils/utils_test.go @@ -938,7 +938,10 @@ func TestSetModelFieldsToNull_ComplexStructures(t *testing.T) { } // Check first object - age should be null - obj1 := elements[0].(types.Object) + obj1, ok := elements[0].(types.Object) + if !ok { + t.Fatalf("expected element 0 to be types.Object, got %T", elements[0]) + } if !obj1.Attributes()["age"].IsNull() { t.Error("first object's age field should be null") } @@ -947,7 +950,10 @@ func TestSetModelFieldsToNull_ComplexStructures(t *testing.T) { } // Check second object - name should be null - obj2 := elements[1].(types.Object) + obj2, ok := elements[1].(types.Object) + if !ok { + t.Fatalf("expected element 1 to be types.Object, got %T", elements[1]) + } if !obj2.Attributes()["name"].IsNull() { t.Error("second object's name field should be null") } @@ -1005,8 +1011,14 @@ func TestSetModelFieldsToNull_ComplexStructures(t *testing.T) { } // Navigate to the deep field - level2 := input.Level1.Attributes()["level2"].(types.Object) - level3 := level2.Attributes()["level3"].(types.Object) + level2, ok := input.Level1.Attributes()["level2"].(types.Object) + if !ok { + t.Fatalf("expected level2 to be types.Object, got %T", input.Level1.Attributes()["level2"]) + } + level3, ok := level2.Attributes()["level3"].(types.Object) + if !ok { + t.Fatalf("expected level3 to be types.Object, got %T", level2.Attributes()["level3"]) + } deepField := level3.Attributes()["deep_field"] if !deepField.IsNull() { From 6e89bf96b46cf64ee45998f99dbb1ac06a498c23 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Thu, 13 Nov 2025 21:54:58 +0100 Subject: [PATCH 19/20] fix linting --- stackit/internal/utils/utils_test.go | 35 ++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/stackit/internal/utils/utils_test.go b/stackit/internal/utils/utils_test.go index dd8789694..e5569dd81 100644 --- a/stackit/internal/utils/utils_test.go +++ b/stackit/internal/utils/utils_test.go @@ -1064,14 +1064,20 @@ func TestSetModelFieldsToNull_ComplexStructures(t *testing.T) { outerElements := input.OuterList.Elements() // Check first inner list - innerList1 := outerElements[0].(types.List) + innerList1, ok := outerElements[0].(types.List) + if !ok { + t.Fatalf("expected outerElements[0] to be types.List, got %T", outerElements[0]) + } innerElements1 := innerList1.Elements() if !innerElements1[1].IsNull() { t.Error("second element of first inner list should be null") } // Check second inner list - innerList2 := outerElements[1].(types.List) + innerList2, ok := outerElements[1].(types.List) + if !ok { + t.Fatalf("expected outerElements[1] to be types.List, got %T", outerElements[1]) + } innerElements2 := innerList2.Elements() if !innerElements2[0].IsNull() { t.Error("first element of second inner list should be null") @@ -1121,7 +1127,10 @@ func TestSetModelFieldsToNull_ComplexStructures(t *testing.T) { elements := input.MyMap.Elements() // Check key1 object - obj1 := elements["key1"].(types.Object) + obj1, ok := elements["key1"].(types.Object) + if !ok { + t.Fatalf("expected elements[\"key1\"] to be types.Object, got %T", elements["key1"]) + } if !obj1.Attributes()["field2"].IsNull() { t.Error("key1 object's field2 should be null") } @@ -1130,7 +1139,10 @@ func TestSetModelFieldsToNull_ComplexStructures(t *testing.T) { } // Check key2 object - obj2 := elements["key2"].(types.Object) + obj2, ok := elements["key2"].(types.Object) + if !ok { + t.Fatalf("expected elements[\"key2\"] to be types.Object, got %T", elements["key2"]) + } if !obj2.Attributes()["field1"].IsNull() { t.Error("key2 object's field1 should be null") } @@ -1223,7 +1235,10 @@ func TestSetModelFieldsToNull_ComplexStructures(t *testing.T) { // Check that unknown fields within objects were converted to null for _, elem := range elements { - obj := elem.(types.Object) + obj, ok := elem.(types.Object) + if !ok { + t.Fatalf("expected element to be types.Object, got %T", elem) + } attrs := obj.Attributes() // At least one field in each object should be null (the unknown one) @@ -1271,9 +1286,15 @@ func TestSetModelFieldsToNull_ComplexStructures(t *testing.T) { } elements := input.MyMap.Elements() - list := elements["key1"].(types.List) + list, ok := elements["key1"].(types.List) + if !ok { + t.Fatalf("expected elements[\"key1\"] to be types.List, got %T", elements["key1"]) + } listElements := list.Elements() - obj := listElements[0].(types.Object) + obj, ok := listElements[0].(types.Object) + if !ok { + t.Fatalf("expected listElements[0] to be types.Object, got %T", listElements[0]) + } if !obj.Attributes()["prop"].IsNull() { t.Error("prop field should be null after processing") From b769ba1b437ad011d6fa781d345ee81173f82d57 Mon Sep 17 00:00:00 2001 From: Patrick Koss Date: Fri, 14 Nov 2025 23:38:48 +0100 Subject: [PATCH 20/20] add dns wait warn log for tf idempotency --- stackit/internal/services/dns/recordset/resource.go | 6 +++--- stackit/internal/services/dns/zone/resource.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/stackit/internal/services/dns/recordset/resource.go b/stackit/internal/services/dns/recordset/resource.go index 5989f4737..57a3d0578 100644 --- a/stackit/internal/services/dns/recordset/resource.go +++ b/stackit/internal/services/dns/recordset/resource.go @@ -245,7 +245,7 @@ func (r *recordSetResource) Create(ctx context.Context, req resource.CreateReque waitResp, err := wait.CreateRecordSetWaitHandler(ctx, r.client, projectId, zoneId, *recordSetResp.Rrset.Id).WaitWithContext(ctx) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Instance creation waiting: %v", err)) + tflog.Warn(ctx, fmt.Sprintf("Record set creation waiting failed: %v. The record set creation was triggered but waiting for completion was interrupted. The record set may still be creating.", err)) return } @@ -348,7 +348,7 @@ func (r *recordSetResource) Update(ctx context.Context, req resource.UpdateReque waitResp, err := wait.PartialUpdateRecordSetWaitHandler(ctx, r.client, projectId, zoneId, recordSetId).WaitWithContext(ctx) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating record set", fmt.Sprintf("Instance update waiting: %v", err)) + tflog.Warn(ctx, fmt.Sprintf("Record set update waiting failed: %v. The record set update was triggered but waiting for completion was interrupted. The record set may still be updating.", err)) return } @@ -403,7 +403,7 @@ func (r *recordSetResource) Delete(ctx context.Context, req resource.DeleteReque _, err = wait.DeleteRecordSetWaitHandler(ctx, r.client, projectId, zoneId, recordSetId).WaitWithContext(ctx) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting record set", fmt.Sprintf("Instance deletion waiting: %v", err)) + tflog.Warn(ctx, fmt.Sprintf("Record set deletion waiting failed: %v. The record set deletion was triggered but waiting for completion was interrupted. The record set may still be deleting.", err)) return } tflog.Info(ctx, "DNS record set deleted") diff --git a/stackit/internal/services/dns/zone/resource.go b/stackit/internal/services/dns/zone/resource.go index e97163cd1..24f8357f4 100644 --- a/stackit/internal/services/dns/zone/resource.go +++ b/stackit/internal/services/dns/zone/resource.go @@ -327,7 +327,7 @@ func (r *zoneResource) Create(ctx context.Context, req resource.CreateRequest, r waitResp, err := wait.CreateZoneWaitHandler(ctx, r.client, projectId, zoneId).WaitWithContext(ctx) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating zone", fmt.Sprintf("Zone creation waiting: %v", err)) + tflog.Warn(ctx, fmt.Sprintf("Zone creation waiting failed: %v. The zone creation was triggered but waiting for completion was interrupted. The zone may still be creating.", err)) return } @@ -424,7 +424,7 @@ func (r *zoneResource) Update(ctx context.Context, req resource.UpdateRequest, r waitResp, err := wait.PartialUpdateZoneWaitHandler(ctx, r.client, projectId, zoneId).WaitWithContext(ctx) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", fmt.Sprintf("Zone update waiting: %v", err)) + tflog.Warn(ctx, fmt.Sprintf("Zone update waiting failed: %v. The zone update was triggered but waiting for completion was interrupted. The zone may still be updating.", err)) return } @@ -478,7 +478,7 @@ func (r *zoneResource) Delete(ctx context.Context, req resource.DeleteRequest, r _, err = wait.DeleteZoneWaitHandler(ctx, r.client, projectId, zoneId).WaitWithContext(ctx) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting zone", fmt.Sprintf("Zone deletion waiting: %v", err)) + tflog.Warn(ctx, fmt.Sprintf("Zone deletion waiting failed: %v. The zone deletion was triggered but waiting for completion was interrupted. The zone may still be deleting.", err)) return }