diff --git a/docs/resources/machine_learning_alert.md b/docs/resources/machine_learning_alert.md new file mode 100644 index 000000000..2d9963834 --- /dev/null +++ b/docs/resources/machine_learning_alert.md @@ -0,0 +1,44 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "grafana_machine_learning_alert Resource - terraform-provider-grafana" +subcategory: "Machine Learning" +description: |- + A job defines the queries and model parameters for a machine learning task. +--- + +# grafana_machine_learning_alert (Resource) + +A job defines the queries and model parameters for a machine learning task. + + + + +## Schema + +### Required + +- `title` (String) The title of the alert. + +### Optional + +- `annotations` (Map of String) Annotations to add to the alert generated in Grafana. +- `anomaly_condition` (String) The condition for when to consider a point as anomalous. +- `for` (String) How long values must be anomalous before firing an alert. +- `job_id` (String) The forecast this alert belongs to. +- `labels` (Map of String) Labels to add to the alert generated in Grafana. +- `no_data_state` (String) How the alert should be processed when no data is returned by the underlying series +- `outlier_id` (String) The forecast this alert belongs to. +- `threshold` (String) The threshold of points over the window that need to be anomalous to alert. +- `window` (String) How much time to average values over + +### Read-Only + +- `id` (String) The ID of the alert. + +## Import + +Import is supported using the following syntax: + +```shell +terraform import grafana_machine_learning_alert.name "{{ id }}" +``` diff --git a/examples/resources/grafana_machine_learning_alert/import.sh b/examples/resources/grafana_machine_learning_alert/import.sh new file mode 100644 index 000000000..5ed876422 --- /dev/null +++ b/examples/resources/grafana_machine_learning_alert/import.sh @@ -0,0 +1 @@ +terraform import grafana_machine_learning_alert.name "{{ id }}" diff --git a/examples/resources/grafana_machine_learning_alert/job_alert.tf b/examples/resources/grafana_machine_learning_alert/job_alert.tf new file mode 100644 index 000000000..9952d0050 --- /dev/null +++ b/examples/resources/grafana_machine_learning_alert/job_alert.tf @@ -0,0 +1,36 @@ +resource "grafana_data_source" "foo" { + type = "prometheus" + name = "prometheus-ds-test" + uid = "prometheus-ds-test-uid" + url = "https://my-instance.com" + basic_auth_enabled = true + basic_auth_username = "username" + + json_data_encoded = jsonencode({ + httpMethod = "POST" + prometheusType = "Mimir" + prometheusVersion = "2.4.0" + }) + + secure_json_data_encoded = jsonencode({ + basicAuthPassword = "password" + }) +} + +resource "grafana_machine_learning_job" "test_job" { + name = "Test Job" + metric = "tf_test_job" + datasource_type = "prometheus" + datasource_uid = grafana_data_source.foo.uid + query_params = { + expr = "grafanacloud_grafana_instance_active_user_count" + } +} + +resource "grafana_machine_learning_alert" "test_job_alert" { + job_id = grafana_machine_learning_job.test_job.id + title = "Test Alert" + anomaly_condition = "any" + threshold = ">0.8" + window = "15m" +} diff --git a/examples/resources/grafana_machine_learning_alert/outlier_alert.tf b/examples/resources/grafana_machine_learning_alert/outlier_alert.tf new file mode 100644 index 000000000..930af73c8 --- /dev/null +++ b/examples/resources/grafana_machine_learning_alert/outlier_alert.tf @@ -0,0 +1,26 @@ +resource "grafana_machine_learning_outlier_detector" "my_dbscan_outlier_detector" { + name = "My DBSCAN outlier detector" + description = "My DBSCAN Outlier Detector" + + metric = "tf_test_dbscan_job" + datasource_type = "prometheus" + datasource_uid = "AbCd12345" + query_params = { + expr = "grafanacloud_grafana_instance_active_user_count" + } + interval = 300 + + algorithm { + name = "dbscan" + sensitivity = 0.5 + config { + epsilon = 1.0 + } + } +} + +resource "grafana_machine_learning_alert" "test_job_alert" { + outlier_id = grafana_machine_learning_outlier_detector.my_dbscan_outlier_detector.id + title = "Test Alert" + window = "1h" +} diff --git a/go.mod b/go.mod index e3a3fa7b2..0997417f3 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/grafana/amixr-api-go-client v0.0.12 // main branch github.com/grafana/grafana-com-public-clients/go/gcom v0.0.0-20240322153219-42c6a1d2bcab github.com/grafana/grafana-openapi-client-go v0.0.0-20240523010106-657d101fcbd9 - github.com/grafana/machine-learning-go-client v0.7.0 + github.com/grafana/machine-learning-go-client v0.7.1-0.20240709184107-86215ffa4596 github.com/grafana/slo-openapi-client/go v0.0.0-20240626093634-e6741482b090 github.com/grafana/synthetic-monitoring-agent v0.24.3 github.com/grafana/synthetic-monitoring-api-go-client v0.8.0 @@ -30,6 +30,7 @@ require ( github.com/hashicorp/terraform-plugin-go v0.23.0 github.com/hashicorp/terraform-plugin-mux v0.16.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0 + github.com/prometheus/common v0.53.0 github.com/stretchr/testify v1.9.0 github.com/tmccombs/hcl2json v0.6.3 github.com/urfave/cli/v2 v2.27.2 @@ -129,7 +130,6 @@ require ( github.com/posener/complete v1.2.3 // indirect github.com/prometheus/client_golang v1.19.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.53.0 // indirect github.com/prometheus/procfs v0.14.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shopspring/decimal v1.3.1 // indirect diff --git a/go.sum b/go.sum index d8c9d9931..c47293028 100644 --- a/go.sum +++ b/go.sum @@ -134,8 +134,6 @@ github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1 github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/grafana/amixr-api-go-client v0.0.12-0.20240410110211-c9f68db085c4 h1:e7cZfDiNodjQn63be9m8zfnvMEQAMqHVFswjcbdlspk= -github.com/grafana/amixr-api-go-client v0.0.12-0.20240410110211-c9f68db085c4/go.mod h1:N6x26XUrM5zGtK5zL5vNJnAn2JFMxLFPPLTw/6pDkFE= github.com/grafana/amixr-api-go-client v0.0.12 h1:oEHZTBhxoZ35EsfeccZBJGPKhZUVOmdSir3WWnSJMLc= github.com/grafana/amixr-api-go-client v0.0.12/go.mod h1:N6x26XUrM5zGtK5zL5vNJnAn2JFMxLFPPLTw/6pDkFE= github.com/grafana/grafana-com-public-clients/go/gcom v0.0.0-20240322153219-42c6a1d2bcab h1:/5R8NO996/keDkZqKXEkU3/QgFs1wzChKYkakjsBpRk= @@ -144,8 +142,8 @@ github.com/grafana/grafana-openapi-client-go v0.0.0-20240523010106-657d101fcbd9 github.com/grafana/grafana-openapi-client-go v0.0.0-20240523010106-657d101fcbd9/go.mod h1:hiZnMmXc9KXNUlvkV2BKFsiWuIFF/fF4wGgYWEjBitI= github.com/grafana/grafana-plugin-sdk-go v0.235.0 h1:UnZ/iBDvCkfDgwR94opi8trAWJXv4V8Qr1ocJKRRmqA= github.com/grafana/grafana-plugin-sdk-go v0.235.0/go.mod h1:6n9LbrjGL3xAATntYVNcIi90G9BVHRJjzHKz5FXVfWw= -github.com/grafana/machine-learning-go-client v0.7.0 h1:yiRBg8rCNbHh9BURa+vtZ8ItRYvabbdYAtsAOfxoFPI= -github.com/grafana/machine-learning-go-client v0.7.0/go.mod h1:bKsLSJTreH7HXaL2FJnnrliMuP0L8XwMkXte6AgwFFg= +github.com/grafana/machine-learning-go-client v0.7.1-0.20240709184107-86215ffa4596 h1:HDL0Y6RiZbal9K+FfprWNjuVnG0RAAmr85pv0POXHNo= +github.com/grafana/machine-learning-go-client v0.7.1-0.20240709184107-86215ffa4596/go.mod h1:9xRIoH6Y6RubuCPNjLfpckE/fLVe9dazg3HSLI1ARAU= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= github.com/grafana/pyroscope-go/godeltaprof v0.1.7 h1:C11j63y7gymiW8VugJ9ZW0pWfxTZugdSJyC48olk5KY= diff --git a/internal/resources/machinelearning/resource_alert.go b/internal/resources/machinelearning/resource_alert.go new file mode 100644 index 000000000..87e078ea1 --- /dev/null +++ b/internal/resources/machinelearning/resource_alert.go @@ -0,0 +1,222 @@ +package machinelearning + +import ( + "context" + + "github.com/grafana/machine-learning-go-client/mlapi" + "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/prometheus/common/model" +) + +var resourceAlertID = common.NewResourceID(common.StringIDField("id")) + +func resourceAlert() *common.Resource { + schema := &schema.Resource{ + + Description: ` +A job defines the queries and model parameters for a machine learning task. +`, + + CreateContext: checkClient(resourceAlertCreate), + ReadContext: checkClient(resourceAlertRead), + UpdateContext: checkClient(resourceAlertUpdate), + DeleteContext: checkClient(resourceAlertDelete), + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "job_id": { + Description: "The forecast this alert belongs to.", + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ExactlyOneOf: []string{"job_id", "outlier_id"}, + }, + "outlier_id": { + Description: "The forecast this alert belongs to.", + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ExactlyOneOf: []string{"job_id", "outlier_id"}, + }, + "id": { + Description: "The ID of the alert.", + Type: schema.TypeString, + Computed: true, + }, + "title": { + Description: "The title of the alert.", + Type: schema.TypeString, + Required: true, + }, + "anomaly_condition": { + Description: "The condition for when to consider a point as anomalous.", + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice([]string{"any", "low", "high"}, false), + }, + "for": { + Description: "How long values must be anomalous before firing an alert.", + Type: schema.TypeString, + Optional: true, + }, + "threshold": { + Description: "The threshold of points over the window that need to be anomalous to alert.", + Type: schema.TypeString, + Optional: true, + }, + "window": { + Description: "How much time to average values over", + Type: schema.TypeString, + Optional: true, + }, + "labels": { + Description: "Labels to add to the alert generated in Grafana.", + Type: schema.TypeMap, + Optional: true, + }, + "annotations": { + Description: "Annotations to add to the alert generated in Grafana.", + Type: schema.TypeMap, + Optional: true, + }, + "no_data_state": { + Description: "How the alert should be processed when no data is returned by the underlying series", + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice([]string{"Alerting", "NoData", "OK"}, false), + }, + }, + } + + return common.NewLegacySDKResource( + common.CategoryMachineLearning, + "grafana_machine_learning_alert", + resourceAlertID, + schema, + ) +} + +func resourceAlertCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + c := meta.(*common.Client).MLAPI + alert, err := makeMLAlert(d) + if err != nil { + return diag.FromErr(err) + } + jobID := d.Get("job_id").(string) + if jobID != "" { + alert, err = c.NewJobAlert(ctx, jobID, alert) + } else { + outlierID := d.Get("outlier_id").(string) + alert, err = c.NewOutlierAlert(ctx, outlierID, alert) + } + if err != nil { + return diag.FromErr(err) + } + d.SetId(alert.ID) + return resourceAlertRead(ctx, d, meta) +} + +func resourceAlertRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + c := meta.(*common.Client).MLAPI + var ( + alert mlapi.Alert + err error + ) + jobID := d.Get("job_id").(string) + if jobID != "" { + alert, err = c.JobAlert(ctx, jobID, d.Id()) + } else { + outlierID := d.Get("outlier_id").(string) + alert, err = c.OutlierAlert(ctx, outlierID, d.Id()) + } + + if err, shouldReturn := common.CheckReadError("alert", d, err); shouldReturn { + return err + } + + d.Set("title", alert.Title) + d.Set("anomaly_condition", alert.AnomalyCondition) + d.Set("for", alert.For) + d.Set("threshold", alert.Threshold) + d.Set("window", alert.Window) + d.Set("labels", alert.Labels) + d.Set("annotations", alert.Annotations) + d.Set("no_data_state", alert.NoDataState) + + return nil +} + +func resourceAlertUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + c := meta.(*common.Client).MLAPI + alert, err := makeMLAlert(d) + if err != nil { + return diag.FromErr(err) + } + jobID := d.Get("job_id").(string) + if jobID != "" { + _, err = c.UpdateJobAlert(ctx, jobID, alert) + } else { + outlierID := d.Get("outlier_id").(string) + _, err = c.UpdateOutlierAlert(ctx, outlierID, alert) + } + + if err != nil { + return diag.FromErr(err) + } + return resourceAlertRead(ctx, d, meta) +} + +func resourceAlertDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + c := meta.(*common.Client).MLAPI + jobID := d.Get("job_id").(string) + var err error + if jobID != "" { + err = c.DeleteJobAlert(ctx, jobID, d.Id()) + } else { + outlierID := d.Get("outlier_id").(string) + err = c.DeleteOutlierAlert(ctx, outlierID, d.Id()) + } + return diag.FromErr(err) +} + +func makeMLAlert(d *schema.ResourceData) (mlapi.Alert, error) { + forClause, err := parseDuration(d.Get("for").(string)) + if err != nil { + return mlapi.Alert{}, err + } + window, err := parseDuration(d.Get("window").(string)) + if err != nil { + return mlapi.Alert{}, err + } + labels := map[string]string{} + for k, v := range d.Get("labels").(map[string]interface{}) { + labels[k] = v.(string) + } + annotations := map[string]string{} + for k, v := range d.Get("annotations").(map[string]interface{}) { + annotations[k] = v.(string) + } + return mlapi.Alert{ + ID: d.Id(), + Title: d.Get("title").(string), + AnomalyCondition: mlapi.AnomalyCondition(d.Get("anomaly_condition").(string)), + For: forClause, + Threshold: d.Get("threshold").(string), + Window: window, + Labels: labels, + Annotations: annotations, + NoDataState: mlapi.NoDataState(d.Get("no_data_state").(string)), + }, nil +} + +func parseDuration(s string) (model.Duration, error) { + if s == "" { + return 0, nil + } + return model.ParseDuration(s) +} diff --git a/internal/resources/machinelearning/resource_alert_test.go b/internal/resources/machinelearning/resource_alert_test.go new file mode 100644 index 000000000..da1240c11 --- /dev/null +++ b/internal/resources/machinelearning/resource_alert_test.go @@ -0,0 +1,158 @@ +package machinelearning_test + +import ( + "context" + "fmt" + "testing" + + "github.com/grafana/machine-learning-go-client/mlapi" + "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/grafana/terraform-provider-grafana/v3/internal/testutils" + "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" +) + +func TestAccResourceJobAlert(t *testing.T) { + testutils.CheckCloudInstanceTestsEnabled(t) + + randomJobName := acctest.RandomWithPrefix("Test Job") + randomAlertName := acctest.RandomWithPrefix("Test Job Alert") + + var job mlapi.Job + var alert mlapi.Alert + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + CheckDestroy: resource.ComposeTestCheckFunc( + testAccMLJobAlertCheckDestroy(&job, &alert), + testAccMLJobCheckDestroy(&job), + testAccDatasourceCheckDestroy(), + ), + Steps: []resource.TestStep{ + { + Config: testutils.TestAccExampleWithReplace(t, "resources/grafana_machine_learning_alert/job_alert.tf", map[string]string{ + "Test Job": randomJobName, + "Test Alert": randomAlertName, + }), + Check: resource.ComposeTestCheckFunc( + testAccMLJobCheckExists("grafana_machine_learning_job.test_job", &job), + testAccMLJobAlertCheckExists("grafana_machine_learning_job.test_job_alert", &job, &alert), + ), + }, + }, + }) +} + +func TestAccResourceOutlierAlert(t *testing.T) { + testutils.CheckCloudInstanceTestsEnabled(t) + + randomOutlierName := acctest.RandomWithPrefix("Test Job") + randomAlertName := acctest.RandomWithPrefix("Test Outlier Alert") + + var outlier mlapi.OutlierDetector + var alert mlapi.Alert + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + CheckDestroy: resource.ComposeTestCheckFunc( + testAccMLOutlierAlertCheckDestroy(&outlier, &alert), + testAccMLOutlierCheckDestroy(&outlier), + testAccDatasourceCheckDestroy(), + ), + Steps: []resource.TestStep{ + { + Config: testutils.TestAccExampleWithReplace(t, "resources/grafana_machine_learning_alert/outlier_alert.tf", map[string]string{ + "My DBSCAN outlier detector": "DBSCAN " + randomOutlierName, + "Test Alert": randomAlertName, + }), + Check: resource.ComposeTestCheckFunc( + testAccMLOutlierCheckExists("grafana_machine_learning_job.test_job", &outlier), + testAccMLOutlierAlertCheckExists("grafana_machine_learning_job.test_job_alert", &outlier, &alert), + ), + }, + }, + }) +} + +func testAccMLJobAlertCheckExists(rn string, job *mlapi.Job, alert *mlapi.Alert) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[rn] + if !ok { + return fmt.Errorf("resource not found: %s\n %#v", rn, s.RootModule().Resources) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("resource id not set") + } + + client := testutils.Provider.Meta().(*common.Client).MLAPI + gotAlert, err := client.JobAlert(context.Background(), job.ID, rs.Primary.ID) + if err != nil { + return fmt.Errorf("error getting job: %s", err) + } + + *alert = gotAlert + + return nil + } +} + +func testAccMLOutlierAlertCheckExists(rn string, outlier *mlapi.OutlierDetector, alert *mlapi.Alert) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[rn] + if !ok { + return fmt.Errorf("resource not found: %s\n %#v", rn, s.RootModule().Resources) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("resource id not set") + } + + client := testutils.Provider.Meta().(*common.Client).MLAPI + gotAlert, err := client.OutlierAlert(context.Background(), outlier.ID, rs.Primary.ID) + if err != nil { + return fmt.Errorf("error getting job: %s", err) + } + + *alert = gotAlert + + return nil + } +} + +func testAccMLJobAlertCheckDestroy(job *mlapi.Job, alert *mlapi.Alert) resource.TestCheckFunc { + return func(s *terraform.State) error { + // This check is to make sure that no pointer conversions are incorrect + // while mutating alert. + if job.ID == "" { + return fmt.Errorf("checking deletion of empty job id") + } + if alert.ID == "" { + return fmt.Errorf("checking deletion of empty alert id") + } + client := testutils.Provider.Meta().(*common.Client).MLAPI + _, err := client.JobAlert(context.Background(), job.ID, alert.ID) + if err == nil { + return fmt.Errorf("job still exists on server") + } + return nil + } +} + +func testAccMLOutlierAlertCheckDestroy(outlier *mlapi.OutlierDetector, alert *mlapi.Alert) resource.TestCheckFunc { + return func(s *terraform.State) error { + // This check is to make sure that no pointer conversions are incorrect + // while mutating alert. + if outlier.ID == "" { + return fmt.Errorf("checking deletion of empty outlier id") + } + if alert.ID == "" { + return fmt.Errorf("checking deletion of empty alert id") + } + client := testutils.Provider.Meta().(*common.Client).MLAPI + _, err := client.OutlierAlert(context.Background(), outlier.ID, alert.ID) + if err == nil { + return fmt.Errorf("job still exists on server") + } + return nil + } +} diff --git a/internal/resources/machinelearning/resources.go b/internal/resources/machinelearning/resources.go index fb3b020f7..a82c6f4b7 100644 --- a/internal/resources/machinelearning/resources.go +++ b/internal/resources/machinelearning/resources.go @@ -35,4 +35,5 @@ var Resources = []*common.Resource{ resourceJob(), resourceHoliday(), resourceOutlierDetector(), + resourceAlert(), }