From a8db1f9b236b39c839e8677ef6045bf16e73aea4 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Wed, 10 Jul 2024 08:30:22 -0600 Subject: [PATCH] Refactor resource_alert.go to use framework --- .../machinelearning/resource_alert.go | 410 ++++++++++++------ 1 file changed, 276 insertions(+), 134 deletions(-) diff --git a/internal/resources/machinelearning/resource_alert.go b/internal/resources/machinelearning/resource_alert.go index 160b3cef8..7c13c5d7e 100644 --- a/internal/resources/machinelearning/resource_alert.go +++ b/internal/resources/machinelearning/resource_alert.go @@ -2,222 +2,364 @@ 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")), + stringvalidator.ExactlyOneOf(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")), + stringvalidator.ExactlyOneOf(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, }, - "annotations": { + "annotations": schema.MapAttribute{ 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), + "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 := makeMLAlert(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()) + data.ID = types.StringValue(alert.ID) + // Read created resource + 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 := makeMLAlert(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() != "" { + alert, err = r.mlapi.UpdateJobAlert(ctx, data.JobID.ValueString(), alert) } else { - outlierID := d.Get("outlier_id").(string) - _, err = c.UpdateOutlierAlert(ctx, outlierID, alert) + 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 makeMLAlert(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