From e23bbdc844727ce0b3ad4bdbd2ca097fff0f4660 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Tue, 9 Jul 2024 10:50:24 -0600 Subject: [PATCH 1/8] Add resources for creating ML managed alerts --- docs/resources/machine_learning_alert.md | 44 ++++ .../grafana_machine_learning_alert/import.sh | 1 + .../job_alert.tf | 17 ++ .../outlier_alert.tf | 25 ++ go.mod | 4 +- go.sum | 4 +- .../machinelearning/resource_alert.go | 226 ++++++++++++++++++ .../machinelearning/resource_alert_test.go | 156 ++++++++++++ .../resources/machinelearning/resources.go | 1 + 9 files changed, 474 insertions(+), 4 deletions(-) create mode 100644 docs/resources/machine_learning_alert.md create mode 100644 examples/resources/grafana_machine_learning_alert/import.sh create mode 100644 examples/resources/grafana_machine_learning_alert/job_alert.tf create mode 100644 examples/resources/grafana_machine_learning_alert/outlier_alert.tf create mode 100644 internal/resources/machinelearning/resource_alert.go create mode 100644 internal/resources/machinelearning/resource_alert_test.go 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..83efd4376 --- /dev/null +++ b/examples/resources/grafana_machine_learning_alert/job_alert.tf @@ -0,0 +1,17 @@ +resource "grafana_machine_learning_job" "test_alert_job" { + name = "Test Job" + metric = "tf_test_alert_job" + datasource_type = "prometheus" + datasource_uid = "abcd12345" + query_params = { + expr = "grafanacloud_grafana_instance_active_user_count" + } +} + +resource "grafana_machine_learning_alert" "test_job_alert" { + job_id = grafana_machine_learning_job.test_alert_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..5b65970d8 --- /dev/null +++ b/examples/resources/grafana_machine_learning_alert/outlier_alert.tf @@ -0,0 +1,25 @@ +resource "grafana_machine_learning_outlier_detector" "test_alert_outlier_detector" { + name = "Test Outlier" + + metric = "tf_test_alert_outlier" + 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_outlier_alert" { + outlier_id = grafana_machine_learning_outlier_detector.test_alert_outlier_detector.id + title = "Test Alert" + window = "1h" +} diff --git a/go.mod b/go.mod index 23af00c58..2c1e655b4 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.8.0 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 114659042..fe80f624b 100644 --- a/go.sum +++ b/go.sum @@ -142,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.8.0 h1:N8+0f5aFM/umVJWvlJkJy9McVIp9MIBUtuNruug94II= +github.com/grafana/machine-learning-go-client v0.8.0/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..160b3cef8 --- /dev/null +++ b/internal/resources/machinelearning/resource_alert.go @@ -0,0 +1,226 @@ +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) + if alert.For > 0 { + d.Set("for", alert.For.String()) + } + d.Set("threshold", alert.Threshold) + if alert.Window > 0 { + d.Set("window", alert.Window.String()) + } + 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..862d517b6 --- /dev/null +++ b/internal/resources/machinelearning/resource_alert_test.go @@ -0,0 +1,156 @@ +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), + ), + 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_alert_job", &job), + testAccMLJobAlertCheckExists("grafana_machine_learning_alert.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), + ), + Steps: []resource.TestStep{ + { + Config: testutils.TestAccExampleWithReplace(t, "resources/grafana_machine_learning_alert/outlier_alert.tf", map[string]string{ + "Test Outlier": randomOutlierName, + "Test Alert": randomAlertName, + }), + Check: resource.ComposeTestCheckFunc( + testAccMLOutlierCheckExists("grafana_machine_learning_outlier_detector.test_alert_outlier_detector", &outlier), + testAccMLOutlierAlertCheckExists("grafana_machine_learning_alert.test_outlier_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(), } From 3fb6865098b7852c33d0b3a117a5159ce27f5f78 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Wed, 10 Jul 2024 08:30:22 -0600 Subject: [PATCH 2/8] Refactor resource_alert.go to use framework --- docs/resources/machine_learning_alert.md | 4 +- .../machinelearning/resource_alert.go | 416 ++++++++++++------ 2 files changed, 284 insertions(+), 136 deletions(-) diff --git a/docs/resources/machine_learning_alert.md b/docs/resources/machine_learning_alert.md index 2d9963834..b24111548 100644 --- a/docs/resources/machine_learning_alert.md +++ b/docs/resources/machine_learning_alert.md @@ -3,12 +3,12 @@ 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. + diff --git a/internal/resources/machinelearning/resource_alert.go b/internal/resources/machinelearning/resource_alert.go index 160b3cef8..c169bf69f 100644 --- a/internal/resources/machinelearning/resource_alert.go +++ b/internal/resources/machinelearning/resource_alert.go @@ -2,222 +2,370 @@ package machinelearning import ( "context" + "fmt" "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/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/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-framework/types/basetypes" "github.com/prometheus/common/model" ) -var resourceAlertID = common.NewResourceID(common.StringIDField("id")) +var ( + resourceAlertID = common.NewResourceID(common.StringIDField("id")) + resourceAlertName = "grafana_machine_learning_alert" +) 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, - }, + return common.NewResource( + common.CategoryMachineLearning, + resourceAlertName, + resourceAlertID, + &alertResource{}, + ) +} - 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"}, +type resourceAlertModel struct { + ID types.String `tfsdk:"id"` + JobID types.String `tfsdk:"job_id"` + OutlierID types.String `tfsdk:"outlier_id"` + Title types.String `tfsdk:"title"` + AnomalyCondition types.String `tfsdk:"anomaly_condition"` + For types.String `tfsdk:"for"` + Threshold types.String `tfsdk:"threshold"` + Window types.String `tfsdk:"window"` + Labels types.Map `tfsdk:"labels"` + Annotations types.Map `tfsdk:"annotations"` + NoDataState types.String `tfsdk:"no_data_state"` +} + +type alertResource struct { + mlapi *mlapi.Client +} + +func (r *alertResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Configure is called multiple times (sometimes when ProviderData is not yet available), we only want to configure once + if req.ProviderData == nil || r.mlapi != nil { + return + } + + client, ok := req.ProviderData.(*common.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *common.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.mlapi = client.MLAPI +} + +func (r *alertResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "grafana_machine_learning_alert" +} + +func (r *alertResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "job_id": schema.StringAttribute{ + Description: "The forecast this alert belongs to.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf( + path.MatchRelative().AtParent().AtName("job_id"), + path.MatchRelative().AtParent().AtName("outlier_id"), + ), + }, }, - "outlier_id": { - Description: "The forecast this alert belongs to.", - Type: schema.TypeString, - Optional: true, - ForceNew: true, - ExactlyOneOf: []string{"job_id", "outlier_id"}, + "outlier_id": schema.StringAttribute{ + Description: "The forecast this alert belongs to.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf( + path.MatchRelative().AtParent().AtName("job_id"), + path.MatchRelative().AtParent().AtName("outlier_id"), + ), + }, }, - "id": { + "id": schema.StringAttribute{ Description: "The ID of the alert.", - Type: schema.TypeString, Computed: true, }, - "title": { + "title": schema.StringAttribute{ 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), + "anomaly_condition": schema.StringAttribute{ + Description: "The condition for when to consider a point as anomalous.", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("any", "low", "high"), + }, }, - "for": { + "for": schema.StringAttribute{ Description: "How long values must be anomalous before firing an alert.", - Type: schema.TypeString, Optional: true, }, - "threshold": { + "threshold": schema.StringAttribute{ Description: "The threshold of points over the window that need to be anomalous to alert.", - Type: schema.TypeString, Optional: true, }, - "window": { + "window": schema.StringAttribute{ Description: "How much time to average values over", - Type: schema.TypeString, Optional: true, }, - "labels": { + "labels": schema.MapAttribute{ Description: "Labels to add to the alert generated in Grafana.", - Type: schema.TypeMap, Optional: true, + ElementType: types.StringType, }, - "annotations": { + "annotations": schema.MapAttribute{ Description: "Annotations to add to the alert generated in Grafana.", - Type: schema.TypeMap, Optional: true, + ElementType: types.StringType, }, - "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), + "no_data_state": schema.StringAttribute{ + Description: "How the alert should be processed when no data is returned by the underlying series", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("Alerting", "NoData", "OK"), + }, }, }, } - - 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) +func (r *alertResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + if r.mlapi == nil { + resp.Diagnostics.AddError("client not configured", "client not configured") + return + } + + // Read Terraform plan data into the model + var data resourceAlertModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + alert, err := alertFromModel(data) if err != nil { - return diag.FromErr(err) + resp.Diagnostics.AddError("unable to make alert structure", err.Error()) + return } - jobID := d.Get("job_id").(string) - if jobID != "" { - alert, err = c.NewJobAlert(ctx, jobID, alert) + if data.JobID.ValueString() != "" { + alert, err = r.mlapi.NewJobAlert(ctx, data.JobID.ValueString(), alert) } else { - outlierID := d.Get("outlier_id").(string) - alert, err = c.NewOutlierAlert(ctx, outlierID, alert) + alert, err = r.mlapi.NewOutlierAlert(ctx, data.OutlierID.ValueString(), alert) } if err != nil { - return diag.FromErr(err) + resp.Diagnostics.AddError("Unable to Create Resource", err.Error()) + return } - 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()) + // Read created resource + data.ID = types.StringValue(alert.ID) + readData, diags := r.read(ctx, data) + if diags != nil { + resp.Diagnostics = diags + return } - - if err, shouldReturn := common.CheckReadError("alert", d, err); shouldReturn { - return err + if readData == nil { + resp.Diagnostics.AddError("Unable to read created resource", "Resource not found") + return } - d.Set("title", alert.Title) - d.Set("anomaly_condition", alert.AnomalyCondition) - if alert.For > 0 { - d.Set("for", alert.For.String()) + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, readData)...) +} + +func (r *alertResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Read Terraform state data into the model + var data resourceAlertModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + // Read from API + readData, diags := r.read(ctx, data) + if diags != nil { + resp.Diagnostics = diags + return } - d.Set("threshold", alert.Threshold) - if alert.Window > 0 { - d.Set("window", alert.Window.String()) + if readData == nil { + resp.State.RemoveResource(ctx) + return } - d.Set("labels", alert.Labels) - d.Set("annotations", alert.Annotations) - d.Set("no_data_state", alert.NoDataState) - return nil + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, readData)...) } -func resourceAlertUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - c := meta.(*common.Client).MLAPI - alert, err := makeMLAlert(d) +func (r *alertResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + if r.mlapi == nil { + resp.Diagnostics.AddError("client not configured", "client not configured") + return + } + + // Read Terraform plan data into the model + var data resourceAlertModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + alert, err := alertFromModel(data) if err != nil { - return diag.FromErr(err) + resp.Diagnostics.AddError("unable to make alert structure", err.Error()) + return } - jobID := d.Get("job_id").(string) - if jobID != "" { - _, err = c.UpdateJobAlert(ctx, jobID, alert) + if data.JobID.ValueString() != "" { + _, err = r.mlapi.UpdateJobAlert(ctx, data.JobID.ValueString(), alert) } else { - outlierID := d.Get("outlier_id").(string) - _, err = c.UpdateOutlierAlert(ctx, outlierID, alert) + _, err = r.mlapi.UpdateOutlierAlert(ctx, data.OutlierID.ValueString(), alert) } - if err != nil { - return diag.FromErr(err) + resp.Diagnostics.AddError("Unable to Update Resource", err.Error()) + return } - return resourceAlertRead(ctx, d, meta) + + // Read updated resource + readData, diags := r.read(ctx, data) + if diags != nil { + resp.Diagnostics = diags + return + } + if readData == nil { + resp.Diagnostics.AddError("Unable to read updated resource", "Resource not found") + return + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, readData)...) } -func resourceAlertDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - c := meta.(*common.Client).MLAPI - jobID := d.Get("job_id").(string) +func (r *alertResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + if r.mlapi == nil { + resp.Diagnostics.AddError("client not configured", "client not configured") + return + } + + // Read Terraform plan data into the model + var data resourceAlertModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } var err error - if jobID != "" { - err = c.DeleteJobAlert(ctx, jobID, d.Id()) + if data.JobID.ValueString() != "" { + err = r.mlapi.DeleteJobAlert(ctx, data.JobID.ValueString(), data.ID.ValueString()) + } else { + err = r.mlapi.DeleteOutlierAlert(ctx, data.OutlierID.ValueString(), data.ID.ValueString()) + } + if err != nil { + resp.Diagnostics.AddError("Unable to Delete Resource", err.Error()) + } +} + +func (r *alertResource) read(ctx context.Context, model resourceAlertModel) (*resourceAlertModel, diag.Diagnostics) { + if r.mlapi == nil { + return nil, diag.Diagnostics{diag.NewErrorDiagnostic("client not configured", "client not configured")} + } + + var ( + alert mlapi.Alert + err error + ) + if model.JobID.ValueString() != "" { + alert, err = r.mlapi.JobAlert(ctx, model.JobID.ValueString(), model.ID.String()) } else { - outlierID := d.Get("outlier_id").(string) - err = c.DeleteOutlierAlert(ctx, outlierID, d.Id()) + alert, err = r.mlapi.OutlierAlert(ctx, model.OutlierID.ValueString(), model.ID.String()) + } + if err != nil { + return nil, diag.Diagnostics{diag.NewErrorDiagnostic("Unable to read resource", err.Error())} } - return diag.FromErr(err) + + data := &resourceAlertModel{} + data.ID = model.ID + data.JobID = model.JobID + data.OutlierID = model.OutlierID + data.Title = types.StringValue(alert.Title) + data.AnomalyCondition = types.StringValue(string(alert.AnomalyCondition)) + data.For = types.StringValue(alert.For.String()) + data.Threshold = types.StringValue(alert.Threshold) + data.Window = types.StringValue(alert.Window.String()) + data.Labels = labelsToMapValue(alert.Labels) + data.Annotations = labelsToMapValue(alert.Annotations) + data.NoDataState = types.StringValue(string(alert.NoDataState)) + + return data, nil } -func makeMLAlert(d *schema.ResourceData) (mlapi.Alert, error) { - forClause, err := parseDuration(d.Get("for").(string)) +func alertFromModel(model resourceAlertModel) (mlapi.Alert, error) { + forClause, err := parseDuration(model.For.ValueString()) if err != nil { return mlapi.Alert{}, err } - window, err := parseDuration(d.Get("window").(string)) + window, err := parseDuration(model.Window.ValueString()) 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) + labels, err := mapToLabels(model.Labels) + if err != nil { + return mlapi.Alert{}, err } - annotations := map[string]string{} - for k, v := range d.Get("annotations").(map[string]interface{}) { - annotations[k] = v.(string) + annotations, err := mapToLabels(model.Annotations) + if err != nil { + return mlapi.Alert{}, err } return mlapi.Alert{ - ID: d.Id(), - Title: d.Get("title").(string), - AnomalyCondition: mlapi.AnomalyCondition(d.Get("anomaly_condition").(string)), + ID: model.ID.ValueString(), + Title: model.Title.ValueString(), + AnomalyCondition: mlapi.AnomalyCondition(model.AnomalyCondition.ValueString()), For: forClause, - Threshold: d.Get("threshold").(string), + Threshold: model.Threshold.ValueString(), Window: window, Labels: labels, Annotations: annotations, - NoDataState: mlapi.NoDataState(d.Get("no_data_state").(string)), + NoDataState: mlapi.NoDataState(model.NoDataState.ValueString()), }, nil } +func labelsToMapValue(labels map[string]string) basetypes.MapValue { + values := map[string]attr.Value{} + for k, v := range labels { + values[k] = types.StringValue(v) + } + return types.MapValueMust(types.StringType, values) +} + +func mapToLabels(m basetypes.MapValue) (map[string]string, error) { + labels := map[string]string{} + for k, v := range m.Elements() { + if vString, ok := v.(types.String); ok { + labels[k] = vString.ValueString() + } else { + return nil, fmt.Errorf("invalid label value for %s: %v", k, v) + } + } + return labels, nil +} + func parseDuration(s string) (model.Duration, error) { if s == "" { return 0, nil From 9f084060d54d148f302a1f4488c16b95045a3d57 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Wed, 10 Jul 2024 09:02:29 -0600 Subject: [PATCH 3/8] Specifically handle nil map case --- internal/resources/machinelearning/resource_alert.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/resources/machinelearning/resource_alert.go b/internal/resources/machinelearning/resource_alert.go index c169bf69f..48807001b 100644 --- a/internal/resources/machinelearning/resource_alert.go +++ b/internal/resources/machinelearning/resource_alert.go @@ -347,6 +347,9 @@ func alertFromModel(model resourceAlertModel) (mlapi.Alert, error) { } func labelsToMapValue(labels map[string]string) basetypes.MapValue { + if labels == nil { + return basetypes.NewMapNull(types.StringType) + } values := map[string]attr.Value{} for k, v := range labels { values[k] = types.StringValue(v) @@ -355,6 +358,9 @@ func labelsToMapValue(labels map[string]string) basetypes.MapValue { } func mapToLabels(m basetypes.MapValue) (map[string]string, error) { + if m.IsNull() { + return nil, nil + } labels := map[string]string{} for k, v := range m.Elements() { if vString, ok := v.(types.String); ok { From 478d0e083e91d7cf36700faaa3169b9a06025854 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Wed, 10 Jul 2024 09:13:11 -0600 Subject: [PATCH 4/8] Add default values for string fields --- .../machinelearning/resource_alert.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/internal/resources/machinelearning/resource_alert.go b/internal/resources/machinelearning/resource_alert.go index 48807001b..f10a355cb 100644 --- a/internal/resources/machinelearning/resource_alert.go +++ b/internal/resources/machinelearning/resource_alert.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" "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/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" @@ -118,11 +119,14 @@ func (r *alertResource) Schema(ctx context.Context, req resource.SchemaRequest, Optional: true, Validators: []validator.String{ stringvalidator.OneOf("any", "low", "high"), + stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("outlier_id")), }, }, "for": schema.StringAttribute{ Description: "How long values must be anomalous before firing an alert.", Optional: true, + Computed: true, + Default: stringdefault.StaticString("0s"), }, "threshold": schema.StringAttribute{ Description: "The threshold of points over the window that need to be anomalous to alert.", @@ -131,6 +135,8 @@ func (r *alertResource) Schema(ctx context.Context, req resource.SchemaRequest, "window": schema.StringAttribute{ Description: "How much time to average values over", Optional: true, + Computed: true, + Default: stringdefault.StaticString("0s"), }, "labels": schema.MapAttribute{ Description: "Labels to add to the alert generated in Grafana.", @@ -305,13 +311,19 @@ func (r *alertResource) read(ctx context.Context, model resourceAlertModel) (*re data.JobID = model.JobID data.OutlierID = model.OutlierID data.Title = types.StringValue(alert.Title) - data.AnomalyCondition = types.StringValue(string(alert.AnomalyCondition)) + if alert.AnomalyCondition != "" { + data.AnomalyCondition = types.StringValue(string(alert.AnomalyCondition)) + } data.For = types.StringValue(alert.For.String()) - data.Threshold = types.StringValue(alert.Threshold) + if alert.Threshold != "" { + data.Threshold = types.StringValue(alert.Threshold) + } data.Window = types.StringValue(alert.Window.String()) data.Labels = labelsToMapValue(alert.Labels) data.Annotations = labelsToMapValue(alert.Annotations) - data.NoDataState = types.StringValue(string(alert.NoDataState)) + if alert.NoDataState != "" { + data.NoDataState = types.StringValue(string(alert.NoDataState)) + } return data, nil } From c65d4962c11f56a9042ee2caa243904d39648568 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Wed, 10 Jul 2024 14:30:02 -0600 Subject: [PATCH 5/8] Add duration validation and test --- .../machinelearning/resource_alert.go | 51 +++++++++++++++++++ .../machinelearning/resource_alert_test.go | 31 +++++++++++ 2 files changed, 82 insertions(+) diff --git a/internal/resources/machinelearning/resource_alert.go b/internal/resources/machinelearning/resource_alert.go index f10a355cb..14cca7a22 100644 --- a/internal/resources/machinelearning/resource_alert.go +++ b/internal/resources/machinelearning/resource_alert.go @@ -3,9 +3,11 @@ package machinelearning import ( "context" "fmt" + "time" "github.com/grafana/machine-learning-go-client/mlapi" "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -127,6 +129,9 @@ func (r *alertResource) Schema(ctx context.Context, req resource.SchemaRequest, Optional: true, Computed: true, Default: stringdefault.StaticString("0s"), + Validators: []validator.String{ + anyDuration(), + }, }, "threshold": schema.StringAttribute{ Description: "The threshold of points over the window that need to be anomalous to alert.", @@ -137,6 +142,9 @@ func (r *alertResource) Schema(ctx context.Context, req resource.SchemaRequest, Optional: true, Computed: true, Default: stringdefault.StaticString("0s"), + Validators: []validator.String{ + maxDuration(12 * time.Hour), + }, }, "labels": schema.MapAttribute{ Description: "Labels to add to the alert generated in Grafana.", @@ -390,3 +398,46 @@ func parseDuration(s string) (model.Duration, error) { } return model.ParseDuration(s) } + +type durationValidator struct { + max model.Duration +} + +func (v durationValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v durationValidator) MarkdownDescription(_ context.Context) string { + if v.max == 0 { + return "value must be a duration like 5m" + } + return fmt.Sprintf("value must be a duration less than: %s", v.max) +} + +func (v durationValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue + + duration, err := model.ParseDuration(request.ConfigValue.ValueString()) + + if err != nil || (v.max > 0 && duration > v.max) { + response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + request.Path, + v.Description(ctx), + value.String(), + )) + } +} + +func anyDuration() validator.String { + return durationValidator{} +} + +func maxDuration(max time.Duration) validator.String { + return durationValidator{ + max: model.Duration(max), + } +} diff --git a/internal/resources/machinelearning/resource_alert_test.go b/internal/resources/machinelearning/resource_alert_test.go index 862d517b6..036a86270 100644 --- a/internal/resources/machinelearning/resource_alert_test.go +++ b/internal/resources/machinelearning/resource_alert_test.go @@ -3,6 +3,7 @@ package machinelearning_test import ( "context" "fmt" + "regexp" "testing" "github.com/grafana/machine-learning-go-client/mlapi" @@ -154,3 +155,33 @@ func testAccMLOutlierAlertCheckDestroy(outlier *mlapi.OutlierDetector, alert *ml return nil } } + +func TestAccResourceInvalidMachineLearningAlert(t *testing.T) { + testutils.CheckCloudInstanceTestsEnabled(t) + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` +resource "grafana_machine_learning_alert" "invalid" { + job_id = "xyz" + title = "Test Job" + for = "foo" +} +`, + ExpectError: regexp.MustCompile(".*value must be a duration.*"), + }, + { + Config: ` +resource "grafana_machine_learning_alert" "invalid" { + job_id = "xyz" + title = "Test Job" + window = "24h" +} +`, + ExpectError: regexp.MustCompile(".*value must be a duration less than: 12h.*"), + }, + }, + }) +} From 2ff0d1dab5edd4a2f4146fc6594519cbe99e25a8 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Wed, 10 Jul 2024 14:34:43 -0600 Subject: [PATCH 6/8] Rename job_alert.tf to resource.tf This generates an example in the documentation for how to create an alert resource. --- docs/resources/machine_learning_alert.md | 20 +++++++++++++++++++ .../{job_alert.tf => resource.tf} | 0 .../machinelearning/resource_alert_test.go | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) rename examples/resources/grafana_machine_learning_alert/{job_alert.tf => resource.tf} (100%) diff --git a/docs/resources/machine_learning_alert.md b/docs/resources/machine_learning_alert.md index b24111548..8972b5a26 100644 --- a/docs/resources/machine_learning_alert.md +++ b/docs/resources/machine_learning_alert.md @@ -10,7 +10,27 @@ description: |- +## Example Usage +```terraform +resource "grafana_machine_learning_job" "test_alert_job" { + name = "Test Job" + metric = "tf_test_alert_job" + datasource_type = "prometheus" + datasource_uid = "abcd12345" + query_params = { + expr = "grafanacloud_grafana_instance_active_user_count" + } +} + +resource "grafana_machine_learning_alert" "test_job_alert" { + job_id = grafana_machine_learning_job.test_alert_job.id + title = "Test Alert" + anomaly_condition = "any" + threshold = ">0.8" + window = "15m" +} +``` ## Schema diff --git a/examples/resources/grafana_machine_learning_alert/job_alert.tf b/examples/resources/grafana_machine_learning_alert/resource.tf similarity index 100% rename from examples/resources/grafana_machine_learning_alert/job_alert.tf rename to examples/resources/grafana_machine_learning_alert/resource.tf diff --git a/internal/resources/machinelearning/resource_alert_test.go b/internal/resources/machinelearning/resource_alert_test.go index 036a86270..5eaacae76 100644 --- a/internal/resources/machinelearning/resource_alert_test.go +++ b/internal/resources/machinelearning/resource_alert_test.go @@ -30,7 +30,7 @@ func TestAccResourceJobAlert(t *testing.T) { ), Steps: []resource.TestStep{ { - Config: testutils.TestAccExampleWithReplace(t, "resources/grafana_machine_learning_alert/job_alert.tf", map[string]string{ + Config: testutils.TestAccExampleWithReplace(t, "resources/grafana_machine_learning_alert/resource.tf", map[string]string{ "Test Job": randomJobName, "Test Alert": randomAlertName, }), From 14b6b617438d352e0c9197799817cab9b57abbb0 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Wed, 10 Jul 2024 14:55:59 -0600 Subject: [PATCH 7/8] Implement importing of ML alerts --- .../machinelearning/resource_alert.go | 35 +++++++++++++++++++ .../machinelearning/resource_alert_test.go | 16 +++++++++ 2 files changed, 51 insertions(+) diff --git a/internal/resources/machinelearning/resource_alert.go b/internal/resources/machinelearning/resource_alert.go index 14cca7a22..9e2524ae1 100644 --- a/internal/resources/machinelearning/resource_alert.go +++ b/internal/resources/machinelearning/resource_alert.go @@ -3,6 +3,7 @@ package machinelearning import ( "context" "fmt" + "strings" "time" "github.com/grafana/machine-learning-go-client/mlapi" @@ -26,6 +27,9 @@ import ( var ( resourceAlertID = common.NewResourceID(common.StringIDField("id")) resourceAlertName = "grafana_machine_learning_alert" + + // Check interface + _ resource.ResourceWithImportState = (*alertResource)(nil) ) func resourceAlert() *common.Resource { @@ -167,6 +171,37 @@ func (r *alertResource) Schema(ctx context.Context, req resource.SchemaRequest, } } +func (r *alertResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // Import ID looks like: /(jobs|outliers)//alerts/ + id := strings.TrimLeft(req.ID, "/") + parts := strings.Split(id, "/") + if len(parts) != 4 || + (parts[0] != "jobs" && parts[0] != "outliers") || + parts[2] != "alerts" { + resp.Diagnostics.AddError("Invalid import ID format", "Import ID must be in the format '/(jobs|outliers)//alerts/'") + return + } + model := resourceAlertModel{ + ID: types.StringValue(parts[3]), + } + if parts[0] == "jobs" { + model.JobID = types.StringValue(parts[1]) + } else { + model.OutlierID = types.StringValue(parts[1]) + } + + data, diags := r.read(ctx, model) + if diags != nil { + resp.Diagnostics = diags + return + } + if data == nil { + resp.Diagnostics.AddError("Resource not found", "Resource not found") + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, data)...) +} + func (r *alertResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { if r.mlapi == nil { resp.Diagnostics.AddError("client not configured", "client not configured") diff --git a/internal/resources/machinelearning/resource_alert_test.go b/internal/resources/machinelearning/resource_alert_test.go index 5eaacae76..23950251b 100644 --- a/internal/resources/machinelearning/resource_alert_test.go +++ b/internal/resources/machinelearning/resource_alert_test.go @@ -39,6 +39,14 @@ func TestAccResourceJobAlert(t *testing.T) { testAccMLJobAlertCheckExists("grafana_machine_learning_alert.test_job_alert", &job, &alert), ), }, + { + ResourceName: "grafana_machine_learning_alert.test_job_alert", + ImportState: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + return fmt.Sprintf("/jobs/%s/alerts/%s", job.ID, alert.ID), nil + }, + ImportStateVerify: true, + }, }, }) } @@ -68,6 +76,14 @@ func TestAccResourceOutlierAlert(t *testing.T) { testAccMLOutlierAlertCheckExists("grafana_machine_learning_alert.test_outlier_alert", &outlier, &alert), ), }, + { + ResourceName: "grafana_machine_learning_alert.test_outlier_alert", + ImportState: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + return fmt.Sprintf("/outliers/%s/alerts/%s", outlier.ID, alert.ID), nil + }, + ImportStateVerify: true, + }, }, }) } From b70d4afea47b4f0b92fefa1fb761b2ad38c5677b Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Thu, 11 Jul 2024 10:34:22 -0600 Subject: [PATCH 8/8] Use sentence case for all error diagnostics --- .../resources/machinelearning/resource_alert.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/resources/machinelearning/resource_alert.go b/internal/resources/machinelearning/resource_alert.go index 9e2524ae1..48a76c613 100644 --- a/internal/resources/machinelearning/resource_alert.go +++ b/internal/resources/machinelearning/resource_alert.go @@ -69,7 +69,7 @@ func (r *alertResource) Configure(ctx context.Context, req resource.ConfigureReq if !ok { resp.Diagnostics.AddError( - "Unexpected Resource Configure Type", + "Unexpected resource configure type", fmt.Sprintf("Expected *common.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), ) @@ -204,7 +204,7 @@ func (r *alertResource) ImportState(ctx context.Context, req resource.ImportStat func (r *alertResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { if r.mlapi == nil { - resp.Diagnostics.AddError("client not configured", "client not configured") + resp.Diagnostics.AddError("Client not configured", "Client not configured") return } @@ -216,7 +216,7 @@ func (r *alertResource) Create(ctx context.Context, req resource.CreateRequest, } alert, err := alertFromModel(data) if err != nil { - resp.Diagnostics.AddError("unable to make alert structure", err.Error()) + resp.Diagnostics.AddError("Unable to make alert structure", err.Error()) return } if data.JobID.ValueString() != "" { @@ -225,7 +225,7 @@ func (r *alertResource) Create(ctx context.Context, req resource.CreateRequest, alert, err = r.mlapi.NewOutlierAlert(ctx, data.OutlierID.ValueString(), alert) } if err != nil { - resp.Diagnostics.AddError("Unable to Create Resource", err.Error()) + resp.Diagnostics.AddError("Unable to create resource", err.Error()) return } @@ -267,7 +267,7 @@ func (r *alertResource) Read(ctx context.Context, req resource.ReadRequest, resp func (r *alertResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { if r.mlapi == nil { - resp.Diagnostics.AddError("client not configured", "client not configured") + resp.Diagnostics.AddError("Client not configured", "Client not configured") return } @@ -280,7 +280,7 @@ func (r *alertResource) Update(ctx context.Context, req resource.UpdateRequest, alert, err := alertFromModel(data) if err != nil { - resp.Diagnostics.AddError("unable to make alert structure", err.Error()) + resp.Diagnostics.AddError("Unable to make alert structure", err.Error()) return } if data.JobID.ValueString() != "" { @@ -310,7 +310,7 @@ func (r *alertResource) Update(ctx context.Context, req resource.UpdateRequest, func (r *alertResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { if r.mlapi == nil { - resp.Diagnostics.AddError("client not configured", "client not configured") + resp.Diagnostics.AddError("Client not configured", "Client not configured") return }