From 21e24c461c4ad9638de22ac1489e74ca0b47ff0b Mon Sep 17 00:00:00 2001 From: Witold Duranek Date: Thu, 3 Sep 2026 08:24:15 +0200 Subject: [PATCH] feat: add netdata_notification_silencing_rule --- docs/resources/notification_silencing_rule.md | 72 +++ examples/complete/main.tf | 13 + .../import.sh | 3 + .../resource.tf | 12 + go.mod | 1 + go.sum | 2 + internal/client/client.go | 2 + internal/client/models.go | 21 + .../client/notification_silencing_rule.go | 133 ++++ internal/provider/node_room_member.go | 6 + .../provider/notification_silencing_rule.go | 587 ++++++++++++++++++ ...tification_silencing_rule_resource_test.go | 224 +++++++ internal/provider/notifications.go | 6 + internal/provider/provider.go | 1 + internal/provider/room_data_source.go | 7 + internal/provider/room_member_resource.go | 10 + internal/provider/room_resource.go | 4 + internal/provider/space_data_source.go | 4 + internal/provider/space_member_resource.go | 3 + internal/provider/validators.go | 156 +++++ 20 files changed, 1267 insertions(+) create mode 100644 docs/resources/notification_silencing_rule.md create mode 100755 examples/resources/netdata_notification_silencing_rule/import.sh create mode 100644 examples/resources/netdata_notification_silencing_rule/resource.tf create mode 100644 internal/client/notification_silencing_rule.go create mode 100644 internal/provider/notification_silencing_rule.go create mode 100644 internal/provider/notification_silencing_rule_resource_test.go create mode 100644 internal/provider/validators.go diff --git a/docs/resources/notification_silencing_rule.md b/docs/resources/notification_silencing_rule.md new file mode 100644 index 0000000..c414adb --- /dev/null +++ b/docs/resources/notification_silencing_rule.md @@ -0,0 +1,72 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "netdata_notification_silencing_rule Resource - terraform-provider-netdata" +subcategory: "" +description: |- + Provides a Netdata Cloud Notification Silencing Rule resource. Use this resource to manage notification silencing rules in Netdata Cloud. + A notification silencing rule allows you to silence notifications for specific alerts, nodes, rooms, or spaces based on various criteria. +--- + +# netdata_notification_silencing_rule (Resource) + +Provides a Netdata Cloud Notification Silencing Rule resource. Use this resource to manage notification silencing rules in Netdata Cloud. +A notification silencing rule allows you to silence notifications for specific alerts, nodes, rooms, or spaces based on various criteria. + +## Example Usage + +```terraform +resource "netdata_notification_silencing_rule" "test" { + space_id = "" + name = "testing" + starts_at = "2025-09-01T00:00:00Z" + lasts_until = "2025-12-31T23:59:59Z" + notification_options = ["CRITICAL", "WARNING"] + alert_names = ["disk.space"] + alert_contexts = ["disk_space_usage"] + timezone = "America/New_York" + rrule = "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=5;BYDAY=MO" + delete_on_expiry = false +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the silencing rule. +- `space_id` (String) The ID of the space. +- `starts_at` (String) The start time of the silencing rule in RFC3339 format. Must be expressed in UTC, e.g. "2026-09-01T00:00:00Z". A value carrying a non-UTC offset such as "+02:00" is rejected at plan time, because the API stores and returns timestamps in UTC. Use `timezone` to control the schedule's local time. + +### Optional + +- `alert_contexts` (List of String) List of alert contexts to silence. When empty it applies to all alert contexts. Example ["disk_space_usage"]. +- `alert_instances` (List of String) List of alert instances to silence. When empty it applies to all alert instances. Example ["disk_space./"]. +- `alert_names` (List of String) List of alert names to silence. When empty it applies to all alert names. Example ["disk.space"]. +- `alert_roles` (List of String) List of alert roles to silence. When empty it applies to all alert roles. Example ["webmaster"]. +- `delete_on_expiry` (Boolean) Whether to delete the silencing rule when it expires. If enabled, then it requires setting lasts_until. It is not compatible with an RRule.. then Defaults to false. +- `disabled` (Boolean) Whether the silencing rule is disabled. Defaults to false. +- `host_labels` (Map of String) Host labels to filter nodes the silencing rule applies to. When empty it applies to any label. Example { os = "linux" }. +- `integration_ids` (List of String) List of integration (notification methods) IDs to apply the silencing rule to. When empty it applies to all integrations in the space. +- `lasts_until` (String) The end time of the silencing rule in RFC3339 format. Must be expressed in UTC, e.g. "2026-12-31T23:59:59Z". A value carrying a non-UTC offset such as "+02:00" is rejected at plan time, because the API stores and returns timestamps in UTC. If not set, the rule does not expire. +- `node_ids` (List of String) List of node IDs to apply the silencing rule to. When empty it applies to all nodes in the space. +- `notification_options` (List of String) List of notification options to silence. Valid values: CRITICAL, WARNING, CLEAR, REACHABLE, UNREACHABLE. +- `room_ids` (List of String) List of room IDs to apply the silencing rule to. When empty it applies to all rooms in the space. +- `rrule` (String) The recurrence rule (RRULE) for the silencing rule. The format orignates from the iCalendar specification (RFC 5545). Must not include a DTSTART line - the recurrence start is taken from starts_at. Example: "RRULE:FREQ=MONTHLY;INTERVAL=1;COUNT=10;BYMONTHDAY=1". +- `timezone` (String) The timezone for the silencing rule schedule. Must be a valid IANA timezone (e.g. "America/New_York", "UTC"). Defaults to "UTC". + +### Read-Only + +- `id` (String) The ID of the silencing rule. + +## Import + +Import is supported using the following syntax: + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +```shell +#!/bin/sh + +terraform import netdata_notification_silencing_rule.test space_id,notification_silencing_rule_id +``` diff --git a/examples/complete/main.tf b/examples/complete/main.tf index 2e9eddf..2a63c1d 100644 --- a/examples/complete/main.tf +++ b/examples/complete/main.tf @@ -117,6 +117,19 @@ resource "netdata_notification_pagerduty_channel" "test" { integration_key = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" } +resource "netdata_notification_silencing_rule" "test" { + space_id = netdata_space.test.id + name = "Testing Rule" + starts_at = "2026-09-01T00:00:00Z" + lasts_until = "2030-12-31T23:59:59Z" + notification_options = ["CRITICAL", "WARNING"] + alert_names = ["disk.space"] + alert_contexts = ["disk_space_usage"] + timezone = "America/New_York" + rrule = "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=5;BYDAY=MO" + delete_on_expiry = false +} + data "netdata_space" "test" { id = netdata_space.test.id } diff --git a/examples/resources/netdata_notification_silencing_rule/import.sh b/examples/resources/netdata_notification_silencing_rule/import.sh new file mode 100755 index 0000000..44cf1d4 --- /dev/null +++ b/examples/resources/netdata_notification_silencing_rule/import.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +terraform import netdata_notification_silencing_rule.test space_id,notification_silencing_rule_id diff --git a/examples/resources/netdata_notification_silencing_rule/resource.tf b/examples/resources/netdata_notification_silencing_rule/resource.tf new file mode 100644 index 0000000..ecb31b2 --- /dev/null +++ b/examples/resources/netdata_notification_silencing_rule/resource.tf @@ -0,0 +1,12 @@ +resource "netdata_notification_silencing_rule" "test" { + space_id = "" + name = "testing" + starts_at = "2025-09-01T00:00:00Z" + lasts_until = "2025-12-31T23:59:59Z" + notification_options = ["CRITICAL", "WARNING"] + alert_names = ["disk.space"] + alert_contexts = ["disk_space_usage"] + timezone = "America/New_York" + rrule = "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=5;BYDAY=MO" + delete_on_expiry = false +} diff --git a/go.mod b/go.mod index 5278aeb..129f996 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-exec v0.25.1 // indirect github.com/hashicorp/terraform-json v0.27.3-0.20260213134036-298b8f6b673a // indirect + github.com/hashicorp/terraform-plugin-framework-timetypes v0.5.0 // indirect github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 // indirect github.com/hashicorp/terraform-registry-address v0.4.0 // indirect github.com/hashicorp/terraform-svchost v0.2.1 // indirect diff --git a/go.sum b/go.sum index de3ac6e..958a742 100644 --- a/go.sum +++ b/go.sum @@ -110,6 +110,8 @@ github.com/hashicorp/terraform-plugin-docs v0.25.0 h1:qHs1V257NxVe8tv6HS4UQfNqja github.com/hashicorp/terraform-plugin-docs v0.25.0/go.mod h1:MQggCmY8zgP7R7E/cC0b0cmTvA9hSj3ZKyrrsDjRbLo= github.com/hashicorp/terraform-plugin-framework v1.19.0 h1:q0bwyhxAOR3vfdgbk9iplv3MlTv/dhBHTXjQOtQDoBA= github.com/hashicorp/terraform-plugin-framework v1.19.0/go.mod h1:YRXOBu0jvs7xp4AThBbX4mAzYaMJ1JgtFH//oGKxwLc= +github.com/hashicorp/terraform-plugin-framework-timetypes v0.5.0 h1:v3DapR8gsp3EM8fKMh6up9cJUFQ2iRaFsYLP8UJnCco= +github.com/hashicorp/terraform-plugin-framework-timetypes v0.5.0/go.mod h1:c3PnGE9pHBDfdEVG9t1S1C9ia5LW+gkFR0CygXlM8ak= github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 h1:Zz3iGgzxe/1XBkooZCewS0nJAaCFPFPHdNJd8FgE4Ow= github.com/hashicorp/terraform-plugin-framework-validators v0.19.0/go.mod h1:GBKTNGbGVJohU03dZ7U8wHqc2zYnMUawgCN+gC0itLc= github.com/hashicorp/terraform-plugin-go v0.31.0 h1:0Fz2r9DQ+kNNl6bx8HRxFd1TfMKUvnrOtvJPmp3Z0q8= diff --git a/internal/client/client.go b/internal/client/client.go index d668f1c..96dc274 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -18,6 +18,8 @@ var ( ErrNodeID = errors.New("nodeID is required") ErrNodeMembershipIDRequired = errors.New("nodeMembershipID is required") ErrNodeMembershipActionRequired = errors.New("nodeMembershipAction is required") + ErrSilencingRuleIDRequired = errors.New("silencingRuleID is required") + ErrInvalidSilencingRuleID = errors.New("silencingRuleID is invalid, must be a valid UUID") ) type Client struct { diff --git a/internal/client/models.go b/internal/client/models.go index 6d18871..fac3269 100644 --- a/internal/client/models.go +++ b/internal/client/models.go @@ -2,6 +2,7 @@ package client import ( "encoding/json" + "time" "github.com/google/uuid" ) @@ -102,3 +103,23 @@ type NodeMembershipClause struct { Value string `json:"value"` Negate bool `json:"negate"` } + +type SilencingRule struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + RoomIDs []uuid.UUID `json:"room_ids,omitempty"` + NodeIDs []uuid.UUID `json:"node_ids,omitempty"` + HostLabels map[string]string `json:"host_labels,omitempty"` + AlertNames []string `json:"alert_names,omitempty"` + AlertContexts []string `json:"alert_contexts,omitempty"` + AlertInstances []string `json:"alert_instances,omitempty"` + AlertRoles []string `json:"alert_roles,omitempty"` + NotificationOptions []string `json:"notification_options,omitempty"` + IntegrationIDs []uuid.UUID `json:"integration_ids,omitempty"` + StartsAt time.Time `json:"starts_at"` + LastsUntil *time.Time `json:"lasts_until,omitempty"` + DeleteOnExpiry bool `json:"delete_on_expiry"` + Disabled bool `json:"disabled"` + RRule *string `json:"rrule,omitempty"` + Timezone *string `json:"timezone,omitempty"` +} diff --git a/internal/client/notification_silencing_rule.go b/internal/client/notification_silencing_rule.go new file mode 100644 index 0000000..80841d9 --- /dev/null +++ b/internal/client/notification_silencing_rule.go @@ -0,0 +1,133 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "github.com/google/uuid" +) + +func (c *Client) CreateSilencingRule(spaceID string, silencingRule SilencingRule) (*SilencingRule, error) { + + if spaceID == "" { + return nil, ErrSpaceIDRequired + } + + silencingRuleJson, err := json.Marshal(silencingRule) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v2/spaces/%s/notifications/silencing/rule", c.HostURL, spaceID), bytes.NewReader(silencingRuleJson)) + if err != nil { + return nil, err + } + + var silencingRuleResponse SilencingRule + + err = c.doRequestUnmarshal(req, &silencingRuleResponse) + if err != nil { + return nil, err + } + + return &silencingRuleResponse, nil + +} + +func (c *Client) GetSilencingRule(silencingRuleID, spaceID string) (*SilencingRule, error) { + + if silencingRuleID == "" { + return nil, ErrSilencingRuleIDRequired + } + + parsedSilencingRuleID, err := uuid.Parse(silencingRuleID) + if err != nil { + return nil, ErrInvalidSilencingRuleID + } + + if spaceID == "" { + return nil, ErrSpaceIDRequired + } + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v2/spaces/%s/notifications/silencing/rules", c.HostURL, spaceID), nil) + if err != nil { + return nil, err + } + + var silencingRules []SilencingRule + + err = c.doRequestUnmarshal(req, &silencingRules) + if err != nil { + return nil, err + } + + for _, rule := range silencingRules { + if rule.ID == parsedSilencingRuleID { + return &rule, nil + } + } + + return nil, ErrNotFound +} + +func (c *Client) DeleteSilencingRule(silencingRuleID, spaceID string) error { + + if silencingRuleID == "" { + return ErrSilencingRuleIDRequired + } + + if spaceID == "" { + return ErrSpaceIDRequired + } + + reqBody, err := json.Marshal([]string{silencingRuleID}) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v2/spaces/%s/notifications/silencing/rules/delete", c.HostURL, spaceID), bytes.NewReader(reqBody)) + if err != nil { + return err + } + + _, err = c.doRequest(req) + if err != nil { + return err + } + + return nil + +} + +func (c *Client) UpdateSilencingRule(silencingRuleID, spaceID string, silencingRule SilencingRule) (*SilencingRule, error) { + + if silencingRuleID == "" { + return nil, ErrSilencingRuleIDRequired + } + + if spaceID == "" { + return nil, ErrSpaceIDRequired + } + + silencingRuleJson, err := json.Marshal(silencingRule) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v2/spaces/%s/notifications/silencing/rule/%s", c.HostURL, spaceID, silencingRuleID), bytes.NewReader(silencingRuleJson)) + if err != nil { + return nil, err + } + + var silencingRuleResponse SilencingRule + + err = c.doRequestUnmarshal(req, &silencingRuleResponse) + if err != nil { + return nil, err + } + + return &silencingRuleResponse, nil + +} diff --git a/internal/provider/node_room_member.go b/internal/provider/node_room_member.go index c4cb6dd..74bbbbe 100644 --- a/internal/provider/node_room_member.go +++ b/internal/provider/node_room_member.go @@ -71,6 +71,9 @@ There are two options to add nodes to the room: PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, + Validators: []validator.String{ + UUID(), + }, }, "space_id": schema.StringAttribute{ Description: "Space ID of the member.", @@ -78,6 +81,9 @@ There are two options to add nodes to the room: PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, + Validators: []validator.String{ + UUID(), + }, }, "node_names": schema.ListAttribute{ Description: "List of node names to add to the room. At least one node name is required.", diff --git a/internal/provider/notification_silencing_rule.go b/internal/provider/notification_silencing_rule.go new file mode 100644 index 0000000..37c138e --- /dev/null +++ b/internal/provider/notification_silencing_rule.go @@ -0,0 +1,587 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/netdata/terraform-provider-netdata/internal/client" +) + +var ( + _ resource.Resource = ¬ificationSilencingRuleResource{} + _ resource.ResourceWithConfigure = ¬ificationSilencingRuleResource{} +) + +func NewNotificationSilencingRule() resource.Resource { + return ¬ificationSilencingRuleResource{} +} + +type notificationSilencingRuleResource struct { + client *client.Client +} + +type notificationSilencingRuleResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + SpaceID types.String `tfsdk:"space_id"` + RoomIDs types.List `tfsdk:"room_ids"` + NodeIDs types.List `tfsdk:"node_ids"` + HostLabels types.Map `tfsdk:"host_labels"` + AlertNames types.List `tfsdk:"alert_names"` + AlertContexts types.List `tfsdk:"alert_contexts"` + AlertInstances types.List `tfsdk:"alert_instances"` + AlertRoles types.List `tfsdk:"alert_roles"` + NotificationOptions types.List `tfsdk:"notification_options"` + IntegrationIDs types.List `tfsdk:"integration_ids"` + StartsAt timetypes.RFC3339 `tfsdk:"starts_at"` + LastsUntil timetypes.RFC3339 `tfsdk:"lasts_until"` + DeleteOnExpiry types.Bool `tfsdk:"delete_on_expiry"` + Disabled types.Bool `tfsdk:"disabled"` + RRule types.String `tfsdk:"rrule"` + Timezone types.String `tfsdk:"timezone"` +} + +func (s *notificationSilencingRuleResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_notification_silencing_rule" +} + +func (s *notificationSilencingRuleResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Provides a Netdata Cloud Notification Silencing Rule resource. Use this resource to manage notification silencing rules in Netdata Cloud. +A notification silencing rule allows you to silence notifications for specific alerts, nodes, rooms, or spaces based on various criteria. +`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The ID of the silencing rule.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the silencing rule.", + Required: true, + }, + "space_id": schema.StringAttribute{ + Description: "The ID of the space.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + UUID(), + }, + }, + "room_ids": schema.ListAttribute{ + Description: "List of room IDs to apply the silencing rule to. When empty it applies to all rooms in the space.", + ElementType: types.StringType, + Optional: true, + Computed: true, + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + Validators: []validator.List{ + listvalidator.ValueStringsAre( + UUID(), + ), + }, + }, + "node_ids": schema.ListAttribute{ + Description: "List of node IDs to apply the silencing rule to. When empty it applies to all nodes in the space.", + ElementType: types.StringType, + Optional: true, + Computed: true, + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + Validators: []validator.List{ + listvalidator.ValueStringsAre( + UUID(), + ), + }, + }, + "host_labels": schema.MapAttribute{ + Description: "Host labels to filter nodes the silencing rule applies to. When empty it applies to any label. Example { os = \"linux\" }.", + ElementType: types.StringType, + Optional: true, + Computed: true, + Default: mapdefault.StaticValue(types.MapValueMust(types.StringType, map[string]attr.Value{})), + }, + "alert_names": schema.ListAttribute{ + Description: "List of alert names to silence. When empty it applies to all alert names. Example [\"disk.space\"].", + ElementType: types.StringType, + Optional: true, + Computed: true, + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + "alert_contexts": schema.ListAttribute{ + Description: "List of alert contexts to silence. When empty it applies to all alert contexts. Example [\"disk_space_usage\"].", + ElementType: types.StringType, + Optional: true, + Computed: true, + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + "alert_instances": schema.ListAttribute{ + Description: "List of alert instances to silence. When empty it applies to all alert instances. Example [\"disk_space./\"].", + ElementType: types.StringType, + Optional: true, + Computed: true, + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + "alert_roles": schema.ListAttribute{ + Description: "List of alert roles to silence. When empty it applies to all alert roles. Example [\"webmaster\"].", + ElementType: types.StringType, + Optional: true, + Computed: true, + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + "notification_options": schema.ListAttribute{ + Description: "List of notification options to silence. Valid values: CRITICAL, WARNING, CLEAR, REACHABLE, UNREACHABLE.", + ElementType: types.StringType, + Optional: true, + Computed: true, + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.OneOf("CRITICAL", "WARNING", "CLEAR", "REACHABLE", "UNREACHABLE"), + ), + }, + }, + "integration_ids": schema.ListAttribute{ + Description: "List of integration (notification methods) IDs to apply the silencing rule to. When empty it applies to all integrations in the space.", + ElementType: types.StringType, + Optional: true, + Computed: true, + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + Validators: []validator.List{ + listvalidator.ValueStringsAre( + UUID(), + ), + }, + }, + "starts_at": schema.StringAttribute{ + Description: "The start time of the silencing rule in RFC3339 format. Must be expressed in UTC, e.g. \"2026-09-01T00:00:00Z\". A value carrying a non-UTC offset such as \"+02:00\" is rejected at plan time, because the API stores and returns timestamps in UTC. Use `timezone` to control the schedule's local time.", + CustomType: timetypes.RFC3339Type{}, + Required: true, + Validators: []validator.String{ + UTCTimestamp(), + }, + }, + "lasts_until": schema.StringAttribute{ + Description: "The end time of the silencing rule in RFC3339 format. Must be expressed in UTC, e.g. \"2026-12-31T23:59:59Z\". A value carrying a non-UTC offset such as \"+02:00\" is rejected at plan time, because the API stores and returns timestamps in UTC. If not set, the rule does not expire.", + CustomType: timetypes.RFC3339Type{}, + Optional: true, + Validators: []validator.String{ + UTCTimestamp(), + }, + }, + "delete_on_expiry": schema.BoolAttribute{ + Description: "Whether to delete the silencing rule when it expires. If enabled, then it requires setting lasts_until. It is not compatible with an RRule.. then Defaults to false.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "disabled": schema.BoolAttribute{ + Description: "Whether the silencing rule is disabled. Defaults to false.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "rrule": schema.StringAttribute{ + Description: "The recurrence rule (RRULE) for the silencing rule. The format orignates from the iCalendar specification (RFC 5545). Must not include a DTSTART line - the recurrence start is taken from starts_at. Example: \"RRULE:FREQ=MONTHLY;INTERVAL=1;COUNT=10;BYMONTHDAY=1\".", + Optional: true, + Validators: []validator.String{ + RRuleOnly(), + }, + }, + "timezone": schema.StringAttribute{ + Description: "The timezone for the silencing rule schedule. Must be a valid IANA timezone (e.g. \"America/New_York\", \"UTC\"). Defaults to \"UTC\".", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("UTC"), + Validators: []validator.String{ + IANATimezone(), + }, + }, + }, + } +} + +func (s *notificationSilencingRuleResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + s.client = client +} + +func (s *notificationSilencingRuleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan notificationSilencingRuleResourceModel + + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, fmt.Sprintf("Creating notification silencing rule with name: %s in space: %s", plan.Name.ValueString(), plan.SpaceID.ValueString())) + + silencingRule, _ := planToSilencingRule(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + silencingRuleResponse, err := s.client.CreateSilencingRule(plan.SpaceID.ValueString(), silencingRule) + if err != nil { + resp.Diagnostics.AddError( + "Error Creating Notification Silencing Rule", + "err: "+err.Error(), + ) + return + } + + silencingRuleResponseToplan(ctx, silencingRuleResponse, &plan) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (s *notificationSilencingRuleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state notificationSilencingRuleResourceModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, fmt.Sprintf("Reading notification silencing rule with ID: %s in space: %s", state.ID.ValueString(), state.SpaceID.ValueString())) + + silencingRules, err := s.client.GetSilencingRule(state.ID.ValueString(), state.SpaceID.ValueString()) + if err != nil { + if errors.Is(err, client.ErrNotFound) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Error Getting Notification Silencing Rules", + fmt.Sprintf("Could not read notification silencing rules for space_id: %s err: %v", state.SpaceID.ValueString(), err.Error()), + ) + return + } + + silencingRuleResponseToplan(ctx, silencingRules, &state) + if resp.Diagnostics.HasError() { + return + } + + // SpaceID is not part of the API response — preserve it from state + state.SpaceID = types.StringValue(state.SpaceID.ValueString()) + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) +} + +func (s *notificationSilencingRuleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan notificationSilencingRuleResourceModel + + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, fmt.Sprintf("Updating notification silencing rule with ID: %s in space: %s", plan.ID.ValueString(), plan.SpaceID.ValueString())) + + silencingRule, _ := planToSilencingRule(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + silencingRuleResponse, err := s.client.UpdateSilencingRule(plan.ID.ValueString(), plan.SpaceID.ValueString(), silencingRule) + if err != nil { + resp.Diagnostics.AddError( + "Error Updating Notification Silencing Rule", + "Could Not Update Notification Silencing Rule ID: "+plan.ID.ValueString()+": err: "+err.Error(), + ) + return + } + + silencingRuleResponseToplan(ctx, silencingRuleResponse, &plan) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + +} + +func (s *notificationSilencingRuleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state notificationSilencingRuleResourceModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := s.client.DeleteSilencingRule(state.ID.ValueString(), state.SpaceID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error Deleting Notification Silencing Rule", + "Could Not Delete Notification Silencing Rule ID: "+state.ID.ValueString()+": err: "+err.Error(), + ) + return + } +} + +func (s *notificationSilencingRuleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, ",") + + if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" { + resp.Diagnostics.AddError( + "Unexpected Import Identifier", + fmt.Sprintf("Expected import identifier with format: space_id,silencing_rule_id Got: %q", req.ID), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("space_id"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), idParts[1])...) +} + +// planToSilencingRule converts a notificationSilencingRuleResourceModel to a client.SilencingRule. +func planToSilencingRule(ctx context.Context, plan notificationSilencingRuleResourceModel, diags *diag.Diagnostics) (client.SilencingRule, error) { + rule := client.SilencingRule{ + Name: plan.Name.ValueString(), + DeleteOnExpiry: plan.DeleteOnExpiry.ValueBool(), + Disabled: plan.Disabled.ValueBool(), + } + + // StartsAt (required) + startsAt, d := plan.StartsAt.ValueRFC3339Time() + diags.Append(d...) + if d.HasError() { + return rule, fmt.Errorf("invalid starts_at: %q", plan.StartsAt.ValueString()) + } + rule.StartsAt = startsAt + + // LastsUntil (optional) + if !plan.LastsUntil.IsNull() && !plan.LastsUntil.IsUnknown() { + lastsUntil, d := plan.LastsUntil.ValueRFC3339Time() + diags.Append(d...) + if d.HasError() { + return rule, fmt.Errorf("invalid lasts_until: %q", plan.LastsUntil.ValueString()) + } + rule.LastsUntil = &lastsUntil + } + + // RRule (optional) + if !plan.RRule.IsNull() && !plan.RRule.IsUnknown() { + rrule := plan.RRule.ValueString() + rule.RRule = &rrule + } + + // Timezone (optional) + if !plan.Timezone.IsNull() && !plan.Timezone.IsUnknown() { + tz := plan.Timezone.ValueString() + rule.Timezone = &tz + } + + // RoomIDs (optional, []uuid.UUID) + if !plan.RoomIDs.IsNull() && !plan.RoomIDs.IsUnknown() { + var roomIDStrs []string + diags.Append(plan.RoomIDs.ElementsAs(ctx, &roomIDStrs, false)...) + for _, s := range roomIDStrs { + id, err := uuid.Parse(s) + if err != nil { + diags.AddError("Invalid room_id", fmt.Sprintf("%q is not a valid UUID: %s", s, err)) + return rule, err + } + rule.RoomIDs = append(rule.RoomIDs, id) + } + } + + // NodeIDs (optional, []uuid.UUID) + if !plan.NodeIDs.IsNull() && !plan.NodeIDs.IsUnknown() { + var nodeIDStrs []string + diags.Append(plan.NodeIDs.ElementsAs(ctx, &nodeIDStrs, false)...) + for _, s := range nodeIDStrs { + id, err := uuid.Parse(s) + if err != nil { + diags.AddError("Invalid node_id", fmt.Sprintf("%q is not a valid UUID: %s", s, err)) + return rule, err + } + rule.NodeIDs = append(rule.NodeIDs, id) + } + } + + // IntegrationIDs (optional, []uuid.UUID) + if !plan.IntegrationIDs.IsNull() && !plan.IntegrationIDs.IsUnknown() { + var integrationIDStrs []string + diags.Append(plan.IntegrationIDs.ElementsAs(ctx, &integrationIDStrs, false)...) + for _, s := range integrationIDStrs { + id, err := uuid.Parse(s) + if err != nil { + diags.AddError("Invalid integration_id", fmt.Sprintf("%q is not a valid UUID: %s", s, err)) + return rule, err + } + rule.IntegrationIDs = append(rule.IntegrationIDs, id) + } + } + + // HostLabels (optional, map[string]string) + if !plan.HostLabels.IsNull() && !plan.HostLabels.IsUnknown() { + hostLabels := make(map[string]string, len(plan.HostLabels.Elements())) + diags.Append(plan.HostLabels.ElementsAs(ctx, &hostLabels, false)...) + rule.HostLabels = hostLabels + } + + // String lists (optional) + if !plan.AlertNames.IsNull() && !plan.AlertNames.IsUnknown() { + diags.Append(plan.AlertNames.ElementsAs(ctx, &rule.AlertNames, false)...) + } + if !plan.AlertContexts.IsNull() && !plan.AlertContexts.IsUnknown() { + diags.Append(plan.AlertContexts.ElementsAs(ctx, &rule.AlertContexts, false)...) + } + if !plan.AlertInstances.IsNull() && !plan.AlertInstances.IsUnknown() { + diags.Append(plan.AlertInstances.ElementsAs(ctx, &rule.AlertInstances, false)...) + } + if !plan.AlertRoles.IsNull() && !plan.AlertRoles.IsUnknown() { + diags.Append(plan.AlertRoles.ElementsAs(ctx, &rule.AlertRoles, false)...) + } + + // NotificationOptions (optional, *[]string) + if !plan.NotificationOptions.IsNull() && !plan.NotificationOptions.IsUnknown() { + var opts []string + diags.Append(plan.NotificationOptions.ElementsAs(ctx, &opts, false)...) + rule.NotificationOptions = opts + } + + return rule, nil +} + +// silencingRuleResponseToplan maps a client.SilencingRule response back into the plan model. +func silencingRuleResponseToplan(ctx context.Context, r *client.SilencingRule, plan *notificationSilencingRuleResourceModel) { + plan.ID = types.StringValue(r.ID.String()) + plan.Name = types.StringValue(r.Name) + plan.DeleteOnExpiry = types.BoolValue(r.DeleteOnExpiry) + plan.Disabled = types.BoolValue(r.Disabled) + plan.StartsAt = timetypes.NewRFC3339TimeValue(r.StartsAt) + plan.LastsUntil = timetypes.NewRFC3339TimePointerValue(r.LastsUntil) + + if r.RRule != nil { + if rrule := stripDTSTART(*r.RRule); rrule != "" { + plan.RRule = types.StringValue(rrule) + } else { + plan.RRule = types.StringNull() + } + } else { + plan.RRule = types.StringNull() + } + + if r.Timezone != nil { + plan.Timezone = types.StringValue(*r.Timezone) + } else { + plan.Timezone = types.StringNull() + } + + // UUID lists → []string + roomIDs := make([]string, len(r.RoomIDs)) + for i, id := range r.RoomIDs { + roomIDs[i] = id.String() + } + plan.RoomIDs, _ = types.ListValueFrom(ctx, types.StringType, roomIDs) + + nodeIDs := make([]string, len(r.NodeIDs)) + for i, id := range r.NodeIDs { + nodeIDs[i] = id.String() + } + plan.NodeIDs, _ = types.ListValueFrom(ctx, types.StringType, nodeIDs) + + integrationIDs := make([]string, len(r.IntegrationIDs)) + for i, id := range r.IntegrationIDs { + integrationIDs[i] = id.String() + } + plan.IntegrationIDs, _ = types.ListValueFrom(ctx, types.StringType, integrationIDs) + + // HostLabels + if len(r.HostLabels) > 0 { + plan.HostLabels, _ = types.MapValueFrom(ctx, types.StringType, r.HostLabels) + } else { + plan.HostLabels, _ = types.MapValueFrom(ctx, types.StringType, map[string]string{}) + } + + // String lists + plan.AlertNames = stringListValue(ctx, r.AlertNames) + plan.AlertContexts = stringListValue(ctx, r.AlertContexts) + plan.AlertInstances = stringListValue(ctx, r.AlertInstances) + plan.AlertRoles = stringListValue(ctx, r.AlertRoles) + plan.NotificationOptions = stringListValue(ctx, r.NotificationOptions) +} + +// stringListValue converts a []string into a types.List, mapping a nil slice to +// an empty list instead of null. The API omits empty collections from its +// response, and returning null for an attribute the plan set to [] makes +// Terraform reject the applied value as inconsistent with the plan. +func stringListValue(ctx context.Context, values []string) types.List { + if values == nil { + values = []string{} + } + list, _ := types.ListValueFrom(ctx, types.StringType, values) + return list +} + +// stripDTSTART removes the leading DTSTART line(s) the API prepends to an RRULE. +// The configuration only ever carries the recurrence rule itself (see RRuleOnly), +// so the schedule start has to be dropped again to keep state consistent with config. +func stripDTSTART(rrule string) string { + for { + trimmed := strings.TrimLeft(rrule, " \t\r\n") + if !strings.HasPrefix(strings.ToUpper(trimmed), "DTSTART") { + return strings.TrimSpace(trimmed) + } + idx := strings.IndexAny(trimmed, "\r\n") + if idx == -1 { + return "" + } + rrule = trimmed[idx+1:] + } +} diff --git a/internal/provider/notification_silencing_rule_resource_test.go b/internal/provider/notification_silencing_rule_resource_test.go new file mode 100644 index 0000000..0ed8b40 --- /dev/null +++ b/internal/provider/notification_silencing_rule_resource_test.go @@ -0,0 +1,224 @@ +package provider + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccNotificationSilencingRuleResource(t *testing.T) { + spaceID := getNonCommunitySpaceIDEnv() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []resource.TestStep{ + // Create with minimal config + { + Config: fmt.Sprintf(` + resource "netdata_notification_silencing_rule" "test" { + space_id = "%s" + name = "testAcc-minimal" + starts_at = "2026-09-01T00:00:00Z" + } + `, spaceID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("netdata_notification_silencing_rule.test", "id"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "name", "testAcc-minimal"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "space_id", spaceID), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "starts_at", "2026-09-01T00:00:00Z"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "disabled", "false"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "delete_on_expiry", "false"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "timezone", "UTC"), + ), + }, + // Update name and add optional fields + { + Config: fmt.Sprintf(` + resource "netdata_notification_silencing_rule" "test" { + space_id = "%s" + name = "testAcc-updated" + starts_at = "2026-09-01T00:00:00Z" + lasts_until = "2026-12-31T23:59:59Z" + disabled = true + } + `, spaceID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("netdata_notification_silencing_rule.test", "id"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "name", "testAcc-updated"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "starts_at", "2026-09-01T00:00:00Z"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "lasts_until", "2026-12-31T23:59:59Z"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "disabled", "true"), + ), + }, + // Import + { + ResourceName: "netdata_notification_silencing_rule.test", + ImportState: true, + ImportStateIdFunc: func(state *terraform.State) (string, error) { + rs, ok := state.RootModule().Resources["netdata_notification_silencing_rule.test"] + if !ok { + return "", fmt.Errorf("resource not found in state") + } + return fmt.Sprintf("%s,%s", rs.Primary.Attributes["space_id"], rs.Primary.ID), nil + }, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccNotificationSilencingRuleResource_WithNotificationOptions(t *testing.T) { + spaceID := getNonCommunitySpaceIDEnv() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "netdata_notification_silencing_rule" "test" { + space_id = "%s" + name = "testAcc-options" + starts_at = "2026-09-01T00:00:00Z" + lasts_until = "2026-12-31T23:59:59Z" + notification_options = ["CRITICAL", "WARNING"] + alert_names = ["disk.space"] + alert_contexts = ["disk_space_usage"] + timezone = "America/New_York" + delete_on_expiry = true + } + `, spaceID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("netdata_notification_silencing_rule.test", "id"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "notification_options.0", "CRITICAL"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "notification_options.1", "WARNING"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "alert_names.0", "disk.space"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "alert_contexts.0", "disk_space_usage"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "timezone", "America/New_York"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "delete_on_expiry", "true"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "starts_at", "2026-09-01T00:00:00Z"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "lasts_until", "2026-12-31T23:59:59Z"), + ), + }, + // Update notification options + { + Config: fmt.Sprintf(` + resource "netdata_notification_silencing_rule" "test" { + space_id = "%s" + name = "testAcc-options" + starts_at = "2026-09-01T00:00:00Z" + notification_options = ["CLEAR", "CRITICAL", "WARNING"] + alert_names = ["disk.space", "cpu.usage"] + timezone = "America/New_York" + delete_on_expiry = false + } + `, spaceID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "notification_options.0", "CLEAR"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "notification_options.1", "CRITICAL"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "notification_options.2", "WARNING"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "alert_names.0", "disk.space"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "alert_names.1", "cpu.usage"), + resource.TestCheckNoResourceAttr("netdata_notification_silencing_rule.test", "alert_contexts.0"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "delete_on_expiry", "false"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "starts_at", "2026-09-01T00:00:00Z"), + ), + }, + }, + }) +} + +func TestAccNotificationSilencingRuleResource_WithRRule(t *testing.T) { + spaceID := getNonCommunitySpaceIDEnv() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []resource.TestStep{ + // Create with rrule + { + Config: fmt.Sprintf(` + resource "netdata_notification_silencing_rule" "test" { + space_id = "%s" + name = "testAcc-rrule" + starts_at = "2026-09-01T07:05:00Z" + rrule = "RRULE:FREQ=MONTHLY;INTERVAL=1;COUNT=10;BYMONTHDAY=1" + timezone = "UTC" + } + `, spaceID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("netdata_notification_silencing_rule.test", "id"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "name", "testAcc-rrule"), + // Asserts the exact value, not just presence: the API returns the + // rrule joined with a DTSTART line that stripDTSTART has to remove + // to reproduce the configured string. + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "rrule", "RRULE:FREQ=MONTHLY;INTERVAL=1;COUNT=10;BYMONTHDAY=1"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "timezone", "UTC"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "delete_on_expiry", "false"), + ), + }, + // Update rrule + { + Config: fmt.Sprintf(` + resource "netdata_notification_silencing_rule" "test" { + space_id = "%s" + name = "testAcc-rrule-updated" + starts_at = "2026-09-01T07:05:00Z" + rrule = "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=5;BYDAY=MO" + timezone = "America/New_York" + } + `, spaceID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "name", "testAcc-rrule-updated"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "rrule", "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=5;BYDAY=MO"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "timezone", "America/New_York"), + ), + }, + // Import + { + ResourceName: "netdata_notification_silencing_rule.test", + ImportState: true, + ImportStateIdFunc: func(state *terraform.State) (string, error) { + rs, ok := state.RootModule().Resources["netdata_notification_silencing_rule.test"] + if !ok { + return "", fmt.Errorf("resource not found in state") + } + return fmt.Sprintf("%s,%s", rs.Primary.Attributes["space_id"], rs.Primary.ID), nil + }, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccNotificationSilencingRuleResource_WithHostLabels(t *testing.T) { + spaceID := getNonCommunitySpaceIDEnv() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "netdata_notification_silencing_rule" "test" { + space_id = "%s" + name = "testAcc-labels" + starts_at = "2026-09-01T00:00:00Z" + host_labels = { + os = "linux" + env = "production" + } + } + `, spaceID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("netdata_notification_silencing_rule.test", "id"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "host_labels.os", "linux"), + resource.TestCheckResourceAttr("netdata_notification_silencing_rule.test", "host_labels.env", "production"), + ), + }, + }, + }) +} diff --git a/internal/provider/notifications.go b/internal/provider/notifications.go index 2e0ac38..9eb549f 100644 --- a/internal/provider/notifications.go +++ b/internal/provider/notifications.go @@ -39,12 +39,18 @@ func commonNotificationSchema(notificationType string) schema.Schema { PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, + Validators: []validator.String{ + UUID(), + }, }, "rooms_id": schema.ListAttribute{ Description: fmt.Sprintf("The list of room IDs to set the %s notification. If the rooms list is null, the %s notification will be applied to `All rooms`", notificationType, notificationType), ElementType: types.StringType, Optional: true, Validators: []validator.List{ + listvalidator.ValueStringsAre( + UUID(), + ), listvalidator.SizeAtLeast(1), }, }, diff --git a/internal/provider/provider.go b/internal/provider/provider.go index d5e68ac..2b3de3e 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -96,6 +96,7 @@ func (p *netdataCloudProvider) Resources(ctx context.Context) []func() resource. NewDiscordChannelResource, NewPagerdutyChannelResource, NewNodeRoomMemberResource, + NewNotificationSilencingRule, } } diff --git a/internal/provider/room_data_source.go b/internal/provider/room_data_source.go index dff31a0..512d198 100644 --- a/internal/provider/room_data_source.go +++ b/internal/provider/room_data_source.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/netdata/terraform-provider-netdata/internal/client" ) @@ -42,10 +43,16 @@ func (s *roomDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, r "id": schema.StringAttribute{ Description: "The ID of the room", Required: true, + Validators: []validator.String{ + UUID(), + }, }, "space_id": schema.StringAttribute{ Description: "The ID of the space", Required: true, + Validators: []validator.String{ + UUID(), + }, }, "name": schema.StringAttribute{ Description: "The name of the room", diff --git a/internal/provider/room_member_resource.go b/internal/provider/room_member_resource.go index c467f87..a9a92b6 100644 --- a/internal/provider/room_member_resource.go +++ b/internal/provider/room_member_resource.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/netdata/terraform-provider-netdata/internal/client" @@ -49,6 +50,9 @@ func (s *roomMemberResource) Schema(ctx context.Context, req resource.SchemaRequ PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, + Validators: []validator.String{ + UUID(), + }, }, "space_id": schema.StringAttribute{ Description: "Space ID of the member", @@ -56,6 +60,9 @@ func (s *roomMemberResource) Schema(ctx context.Context, req resource.SchemaRequ PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, + Validators: []validator.String{ + UUID(), + }, }, "space_member_id": schema.StringAttribute{ Description: "The Space Member ID of the space", @@ -63,6 +70,9 @@ func (s *roomMemberResource) Schema(ctx context.Context, req resource.SchemaRequ PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, + Validators: []validator.String{ + UUID(), + }, }, }, } diff --git a/internal/provider/room_resource.go b/internal/provider/room_resource.go index 69ce6eb..e951834 100644 --- a/internal/provider/room_resource.go +++ b/internal/provider/room_resource.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/netdata/terraform-provider-netdata/internal/client" ) @@ -57,6 +58,9 @@ func (s *roomResource) Schema(ctx context.Context, req resource.SchemaRequest, r PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, + Validators: []validator.String{ + UUID(), + }, }, "name": schema.StringAttribute{ Description: "The name of the room", diff --git a/internal/provider/space_data_source.go b/internal/provider/space_data_source.go index a4b4b66..4b674b2 100644 --- a/internal/provider/space_data_source.go +++ b/internal/provider/space_data_source.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/netdata/terraform-provider-netdata/internal/client" @@ -43,6 +44,9 @@ func (s *spaceDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, "id": schema.StringAttribute{ Description: "The ID of the space", Required: true, + Validators: []validator.String{ + UUID(), + }, }, "name": schema.StringAttribute{ Description: "The name of the space", diff --git a/internal/provider/space_member_resource.go b/internal/provider/space_member_resource.go index 7e8d8f3..5a4d18b 100644 --- a/internal/provider/space_member_resource.go +++ b/internal/provider/space_member_resource.go @@ -83,6 +83,9 @@ func (s *spaceMemberResource) Schema(ctx context.Context, req resource.SchemaReq PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, + Validators: []validator.String{ + UUID(), + }, }, }, } diff --git a/internal/provider/validators.go b/internal/provider/validators.go new file mode 100644 index 0000000..e4f94d3 --- /dev/null +++ b/internal/provider/validators.go @@ -0,0 +1,156 @@ +package provider + +import ( + "context" + "fmt" + "strings" + "time" + + // Embeds the IANA timezone database in the provider binary so + // ianaTimezoneValidator's time.LoadLocation does not depend on the host + // having a zoneinfo database (absent on Windows without Go installed, and + // in scratch/distroless/minimal container images). + _ "time/tzdata" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +type ianaTimezoneValidator struct{} + +func (v ianaTimezoneValidator) Description(_ context.Context) string { + return "Value must be a valid IANA timezone (e.g. \"America/New_York\", \"UTC\")." +} + +func (v ianaTimezoneValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v ianaTimezoneValidator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + tz := strings.TrimSpace(req.ConfigValue.ValueString()) + if _, err := time.LoadLocation(tz); err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid IANA Timezone", + fmt.Sprintf("%q is not a valid IANA timezone: %s", tz, err), + ) + } +} + +// IANATimezone returns a validator that checks whether the string is a valid IANA timezone. +func IANATimezone() validator.String { + return ianaTimezoneValidator{} +} + +type rruleOnlyValidator struct{} + +func (v rruleOnlyValidator) Description(_ context.Context) string { + return "Value must not begin with a DTSTART line; use starts_at instead." +} + +func (v rruleOnlyValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v rruleOnlyValidator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + rrule := strings.TrimSpace(req.ConfigValue.ValueString()) + if strings.HasPrefix(strings.ToUpper(rrule), "DTSTART") { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid RRULE", + fmt.Sprintf("%q must not begin with a DTSTART line. The recurrence start is taken from starts_at; provide only the RRULE part, e.g. \"RRULE:FREQ=MONTHLY;INTERVAL=1\".", rrule), + ) + } +} + +// RRuleOnly returns a validator that requires the value to contain only the recurrence +// rule itself, rejecting values that lead with a schedule start (DTSTART) line. +func RRuleOnly() validator.String { + return rruleOnlyValidator{} +} + +type utcTimestampValidator struct{} + +func (v utcTimestampValidator) Description(_ context.Context) string { + return "Value must be an RFC3339 timestamp expressed in UTC (e.g. \"2026-09-01T00:00:00Z\")." +} + +func (v utcTimestampValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v utcTimestampValidator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + value := req.ConfigValue.ValueString() + + t, err := time.Parse(time.RFC3339, value) + if err != nil { + // Unreachable while the attribute uses timetypes.RFC3339Type, which rejects + // malformed values before attribute validators run. Kept so the check still + // reports rather than silently passes if the custom type is ever removed. + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid RFC3339 Timestamp", + fmt.Sprintf("%q is not a valid RFC3339 time: %s", value, err), + ) + return + } + + if _, offset := t.Zone(); offset != 0 { + resp.Diagnostics.AddAttributeError( + req.Path, + "Timestamp Must Be UTC", + fmt.Sprintf("%q uses a non-UTC offset. The API stores and returns timestamps in UTC, so a non-UTC value cannot round-trip. Express it in UTC instead: %q.", value, t.UTC().Format(time.RFC3339)), + ) + } +} + +// UTCTimestamp returns a validator that requires an RFC3339 timestamp with a UTC +// offset, rejecting values the API would normalize to UTC and hand back changed. +func UTCTimestamp() validator.String { + return utcTimestampValidator{} +} + +type uuidValidator struct{} + +func (v uuidValidator) Description(_ context.Context) string { + return "Value must be a valid UUID string (e.g. \"123e4567-e89b-12d3-a456-426614174000\")." +} + +func (v uuidValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v uuidValidator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + value := req.ConfigValue.ValueString() + + err := uuid.Validate(value) + if err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid UUID", + fmt.Sprintf("%q is not a valid UUID: %s", value, err), + ) + return + } +} + +// UUID returns a validator that requires a valid UUID string. +func UUID() validator.String { + return uuidValidator{} +}