From f0438e888c7aedccb257eea9831b55b2ef5fe89f Mon Sep 17 00:00:00 2001 From: Patrick Koss <49844980+PatrickKoss@users.noreply.github.com> Date: Fri, 19 Sep 2025 17:30:13 +0200 Subject: [PATCH] feat(observability): continue attribute for instance -> alert config (#993) --- docs/data-sources/observability_instance.md | 1 + docs/resources/observability_instance.md | 1 + .../observability/instance/resource.go | 28 +++++++++++++------ .../observability/instance/resource_test.go | 20 +++++++++++-- .../observability/observability_acc_test.go | 4 +++ .../observability/testdata/resource-max.tf | 2 ++ 6 files changed, 45 insertions(+), 11 deletions(-) diff --git a/docs/data-sources/observability_instance.md b/docs/data-sources/observability_instance.md index 23bfdb5c..c6dab428 100644 --- a/docs/data-sources/observability_instance.md +++ b/docs/data-sources/observability_instance.md @@ -145,6 +145,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 c60c2167..21d22c3c 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.) diff --git a/stackit/internal/services/observability/instance/resource.go b/stackit/internal/services/observability/instance/resource.go index eccad5e4..accf435d 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}, } @@ -130,6 +130,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 +147,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"` @@ -158,7 +160,7 @@ type routeModelNoRoutes struct { RepeatInterval types.String `tfsdk:"repeat_interval"` } -var routeTypes = map[string]attr.Type{ +var mainRouteTypes = map[string]attr.Type{ "group_by": types.ListType{ElemType: types.StringType}, "group_interval": types.StringType, "group_wait": types.StringType, @@ -236,6 +238,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 +261,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 +294,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, @@ -1549,7 +1558,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{ + mockRoute, diags := types.ObjectValue(mainRouteTypes, map[string]attr.Value{ "receiver": types.StringValue("email-me"), "group_by": mockGroupByList, "group_wait": types.StringValue("30s"), @@ -1759,17 +1768,17 @@ 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{ @@ -1781,9 +1790,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 @@ -1822,6 +1831,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 +2092,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, @@ -2160,6 +2171,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 e9229ccb..055fa716 100644 --- a/stackit/internal/services/observability/instance/resource_test.go +++ b/stackit/internal/services/observability/instance/resource_test.go @@ -64,7 +64,7 @@ func fixtureReceiverModel(emailConfigs, opsGenieConfigs, webHooksConfigs basetyp } func fixtureRouteModel() basetypes.ObjectValue { - return types.ObjectValueMust(routeTypes, map[string]attr.Value{ + return types.ObjectValueMust(mainRouteTypes, map[string]attr.Value{ "group_by": types.ListValueMust(types.StringType, []attr.Value{ types.StringValue("label1"), types.StringValue("label2"), @@ -76,6 +76,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"), @@ -96,7 +97,7 @@ func fixtureRouteModel() basetypes.ObjectValue { } func fixtureNullRouteModel() basetypes.ObjectValue { - return types.ObjectValueMust(routeTypes, map[string]attr.Value{ + return types.ObjectValueMust(mainRouteTypes, map[string]attr.Value{ "group_by": types.ListNull(types.StringType), "group_interval": types.StringNull(), "group_wait": types.StringNull(), @@ -174,6 +175,7 @@ func fixtureReceiverPayload(emailConfigs *[]observability.CreateAlertConfigRecei func fixtureRoutePayload() *observability.UpdateAlertConfigsPayloadRoute { return &observability.UpdateAlertConfigsPayloadRoute{ + Continue: nil, GroupBy: utils.Ptr([]string{"label1", "label2"}), GroupInterval: utils.Ptr("1m"), GroupWait: utils.Ptr("1m"), @@ -181,6 +183,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 +252,7 @@ func fixtureWebHooksConfigsResponse() observability.WebHook { func fixtureRouteResponse() *observability.Route { return &observability.Route{ + Continue: nil, GroupBy: utils.Ptr([]string{"label1", "label2"}), GroupInterval: utils.Ptr("1m"), GroupWait: utils.Ptr("1m"), @@ -259,6 +263,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 +292,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, @@ -877,7 +887,7 @@ func TestMapAlertConfigField(t *testing.T) { fixtureWebHooksConfigsModel(), ), }), - "route": types.ObjectNull(routeTypes), + "route": types.ObjectNull(mainRouteTypes), "global": types.ObjectNull(globalConfigurationTypes), }), }, @@ -1497,6 +1507,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 +1525,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 +1535,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 +1554,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, diff --git a/stackit/internal/services/observability/observability_acc_test.go b/stackit/internal/services/observability/observability_acc_test.go index 4ec0a852..27a703be 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"), // credential "credential_description": config.StringVariable("This is a description for the test credential."), // logalertgroup @@ -536,6 +537,7 @@ func TestAccResourceMax(t *testing.T) { 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"])), @@ -702,6 +704,7 @@ func TestAccResourceMax(t *testing.T) { 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"])), @@ -928,6 +931,7 @@ func TestAccResourceMax(t *testing.T) { 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 809004bf..871d4d1f 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 "credential_description" {} @@ -155,6 +156,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 }