From 02f31b879281411dcefbdc41f03ca8dfd4e5e45d Mon Sep 17 00:00:00 2001 From: Howard Hellyer Date: Wed, 6 Nov 2024 10:41:11 +0000 Subject: [PATCH] Add support for the v1beta1 alerts api. - Add new resource types for alerts. - Add testcases. - Add examples. --- examples/alert.tf | 31 +++ go.mod | 2 +- go.sum | 2 + ns1/examples/alert.tf | 11 ++ ns1/provider.go | 1 + ns1/resource_alert.go | 218 +++++++++++++++++++++ ns1/resource_alert_test.go | 391 +++++++++++++++++++++++++++++++++++++ 7 files changed, 655 insertions(+), 1 deletion(-) create mode 100644 examples/alert.tf create mode 100644 ns1/examples/alert.tf create mode 100644 ns1/resource_alert.go create mode 100644 ns1/resource_alert_test.go diff --git a/examples/alert.tf b/examples/alert.tf new file mode 100644 index 00000000..faebc0ed --- /dev/null +++ b/examples/alert.tf @@ -0,0 +1,31 @@ +resource "ns1_alert" "email_on_zone_transfer_failure" { + name = "Zone transfer failed" + type = "zone" + subtype = "transfer_failed" + notification_lists = [ns1_notifylist.email_list.id] + zone_names = [ns1_zone.alert_example_one.zone, ns1_zone.alert_example_two.zone] +} + +# Nofitication list +resource "ns1_notifylist" "email_list" { + name = "email list" + notifications { + type = "email" + config = { + email = "jdoe@example.com" + } + } +} + +# Secondary zones +resource "ns1_zone" "alert_example_one" { + zone = "alert1.example" + primary = "192.0.2.1" + additional_primaries = ["192.0.2.2"] +} + +resource "ns1_zone" "alert_example_two" { + zone = "alert2.example" + primary = "192.0.2.1" + additional_primaries = ["192.0.2.2"] +} \ No newline at end of file diff --git a/go.mod b/go.mod index 41856e92..8983f4f7 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hashicorp/terraform-plugin-sdk/v2 v2.24.1 github.com/stretchr/testify v1.8.1 - gopkg.in/ns1/ns1-go.v2 v2.12.2 + gopkg.in/ns1/ns1-go.v2 v2.12.3-0.20241028132723-c522965c035f ) require ( diff --git a/go.sum b/go.sum index 03026863..2945a0f7 100644 --- a/go.sum +++ b/go.sum @@ -253,6 +253,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ns1/ns1-go.v2 v2.12.2 h1:SPM5BTTMJ1zVBhMMiiPFdF7l6Y3fq5o7bKM7jDqsUfM= gopkg.in/ns1/ns1-go.v2 v2.12.2/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc= +gopkg.in/ns1/ns1-go.v2 v2.12.3-0.20241028132723-c522965c035f h1:C+s7m/FyQZDi7VvkKPbhPD+6ZUZk/ua+1gt5J4BPtBY= +gopkg.in/ns1/ns1-go.v2 v2.12.3-0.20241028132723-c522965c035f/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/ns1/examples/alert.tf b/ns1/examples/alert.tf new file mode 100644 index 00000000..a9a3b86c --- /dev/null +++ b/ns1/examples/alert.tf @@ -0,0 +1,11 @@ +resource "ns1_alert" "example" { + #required + name = "Example Alert" + type = "zone" + subtype = "transfer_failed" + + #optional + notification_lists = [] + zone_names = [] + record_ids = [] +} \ No newline at end of file diff --git a/ns1/provider.go b/ns1/provider.go index 63405db6..715027c1 100644 --- a/ns1/provider.go +++ b/ns1/provider.go @@ -74,6 +74,7 @@ func Provider() *schema.Provider { "ns1_dataset": datasetResource(), "ns1_redirect": redirectConfigResource(), "ns1_redirect_certificate": redirectCertificateResource(), + "ns1_alert": alertResource(), }, ConfigureFunc: ns1Configure, } diff --git a/ns1/resource_alert.go b/ns1/resource_alert.go new file mode 100644 index 00000000..98dc650b --- /dev/null +++ b/ns1/resource_alert.go @@ -0,0 +1,218 @@ +package ns1 + +import ( + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + ns1 "gopkg.in/ns1/ns1-go.v2/rest" + "gopkg.in/ns1/ns1-go.v2/rest/model/alerting" +) + +func alertResource() *schema.Resource { + return &schema.Resource{ + Schema: map[string]*schema.Schema{ + // Required + "name": { + Type: schema.TypeString, + Required: true, + }, + "type": { + Type: schema.TypeString, + Required: true, + // ValidateFunc: validatePath, + // DiffSuppressFunc: caseSensitivityDiffSuppress, + }, + "subtype": { + Type: schema.TypeString, + Required: true, + // ValidateFunc: validateURL, + // DiffSuppressFunc: caseSensitivityDiffSuppress, + }, + // Read-only + "id": { + Type: schema.TypeString, + Computed: true, + }, + "created_at": { + Type: schema.TypeInt, + Computed: true, + }, + "updated_at": { + Type: schema.TypeInt, + Computed: true, + }, + "created_by": { + Type: schema.TypeString, + Computed: true, + }, + "updated_by": { + Type: schema.TypeString, + Computed: true, + }, + // Optional + "notification_lists": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "zone_names": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + ConflictsWith: []string{"record_ids"}, + }, + "record_ids": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + ConflictsWith: []string{"zone_names"}, + }, + }, + Create: AlertConfigCreate, + Read: AlertConfigRead, + Update: AlertConfigUpdate, + Delete: AlertConfigDelete, + Importer: &schema.ResourceImporter{}, + } +} + +func alertToResourceData(d *schema.ResourceData, alert *alerting.Alert) error { + d.SetId(*alert.ID) + d.Set("name", alert.Name) + d.Set("type", alert.Type) + d.Set("subtype", alert.Subtype) + d.Set("created_at", alert.CreatedAt) + d.Set("updated_at", alert.UpdatedAt) + d.Set("created_by", alert.CreatedBy) + d.Set("updated_by", alert.UpdatedBy) + d.Set("notification_lists", alert.NotifierListIds) + d.Set("zone_names", alert.ZoneNames) + d.Set("record_ids", alert.RecordIds) + return nil +} + +func strPtr(str string) *string { + return &str +} + +func int64Ptr(n int) *int64 { + n64 := int64(n) + return &n64 +} + +func resourceDataToAlert(d *schema.ResourceData) (*alerting.Alert, error) { + alert := alerting.Alert{ + ID: strPtr(d.Id()), + } + if v, ok := d.GetOk("name"); ok { + alert.Name = strPtr(v.(string)) + } + if v, ok := d.GetOk("type"); ok { + alert.Type = strPtr(v.(string)) + } + if v, ok := d.GetOk("subtype"); ok { + alert.Subtype = strPtr(v.(string)) + } + if v, ok := d.GetOk("created_at"); ok { + alert.CreatedAt = int64Ptr(v.(int)) + } + if v, ok := d.GetOk("updated_at"); ok { + alert.UpdatedAt = int64Ptr(v.(int)) + } + if v, ok := d.GetOk("created_by"); ok { + alert.CreatedBy = strPtr(v.(string)) + } + if v, ok := d.GetOk("updated_by"); ok { + alert.UpdatedBy = strPtr(v.(string)) + } + if v, ok := d.GetOk("notification_lists"); ok { + listIds := v.(*schema.Set) + alert.NotifierListIds = make([]string, 0, listIds.Len()) + for _, id := range listIds.List() { + alert.NotifierListIds = append(alert.NotifierListIds, id.(string)) + } + } else { + alert.NotifierListIds = []string{} + } + if v, ok := d.GetOk("zone_names"); ok { + zoneNames := v.(*schema.Set) + alert.ZoneNames = make([]string, 0, zoneNames.Len()) + for _, zone := range zoneNames.List() { + alert.ZoneNames = append(alert.ZoneNames, zone.(string)) + } + } else { + alert.ZoneNames = []string{} + } + if v, ok := d.GetOk("record_ids"); ok { + recordIds := v.(*schema.Set) + alert.RecordIds = make([]string, 0, recordIds.Len()) + for _, id := range recordIds.List() { + alert.RecordIds = append(alert.RecordIds, id.(string)) + } + } else { + alert.RecordIds = []string{} + } + return &alert, nil +} + +func AlertConfigCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*ns1.Client) + + var alert *alerting.Alert = nil + alert, err := resourceDataToAlert(d) + if err != nil { + return err + } + + if resp, err := client.Alerts.Create(alert); err != nil { + return ConvertToNs1Error(resp, err) + } + + return alertToResourceData(d, alert) +} + +func AlertConfigRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*ns1.Client) + + alert, resp, err := client.Alerts.Get(d.Id()) + if err != nil { + if err == ns1.ErrAlertMissing { + log.Printf("[DEBUG] NS1 alert (%s) not found", d.Id()) + d.SetId("") + return nil + } + + return ConvertToNs1Error(resp, err) + } + + return alertToResourceData(d, alert) +} + +func AlertConfigUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*ns1.Client) + + alert, err := resourceDataToAlert(d) + if err != nil { + return err + } + + if resp, err := client.Alerts.Update(alert); err != nil { + return ConvertToNs1Error(resp, err) + } + + return alertToResourceData(d, alert) +} + +func AlertConfigDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*ns1.Client) + + resp, err := client.Alerts.Delete(d.Id()) + d.SetId("") + return ConvertToNs1Error(resp, err) +} diff --git a/ns1/resource_alert_test.go b/ns1/resource_alert_test.go new file mode 100644 index 00000000..948f9433 --- /dev/null +++ b/ns1/resource_alert_test.go @@ -0,0 +1,391 @@ +package ns1 + +import ( + "fmt" + "log" + "reflect" + "sort" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + ns1 "gopkg.in/ns1/ns1-go.v2/rest" + "gopkg.in/ns1/ns1-go.v2/rest/model/alerting" + "gopkg.in/ns1/ns1-go.v2/rest/model/monitor" +) + +// Creating basic DNS alert +func TestAccAlert_basic(t *testing.T) { + var ( + alert = alerting.Alert{} + alertName = fmt.Sprintf("terraform-test-alert-%s", acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum)) + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAlertDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAlertBasic(alertName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAlertExists("ns1_alert.it", &alert), + testAccCheckAlertName(&alert, alertName), + // testAccCheckAlertPreference(&alert, alertPreference), + ), + }, + { + ResourceName: "ns1_alert.it", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccAlert_links(t *testing.T) { + var ( + alert = alerting.Alert{} + alertName = fmt.Sprintf("terraform-test-alert-%s", acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum)) + ) + + zoneNames := []string{} + for i := 0; i < 3; i++ { + zoneNames = append(zoneNames, fmt.Sprintf("terraform-test-%s.io", acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum))) + } + + // Support objects + notfierLists := []*monitor.NotifyList{{}, {}, {}} + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAlertDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAlertZones(alertName, zoneNames), + Check: resource.ComposeTestCheckFunc( + testAccCheckAlertExists("ns1_alert.it", &alert), + testAccCheckAlertName(&alert, alertName), + testAccCheckAlertType(&alert, "zone"), + testAccCheckAlertSubtype(&alert, "transfer_failed"), + testAccCheckAlertZoneNames(&alert, zoneNames), + testAccCheckAlertNotifierLists(&alert, []*monitor.NotifyList{}), + ), + }, + { + Config: testAccAlertNotifierLists(alertName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAlertExists("ns1_alert.it", &alert), + testAccCheckNotifyListExists("ns1_notifylist.email_list_0", notfierLists[0]), + testAccCheckNotifyListExists("ns1_notifylist.email_list_1", notfierLists[1]), + testAccCheckNotifyListExists("ns1_notifylist.email_list_2", notfierLists[2]), + testAccCheckAlertName(&alert, alertName), + testAccCheckAlertType(&alert, "zone"), + testAccCheckAlertSubtype(&alert, "transfer_failed"), + testAccCheckAlertZoneNames(&alert, []string{}), + testAccCheckAlertNotifierLists(&alert, notfierLists), + ), + }, + { + ResourceName: "ns1_alert.it", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +// Update DNS alert +func TestAccAlert_update(t *testing.T) { + var ( + alert = alerting.Alert{} + alertName = fmt.Sprintf("terraform-test-alert-%s", acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum)) + + updatedAlert = alerting.Alert{} + updatedAlertName = fmt.Sprintf("terraform-test-alert-%s", acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum)) + zoneName = fmt.Sprintf("terraform-test-%s.io", acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum)) + + // Support objects + nl = monitor.NotifyList{} + // zone = dns.Zone{} + ) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAlertDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAlertBasic(alertName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAlertExists("ns1_alert.it", &alert), + testAccCheckAlertName(&alert, alertName), + testAccCheckAlertType(&alert, "zone"), + testAccCheckAlertSubtype(&alert, "transfer_failed"), + testAccCheckAlertZoneNames(&alert, []string{}), + testAccCheckAlertNotifierLists(&alert, []*monitor.NotifyList{}), + ), + }, + { + Config: testAccAlertUpdated(zoneName, updatedAlertName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAlertExists("ns1_alert.it", &updatedAlert), + // Have to retrieve the notifier list to get random ID. + testAccCheckNotifyListExists("ns1_notifylist.it", &nl), + testAccCheckAlertName(&updatedAlert, updatedAlertName), + testAccCheckAlertType(&updatedAlert, "zone"), + testAccCheckAlertSubtype(&updatedAlert, "transfer_failed"), + testAccCheckAlertZoneNames(&updatedAlert, []string{zoneName}), + testAccCheckAlertNotifierLists(&updatedAlert, []*monitor.NotifyList{&nl}), + ), + }, + { + ResourceName: "ns1_alert.it", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +// Manually deleting DNS Alert +func TestAccAlert_ManualDelete(t *testing.T) { + var ( + alert = alerting.Alert{} + alertName = fmt.Sprintf("terraform-test-alert-%s", acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum)) + ) + // Manual deletion test for DNS Alert + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckPulsarJobDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAlertBasic(alertName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAlertExists("ns1_alert.it", &alert), + ), + }, + // Simulate a manual deletion of the DNS Alert and verify that the plan has a diff. + { + PreConfig: testAccManualDeleteAlert(&alert), + Config: testAccAlertBasic(alertName), + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + // Then re-create and make sure it is there again. + { + Config: testAccAlertBasic(alertName), + Check: testAccCheckAlertExists("ns1_alert.it", &alert), + }, + }, + }) +} + +func testAccAlertBasic(alertName string) string { + return fmt.Sprintf(`resource "ns1_alert" "it" { + name = "%s" + type = "zone" + subtype = "transfer_failed" + notification_lists = [] + zone_names = [] +}`, alertName) +} + +// Updates alert "it" created above. +func testAccAlertUpdated(zoneName, alertName string) string { + return fmt.Sprintf(`resource "ns1_zone" "it" { + zone = "%s" + } + + resource "ns1_notifylist" "it" { + name = "email list" + notifications { + type = "email" + config = { + email = "jdoe@example.com" + } + } + } + + resource "ns1_alert" "it" { + name = "%s" + type = "zone" + subtype = "transfer_failed" + zone_names = ["${ns1_zone.it.zone}"] + notification_lists = ["${ns1_notifylist.it.id}"] +} +`, zoneName, alertName) +} + +func testAccAlertNotifierLists(alertName string) string { + config := "" + listResourceNames := []string{} + for i := range []int{1, 2, 3} { + listName := fmt.Sprintf("terraform-test-list-%s", acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum)) + listResourceNames = append(listResourceNames, fmt.Sprintf("ns1_notifylist.email_list_%d.id", i)) + config += fmt.Sprintf(` +resource "ns1_notifylist" "email_list_%d" { + name = "%s" + notifications { + type = "email" + config = { + email = "jdoe@example.com" + } + } +}`, + i, listName) + } + + config += fmt.Sprintf(` +resource "ns1_alert" "it" { + name = "%s" + type = "zone" + subtype = "transfer_failed" + notification_lists = [%s] + zone_names = [] +}`, + alertName, strings.Join(listResourceNames, ",")) + return config +} + +func testAccAlertZones(alertName string, zoneNames []string) string { + config := "" + + zoneResourceNames := make([]string, 0, len(zoneNames)) + for i := range zoneNames { + zoneResourceNames = append(zoneResourceNames, fmt.Sprintf("ns1_zone.alert_zone_%d.zone", i)) + config += fmt.Sprintf(` +resource "ns1_zone" "alert_zone_%d" { + zone = "%s" + primary = "192.0.2.1" + additional_primaries = ["192.0.2.2"] + additional_ports = [53] +}`, + i, zoneNames[i]) + } + + config += fmt.Sprintf(` +resource "ns1_alert" "it" { + name = "%s" + type = "zone" + subtype = "transfer_failed" + notification_lists = [] + zone_names = [%s] +}`, + alertName, strings.Join(zoneResourceNames, ",")) + return config +} + +func testAccCheckAlertName(alert *alerting.Alert, expected string) resource.TestCheckFunc { + return func(s *terraform.State) error { + if *alert.Name != expected { + return fmt.Errorf("alert.Name: got: %s want: %s", *alert.Name, expected) + } + return nil + } +} + +func testAccCheckAlertType(alert *alerting.Alert, expected string) resource.TestCheckFunc { + return func(s *terraform.State) error { + if *alert.Type != expected { + return fmt.Errorf("alert.Type: got: %s want: %s", *alert.Type, expected) + } + return nil + } +} + +func testAccCheckAlertSubtype(alert *alerting.Alert, expected string) resource.TestCheckFunc { + return func(s *terraform.State) error { + if *alert.Subtype != expected { + return fmt.Errorf("alert.Subtype: got: %s want: %s", *alert.Subtype, expected) + } + return nil + } +} + +func testAccCheckAlertZoneNames(alert *alerting.Alert, expected []string) resource.TestCheckFunc { + return func(s *terraform.State) error { + actualSorted := alert.ZoneNames + expectedSorted := expected + sort.Strings(actualSorted) + sort.Strings(expectedSorted) + if !reflect.DeepEqual(actualSorted, expectedSorted) { + return fmt.Errorf("alert.Zones: got: %v want: %v", actualSorted, expectedSorted) + } + return nil + } +} + +func testAccCheckAlertNotifierLists(alert *alerting.Alert, expectedLists []*monitor.NotifyList) resource.TestCheckFunc { + return func(s *terraform.State) error { + actualSorted := alert.NotifierListIds + sort.Strings(actualSorted) + expected := []string{} + for _, nl := range expectedLists { + expected = append(expected, nl.ID) + } + sort.Strings(expected) + if !reflect.DeepEqual(actualSorted, expected) { + return fmt.Errorf("alert.NotifierListIds: got: %v want: `%v`", actualSorted, expected) + } + return nil + } +} + +func testAccCheckAlertDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*ns1.Client) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "ns1_alert" { + continue + } + + alert, _, err := client.Alerts.Get(rs.Primary.ID) + + if err == nil { + return fmt.Errorf("DNS Alert still exists: %#v: %#v", err, alert) + } + + } + + return nil +} + +func testAccCheckAlertExists(n string, alert *alerting.Alert) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + + if !ok { + return fmt.Errorf("not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no ID is set") + } + + client := testAccProvider.Meta().(*ns1.Client) + + foundAlert, _, err := client.Alerts.Get(rs.Primary.ID) + if err != nil { + return err + } + + *alert = *foundAlert + + return nil + } +} + +func testAccManualDeleteAlert(alert *alerting.Alert) func() { + return func() { + client := testAccProvider.Meta().(*ns1.Client) + _, err := client.Alerts.Delete(*alert.ID) + // Not a big deal if this fails, it will get caught in the test conditions and fail the test. + if err != nil { + log.Printf("failed to delete DNS alert: %v", err) + } + } +}