From f12168349bc3269279c6ca98acf3ffe84fcf0fe3 Mon Sep 17 00:00:00 2001 From: Sean Marciniak <30928402+MovieStoreGuy@users.noreply.github.com> Date: Tue, 22 Oct 2024 13:39:20 +1030 Subject: [PATCH] Migrate: Moving detector to new definiton (#532) * Adding current work on detector * Adding further tests * Added create tests * Finishing up tests * Fixing up detector defintition --- internal/definition/detector/resource.go | 158 +++++++ internal/definition/detector/resource_test.go | 438 ++++++++++++++++++ internal/definition/detector/schema.go | 339 ++++++++++++++ internal/definition/detector/schema_test.go | 198 ++++++++ internal/definition/detector/v0_state.go | 37 ++ internal/definition/detector/v0_state_test.go | 76 +++ internal/tfextension/encoding.go | 8 + internal/tfextension/encoding_test.go | 23 + 8 files changed, 1277 insertions(+) create mode 100644 internal/definition/detector/resource.go create mode 100644 internal/definition/detector/resource_test.go create mode 100644 internal/definition/detector/schema.go create mode 100644 internal/definition/detector/schema_test.go create mode 100644 internal/definition/detector/v0_state.go create mode 100644 internal/definition/detector/v0_state_test.go diff --git a/internal/definition/detector/resource.go b/internal/definition/detector/resource.go new file mode 100644 index 00000000..b9cdbf2d --- /dev/null +++ b/internal/definition/detector/resource.go @@ -0,0 +1,158 @@ +// Copyright Splunk, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package detector + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/signalfx/signalfx-go/detector" + + pmeta "github.com/splunk-terraform/terraform-provider-signalfx/internal/providermeta" + tfext "github.com/splunk-terraform/terraform-provider-signalfx/internal/tfextension" +) + +const ( + ResourceName = "signalfx_detector" + AppPath = "/detector/v2" +) + +func NewResource() *schema.Resource { + return &schema.Resource{ + SchemaFunc: newSchema, + CreateContext: resourceCreate, + ReadContext: resourceRead, + UpdateContext: resourceUpdate, + DeleteContext: resourceDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + StateUpgraders: []schema.StateUpgrader{ + {Type: v0state().CoreConfigSchema().ImpliedType(), Upgrade: v0stateMigration, Version: 0}, + }, + } +} + +func resourceCreate(ctx context.Context, data *schema.ResourceData, meta any) (issues diag.Diagnostics) { + client, err := pmeta.LoadClient(ctx, meta) + if err != nil { + return tfext.AsErrorDiagnostics(err) + } + dt, err := decodeTerraform(data) + if err != nil { + return tfext.AsErrorDiagnostics(err) + } + tflog.Debug(ctx, "Creating new detector", tfext.NewLogFields().JSON("detector", dt)) + + resp, err := client.CreateDetector(ctx, &detector.CreateUpdateDetectorRequest{ + Name: dt.Name, + AuthorizedWriters: dt.AuthorizedWriters, + Description: dt.Description, + TimeZone: dt.TimeZone, + MaxDelay: dt.MaxDelay, + MinDelay: dt.MinDelay, + ProgramText: dt.ProgramText, + Rules: dt.Rules, + Tags: dt.Tags, + Teams: dt.Teams, + VisualizationOptions: dt.VisualizationOptions, + ParentDetectorId: dt.ParentDetectorId, + DetectorOrigin: dt.DetectorOrigin, + }) + if err != nil { + return tfext.AsErrorDiagnostics(err) + } + + issues = tfext.AppendDiagnostics(issues, + tfext.AsErrorDiagnostics( + data.Set("url", + pmeta.LoadApplicationURL(ctx, meta, AppPath, resp.Id, "edit"), + ), + )..., + ) + + return tfext.AppendDiagnostics( + issues, + tfext.AsErrorDiagnostics(encodeTerraform(resp, data))..., + ) +} + +func resourceRead(ctx context.Context, data *schema.ResourceData, meta any) (issues diag.Diagnostics) { + client, err := pmeta.LoadClient(ctx, meta) + if err != nil { + return tfext.AsErrorDiagnostics(err) + } + + dt, err := client.GetDetector(ctx, data.Id()) + if err != nil { + return tfext.AsErrorDiagnostics(err) + } + + tflog.Debug(ctx, "Read detector details", tfext.NewLogFields().JSON("detector", dt)) + + if dt.OverMTSLimit { + issues = tfext.AppendDiagnostics(issues, tfext.AsWarnDiagnostics(fmt.Errorf("detector is over mts limit"))...) + } + + return tfext.AppendDiagnostics( + issues, + tfext.AsErrorDiagnostics(encodeTerraform(dt, data))..., + ) +} + +func resourceUpdate(ctx context.Context, data *schema.ResourceData, meta any) (issues diag.Diagnostics) { + client, err := pmeta.LoadClient(ctx, meta) + if err != nil { + return tfext.AsErrorDiagnostics(err) + } + dt, err := decodeTerraform(data) + if err != nil { + return tfext.AsErrorDiagnostics(err) + } + tflog.Debug(ctx, "Updating detector", tfext.NewLogFields(). + JSON("detector", dt). + Field("id", data.Id()), + ) + + resp, err := client.UpdateDetector(ctx, data.Id(), &detector.CreateUpdateDetectorRequest{ + Name: dt.Name, + AuthorizedWriters: dt.AuthorizedWriters, + Description: dt.Description, + TimeZone: dt.TimeZone, + MaxDelay: dt.MaxDelay, + MinDelay: dt.MinDelay, + ProgramText: dt.ProgramText, + Rules: dt.Rules, + Tags: dt.Tags, + Teams: dt.Teams, + VisualizationOptions: dt.VisualizationOptions, + ParentDetectorId: dt.ParentDetectorId, + DetectorOrigin: dt.DetectorOrigin, + }) + if err != nil { + return tfext.AsErrorDiagnostics(err) + } + + issues = tfext.AppendDiagnostics(issues, + tfext.AsErrorDiagnostics( + data.Set("url", pmeta.LoadApplicationURL(ctx, meta, AppPath, resp.Id, "edit")), + )..., + ) + + return tfext.AppendDiagnostics( + issues, + tfext.AsErrorDiagnostics(encodeTerraform(resp, data))..., + ) +} + +func resourceDelete(ctx context.Context, data *schema.ResourceData, meta any) diag.Diagnostics { + client, err := pmeta.LoadClient(ctx, meta) + if err != nil { + return tfext.AsErrorDiagnostics(err) + } + return tfext.AsErrorDiagnostics(client.DeleteDetector(ctx, data.Id())) +} diff --git a/internal/definition/detector/resource_test.go b/internal/definition/detector/resource_test.go new file mode 100644 index 00000000..4061e617 --- /dev/null +++ b/internal/definition/detector/resource_test.go @@ -0,0 +1,438 @@ +// Copyright Splunk, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package detector + +import ( + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/signalfx/signalfx-go/detector" + "github.com/signalfx/signalfx-go/notification" + "github.com/stretchr/testify/assert" + + "github.com/splunk-terraform/terraform-provider-signalfx/internal/common" + tfext "github.com/splunk-terraform/terraform-provider-signalfx/internal/tfextension" + "github.com/splunk-terraform/terraform-provider-signalfx/internal/tftest" +) + +func TestNewResource(t *testing.T) { + t.Parallel() + + assert.NotNil(t, NewResource(), "Must have a valid resource defined") +} + +func TestResourceCreate(t *testing.T) { + t.Parallel() + + for _, tc := range []tftest.ResourceOperationTestCase[detector.Detector]{ + { + Name: "No provider", + Meta: func(_ testing.TB) any { + return nil + }, + Resource: NewResource(), + Encoder: encodeTerraform, + Decoder: decodeTerraform, + Input: &detector.Detector{}, + Issues: diag.Diagnostics{ + {Severity: diag.Error, Summary: "expected to implement type Meta"}, + }, + }, + { + Name: "Failed create", + Resource: NewResource(), + Encoder: encodeTerraform, + Decoder: decodeTerraform, + Meta: tftest.NewTestHTTPMockMeta(map[string]http.HandlerFunc{ + "POST /v2/detector": func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() + + http.Error(w, "Failed create detector", http.StatusInternalServerError) + }, + }), + Input: &detector.Detector{}, + Issues: diag.Diagnostics{ + {Severity: diag.Error, Summary: "Bad status 500: Failed create detector\n"}, + }, + }, + { + Name: "Successful create", + Resource: NewResource(), + Meta: tftest.NewTestHTTPMockMeta(map[string]http.HandlerFunc{ + "POST /v2/detector": func(w http.ResponseWriter, r *http.Request) { + var req detector.CreateUpdateDetectorRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + _ = json.NewEncoder(w).Encode(&detector.Detector{ + Id: "id-01", + Name: req.Name, + Description: req.Description, + AuthorizedWriters: req.AuthorizedWriters, + TimeZone: req.TimeZone, + MinDelay: req.MinDelay, + MaxDelay: req.MaxDelay, + ProgramText: req.ProgramText, + Rules: req.Rules, + Tags: req.Tags, + Teams: req.Teams, + VisualizationOptions: req.VisualizationOptions, + ParentDetectorId: req.ParentDetectorId, + DetectorOrigin: req.DetectorOrigin, + }) + }, + }), + Encoder: encodeTerraform, + Decoder: decodeTerraform, + Input: &detector.Detector{ + Id: "id-01", + Name: "test detector", + Description: "An example detector response", + AuthorizedWriters: &detector.AuthorizedWriters{}, + TimeZone: "Australia/Sydney", + MaxDelay: common.AsPointer[int32](100), + MinDelay: common.AsPointer[int32](100), + ProgramText: `detect(when(data('*').count() < 1)).publish('no data')`, + OverMTSLimit: false, + Rules: []*detector.Rule{ + { + DetectLabel: "no data", + Notifications: []*notification.Notification{ + {Type: "Team", Value: ¬ification.TeamNotification{Type: "Team", Team: "awesome-team"}}, + }, + }, + }, + Tags: []string{"tag-01"}, + Teams: []string{"team-01"}, + DetectorOrigin: "Standard", + VisualizationOptions: &detector.Visualization{}, + }, + Expect: &detector.Detector{ + Id: "id-01", + Name: "test detector", + Description: "An example detector response", + AuthorizedWriters: &detector.AuthorizedWriters{}, + TimeZone: "Australia/Sydney", + MaxDelay: common.AsPointer[int32](100000000), + MinDelay: common.AsPointer[int32](100000000), + ProgramText: `detect(when(data('*').count() < 1)).publish('no data')`, + OverMTSLimit: false, + Rules: []*detector.Rule{ + { + DetectLabel: "no data", + Notifications: []*notification.Notification{ + {Type: "Team", Value: ¬ification.TeamNotification{Type: "Team", Team: "awesome-team"}}, + }, + }, + }, + Tags: []string{"tag-01"}, + Teams: []string{"team-01"}, + DetectorOrigin: "Standard", + VisualizationOptions: &detector.Visualization{}, + }, + }, + } { + tc.TestCreate(t) + } +} + +func TestResourceRead(t *testing.T) { + t.Parallel() + + for _, tc := range []tftest.ResourceOperationTestCase[detector.Detector]{ + { + Name: "No provider", + Meta: func(_ testing.TB) any { + return nil + }, + Resource: NewResource(), + Encoder: encodeTerraform, + Decoder: decodeTerraform, + Input: &detector.Detector{}, + Issues: diag.Diagnostics{ + {Severity: diag.Error, Summary: "expected to implement type Meta"}, + }, + }, + { + Name: "Failed read", + Meta: tftest.NewTestHTTPMockMeta(map[string]http.HandlerFunc{ + "GET /v2/detector/id-01": func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() + + http.Error(w, "failed to read body", http.StatusBadRequest) + }, + }), + Resource: NewResource(), + Encoder: encodeTerraform, + Decoder: decodeTerraform, + Input: &detector.Detector{ + Id: "id-01", + }, + Issues: diag.Diagnostics{ + {Severity: diag.Error, Summary: "Bad status 400: failed to read body\n"}, + }, + }, + { + Name: "Successful Read", + Resource: NewResource(), + Encoder: encodeTerraform, + Decoder: decodeTerraform, + Meta: tftest.NewTestHTTPMockMeta(map[string]http.HandlerFunc{ + "GET /v2/detector/id-01": func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() + + _ = json.NewEncoder(w).Encode(&detector.Detector{ + Id: "id-01", + Name: "test detector", + Description: "An example detector response", + TimeZone: "Australia/Sydney", + MaxDelay: common.AsPointer[int32](100), + MinDelay: common.AsPointer[int32](100), + ProgramText: `detect(when(data('*').count() < 1)).publish('no data')`, + Rules: []*detector.Rule{ + { + DetectLabel: "no data", + Notifications: []*notification.Notification{ + {Type: "Team", Value: ¬ification.TeamNotification{Type: "Team", Team: "awesome-team"}}, + }, + }, + }, + Tags: []string{"tag-01"}, + Teams: []string{"team-01"}, + DetectorOrigin: "Standard", + }) + }, + }), + Input: &detector.Detector{ + Id: "id-01", + }, + Expect: &detector.Detector{ + Id: "id-01", + Name: "test detector", + Description: "An example detector response", + AuthorizedWriters: &detector.AuthorizedWriters{}, + TimeZone: "Australia/Sydney", + MaxDelay: common.AsPointer[int32](100000), + MinDelay: common.AsPointer[int32](100000), + ProgramText: `detect(when(data('*').count() < 1)).publish('no data')`, + Rules: []*detector.Rule{ + { + DetectLabel: "no data", + Notifications: []*notification.Notification{ + {Type: "Team", Value: ¬ification.TeamNotification{Type: "Team", Team: "awesome-team"}}, + }, + }, + }, + Tags: []string{"tag-01"}, + Teams: []string{"team-01"}, + DetectorOrigin: "Standard", + VisualizationOptions: &detector.Visualization{}, + }, + }, + { + Name: "Reported over mts Read", + Resource: NewResource(), + Encoder: encodeTerraform, + Decoder: decodeTerraform, + Meta: tftest.NewTestHTTPMockMeta(map[string]http.HandlerFunc{ + "GET /v2/detector/id-01": func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() + + _ = json.NewEncoder(w).Encode(&detector.Detector{ + Id: "id-01", + Name: "test detector", + Description: "An example detector response", + TimeZone: "Australia/Sydney", + MaxDelay: common.AsPointer[int32](100), + MinDelay: common.AsPointer[int32](100), + ProgramText: `detect(when(data('*').count() < 1)).publish('no data')`, + OverMTSLimit: true, + Rules: []*detector.Rule{ + { + DetectLabel: "no data", + Notifications: []*notification.Notification{ + {Type: "Team", Value: ¬ification.TeamNotification{Type: "Team", Team: "awesome-team"}}, + }, + }, + }, + Tags: []string{"tag-01"}, + Teams: []string{"team-01"}, + DetectorOrigin: "Standard", + }) + }, + }), + Input: &detector.Detector{ + Id: "id-01", + }, + Expect: &detector.Detector{ + Id: "id-01", + Name: "test detector", + Description: "An example detector response", + AuthorizedWriters: &detector.AuthorizedWriters{}, + TimeZone: "Australia/Sydney", + MaxDelay: common.AsPointer[int32](100000000), + MinDelay: common.AsPointer[int32](100000000), + ProgramText: `detect(when(data('*').count() < 1)).publish('no data')`, + OverMTSLimit: true, + Rules: []*detector.Rule{ + { + DetectLabel: "no data", + Notifications: []*notification.Notification{ + {Type: "Team", Value: ¬ification.TeamNotification{Type: "Team", Team: "awesome-team"}}, + }, + }, + }, + Tags: []string{"tag-01"}, + Teams: []string{"team-01"}, + DetectorOrigin: "Standard", + VisualizationOptions: &detector.Visualization{}, + }, + Issues: diag.Diagnostics{ + {Severity: diag.Warning, Summary: "detector is over mts limit"}, + }, + }, + } { + tc.TestRead(t) + } +} + +func TestResourceUpdate(t *testing.T) { + t.Parallel() + + for _, tc := range []tftest.ResourceOperationTestCase[detector.Detector]{ + { + Name: "No provider", + Meta: func(_ testing.TB) any { + return nil + }, + Resource: NewResource(), + Encoder: encodeTerraform, + Decoder: decodeTerraform, + Input: &detector.Detector{}, + Issues: diag.Diagnostics{ + {Severity: diag.Error, Summary: "expected to implement type Meta"}, + }, + }, + { + Name: "Failed update", + Meta: tftest.NewTestHTTPMockMeta(map[string]http.HandlerFunc{ + "PUT /v2/detector/id-01": func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() + + http.Error(w, "failed update", http.StatusInternalServerError) + }, + }), + Decoder: decodeTerraform, + Encoder: encodeTerraform, + Resource: NewResource(), + Input: &detector.Detector{Id: "id-01"}, + Issues: diag.Diagnostics{ + {Severity: diag.Error, Summary: "Bad status 500: failed update\n"}, + }, + }, + { + Name: "Successful update", + Meta: tftest.NewTestHTTPMockMeta(map[string]http.HandlerFunc{ + "PUT /v2/detector/id-01": func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&detector.CreateUpdateDetectorRequest{}); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + _ = json.NewEncoder(w).Encode(&detector.Detector{ + Id: "id-01", + Tags: []string{"updated"}, + }) + }, + }), + Decoder: decodeTerraform, + Encoder: encodeTerraform, + Resource: NewResource(), + Input: &detector.Detector{Id: "id-01"}, + Expect: &detector.Detector{ + Id: "id-01", + Tags: []string{"updated"}, + MinDelay: common.AsPointer[int32](0), + MaxDelay: common.AsPointer[int32](0), + Rules: []*detector.Rule{}, + AuthorizedWriters: &detector.AuthorizedWriters{}, + VisualizationOptions: &detector.Visualization{}, + }, + }, + } { + tc.TestUpdate(t) + } +} + +func TestResourceDelete(t *testing.T) { + t.Parallel() + + for _, tc := range []tftest.ResourceOperationTestCase[detector.Detector]{ + { + Name: "No provider", + Meta: func(_ testing.TB) any { + return nil + }, + Resource: NewResource(), + Encoder: encodeTerraform, + Decoder: tfext.NopDecodeTerraform[detector.Detector], + Input: &detector.Detector{}, + Issues: diag.Diagnostics{ + {Severity: diag.Error, Summary: "expected to implement type Meta"}, + }, + }, + { + Name: "successful delete", + Meta: tftest.NewTestHTTPMockMeta(map[string]http.HandlerFunc{ + "DELETE /v2/detector/detector-01": func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() + + w.WriteHeader(http.StatusNoContent) + }, + }), + Resource: NewResource(), + Encoder: encodeTerraform, + Decoder: tfext.NopDecodeTerraform[detector.Detector], + Input: &detector.Detector{ + Id: "detector-01", + }, + Expect: nil, + Issues: nil, + }, + { + Name: "failed delete", + Meta: tftest.NewTestHTTPMockMeta(map[string]http.HandlerFunc{ + "DELETE /v2/detector/detector-01": func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() + + http.Error(w, "invalid detector", http.StatusBadRequest) + }, + }), + Resource: NewResource(), + Encoder: encodeTerraform, + Decoder: tfext.NopDecodeTerraform[detector.Detector], + Input: &detector.Detector{ + Id: "detector-01", + }, + Expect: nil, + Issues: diag.Diagnostics{ + {Severity: diag.Error, Summary: "Unexpected status code: 400: invalid detector\n"}, + }, + }, + } { + tc.TestDelete(t) + } +} diff --git a/internal/definition/detector/schema.go b/internal/definition/detector/schema.go new file mode 100644 index 00000000..b76fe5dd --- /dev/null +++ b/internal/definition/detector/schema.go @@ -0,0 +1,339 @@ +// Copyright Splunk, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package detector + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/signalfx/signalfx-go/detector" + "go.uber.org/multierr" + + "github.com/splunk-terraform/terraform-provider-signalfx/internal/check" + "github.com/splunk-terraform/terraform-provider-signalfx/internal/common" + "github.com/splunk-terraform/terraform-provider-signalfx/internal/definition/rule" + tfext "github.com/splunk-terraform/terraform-provider-signalfx/internal/tfextension" + "github.com/splunk-terraform/terraform-provider-signalfx/internal/visual" +) + +func newSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the detector", + }, + "program_text": { + Type: schema.TypeString, + Required: true, + Description: "Signalflow program text for the detector. More info at \"https://developers.signalfx.com/docs/signalflow-overview\"", + ValidateFunc: validation.StringLenBetween(1, 50000), + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "Description of the detector", + }, + "timezone": { + Type: schema.TypeString, + Optional: true, + Default: "UTC", + ValidateDiagFunc: check.TimeZoneLocation(), + Description: "The property value is a string that denotes the geographic region associated with the time zone, (e.g. Australia/Sydney)", + }, + "max_delay": { + Type: schema.TypeInt, + Optional: true, + Default: 0, + Description: "Maximum time (in seconds) to wait for late datapoints. Max value is 900 (15m)", + ValidateFunc: validation.IntBetween(0, 900), + }, + "min_delay": { + Type: schema.TypeInt, + Optional: true, + Default: 0, + Description: "Minimum time (in seconds) for the computation to wait even if the datapoints are arriving in a timely fashion. Max value is 900 (15m)", + ValidateFunc: validation.IntBetween(0, 900), + }, + "show_data_markers": { + Type: schema.TypeBool, + Optional: true, + Default: true, + Description: "(true by default) When true, markers will be drawn for each datapoint within the visualization.", + }, + "show_event_lines": { + Type: schema.TypeBool, + Optional: true, + Description: "(false by default) When true, vertical lines will be drawn for each triggered event within the visualization.", + }, + "disable_sampling": { + Type: schema.TypeBool, + Optional: true, + Description: "(false by default) When false, samples a subset of the output MTS in the visualization.", + }, + "time_range": { + Type: schema.TypeInt, + Optional: true, + Default: 3600, + Description: "Seconds to display in the visualization. This is a rolling range from the current time. Example: 3600 = `-1h`. Defaults to 3600", + ConflictsWith: []string{"start_time", "end_time"}, + }, + "start_time": { + Type: schema.TypeInt, + Optional: true, + ConflictsWith: []string{"time_range"}, + Description: "Seconds since epoch. Used for visualization", + ValidateFunc: validation.IntAtLeast(0), + }, + "end_time": { + Type: schema.TypeInt, + Optional: true, + ConflictsWith: []string{"time_range"}, + Description: "Seconds since epoch. Used for visualization", + ValidateFunc: validation.IntAtLeast(0), + }, + "tags": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Tags associated with the detector", + }, + "teams": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Team IDs to associate the detector to", + }, + "rule": { + Type: schema.TypeSet, + Required: true, + Description: "Set of rules used for alerting", + Elem: &schema.Resource{ + SchemaFunc: rule.NewSchema, + }, + Set: rule.Hash, + }, + "authorized_writer_teams": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Team IDs that have write access to this dashboard", + }, + "authorized_writer_users": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "User IDs that have write access to this dashboard", + }, + "viz_options": { + Type: schema.TypeSet, + Optional: true, + Description: "Plot-level customization options, associated with a publish statement", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "label": { + Type: schema.TypeString, + Required: true, + Description: "The label used in the publish statement that displays the plot (metric time series data) you want to customize", + }, + "color": { + Type: schema.TypeString, + Optional: true, + Description: "Color to use", + ValidateDiagFunc: check.ColorName(), + }, + "display_name": { + Type: schema.TypeString, + Optional: true, + Description: "Specifies an alternate value for the Plot Name column of the Data Table associated with the chart.", + }, + "value_unit": { + Type: schema.TypeString, + Optional: true, + ValidateDiagFunc: check.ValueUnit(), + Description: "A unit to attach to this plot. Units support automatic scaling (eg thousands of bytes will be displayed as kilobytes)", + }, + "value_prefix": { + Type: schema.TypeString, + Optional: true, + Description: "An arbitrary prefix to display with the value of this plot", + }, + "value_suffix": { + Type: schema.TypeString, + Optional: true, + Description: "An arbitrary suffix to display with the value of this plot", + }, + }, + }, + }, + "label_resolutions": { + Type: schema.TypeMap, + Computed: true, + Description: "Resolutions of the detector alerts in milliseconds that indicate how often data is analyzed to determine if an alert should be triggered", + Elem: &schema.Schema{ + Type: schema.TypeInt, + }, + }, + "url": { + Type: schema.TypeString, + Computed: true, + Description: "URL of the detector", + }, + "detector_origin": { + Type: schema.TypeString, + Optional: true, + Default: "Standard", + Description: "Indicates how a detector was created", + ValidateFunc: validation.StringInSlice([]string{"Standard", "AutoDetectCustomization"}, false), + }, + "parent_detector_id": { + Type: schema.TypeString, + Optional: true, + Description: "ID of the parent AutoDetect detector from which this detector is customized and created. This property is required for detectors with detector_origin of type AutoDetectCustomization.", + }, + } +} + +func decodeTerraform(rd *schema.ResourceData) (*detector.Detector, error) { + d := &detector.Detector{ + Id: rd.Id(), + Name: rd.Get("name").(string), + Description: rd.Get("description").(string), + ProgramText: rd.Get("program_text").(string), + TimeZone: rd.Get("timezone").(string), + DetectorOrigin: rd.Get("detector_origin").(string), + ParentDetectorId: rd.Get("parent_detector_id").(string), + AuthorizedWriters: &detector.AuthorizedWriters{}, + //nolint:gosec // Overflow is not possible from config + MinDelay: common.AsPointer(int32(rd.Get("min_delay").(int)) * 1000), + //nolint:gosec // Overflow is not possible from config + MaxDelay: common.AsPointer(int32(rd.Get("min_delay").(int)) * 1000), + VisualizationOptions: &detector.Visualization{ + DisableSampling: rd.Get("disable_sampling").(bool), + ShowDataMarkers: rd.Get("show_data_markers").(bool), + ShowEventLines: rd.Get("show_event_lines").(bool), + }, + } + + if tr, ok := rd.GetOk("time_range"); ok { + d.VisualizationOptions.Time = &detector.Time{ + Range: common.AsPointer(int64(tr.(int)) * 1000), + Type: "relative", + } + } + + if rd.HasChanges("start_time", "end_time") { + d.VisualizationOptions.Time = &detector.Time{ + Type: "absolute", + Start: common.AsPointer(int64(rd.Get("start_time").(int)) * 1000), + End: common.AsPointer(int64(rd.Get("end_time").(int)) * 1000), + } + + } + + for field, ref := range map[string]*[]string{ + "teams": &d.Teams, + "tags": &d.Tags, + "authorized_writer_teams": &d.AuthorizedWriters.Teams, + "authorized_writer_users": &d.AuthorizedWriters.Users, + } { + if values, exist := rd.GetOk(field); exist { + for _, v := range values.(*schema.Set).List() { + (*ref) = append((*ref), v.(string)) + } + } + } + + rules, err := rule.DecodeTerraform(rd) + if err != nil { + return nil, err + } + d.Rules = rules + + palette := visual.NewColorPalette() + for _, data := range rd.Get("viz_options").(*schema.Set).List() { + viz := data.(map[string]any) + opt := &detector.PublishLabelOptions{ + Label: viz["label"].(string), + DisplayName: viz["display_name"].(string), + ValueUnit: viz["value_unit"].(string), + ValuePrefix: viz["value_prefix"].(string), + ValueSuffix: viz["value_suffix"].(string), + } + + if idx, ok := palette.ColorIndex(viz["color"].(string)); ok { + opt.PaletteIndex = common.AsPointer(idx) + } + + d.VisualizationOptions.PublishLabelOptions = append(d.VisualizationOptions.PublishLabelOptions, opt) + } + + return d, nil +} + +func encodeTerraform(dt *detector.Detector, rd *schema.ResourceData) error { + rd.SetId(dt.Id) + + errs := multierr.Combine( + rd.Set("name", dt.Name), + rd.Set("description", dt.Description), + rd.Set("timezone", dt.TimeZone), + rd.Set("program_text", dt.ProgramText), + rd.Set("detector_origin", dt.DetectorOrigin), + rd.Set("parent_detector_id", dt.ParentDetectorId), + rd.Set("teams", dt.Teams), + rd.Set("tags", dt.Tags), + rd.Set("label_resolutions", dt.LabelResolutions), + ) + if dt.MinDelay != nil { + errs = multierr.Append(errs, rd.Set("min_delay", *dt.MinDelay)) + } + if dt.MaxDelay != nil { + errs = multierr.Append(errs, rd.Set("max_delay", *dt.MaxDelay)) + } + if auth := dt.AuthorizedWriters; auth != nil { + errs = multierr.Append(errs, rd.Set("authorized_writer_teams", tfext.NewSchemaSet(schema.HashString, auth.Teams))) + errs = multierr.Append(errs, rd.Set("authorized_writer_users", tfext.NewSchemaSet(schema.HashString, auth.Users))) + } + + if viz := dt.VisualizationOptions; viz != nil { + errs = multierr.Append(errs, multierr.Combine( + rd.Set("disable_sampling", dt.VisualizationOptions.DisableSampling), + rd.Set("show_data_markers", dt.VisualizationOptions.ShowDataMarkers), + rd.Set("show_event_lines", dt.VisualizationOptions.ShowEventLines), + )) + if t := viz.Time; t != nil { + switch { + case t.Start != nil && t.End != nil: + errs = multierr.Append(errs, rd.Set("start_time", *t.Start)) + errs = multierr.Append(errs, rd.Set("end_time", *t.End)) + case t.Range != nil: + errs = multierr.Append(errs, rd.Set("time_range", *t.Range)) + } + } + labels := make([]map[string]any, 0, len(viz.PublishLabelOptions)) + palette := visual.NewColorPalette() + + for _, opts := range viz.PublishLabelOptions { + color := "" + if pi := opts.PaletteIndex; pi != nil { + if name, ok := palette.IndexColorName(*pi); ok { + color = name + } + } + + labels = append(labels, map[string]any{ + "label": opts.Label, + "display_name": opts.DisplayName, + "value_unit": opts.ValueUnit, + "value_suffix": opts.ValueSuffix, + "value_prefix": opts.ValuePrefix, + "color": color, + }) + + } + errs = multierr.Append(errs, rd.Set("viz_options", labels)) + } + + return multierr.Append(errs, rule.EncodeTerraform(dt.Rules, rd)) +} diff --git a/internal/definition/detector/schema_test.go b/internal/definition/detector/schema_test.go new file mode 100644 index 00000000..6ff9d4db --- /dev/null +++ b/internal/definition/detector/schema_test.go @@ -0,0 +1,198 @@ +// Copyright Splunk, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package detector + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/signalfx/signalfx-go/detector" + "github.com/signalfx/signalfx-go/notification" + "github.com/stretchr/testify/assert" + + "github.com/splunk-terraform/terraform-provider-signalfx/internal/common" +) + +func TestDecodeTerraform(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + data map[string]any + expect *detector.Detector + errVal string + }{ + { + name: "empty data", + data: map[string]any{}, + expect: &detector.Detector{ + AuthorizedWriters: &detector.AuthorizedWriters{}, + TimeZone: "UTC", + MaxDelay: common.AsPointer[int32](0), + MinDelay: common.AsPointer[int32](0), + Rules: []*detector.Rule{}, + VisualizationOptions: &detector.Visualization{ + ShowDataMarkers: true, + Time: &detector.Time{ + Type: "relative", + Range: common.AsPointer[int64](3600000), + }, + }, + DetectorOrigin: "Standard", + }, + errVal: "", + }, + { + name: "using absolute time references", + data: map[string]any{ + "start_time": 100, + "end_time": 1000, + }, + expect: &detector.Detector{ + AuthorizedWriters: &detector.AuthorizedWriters{}, + TimeZone: "UTC", + MaxDelay: common.AsPointer[int32](0), + MinDelay: common.AsPointer[int32](0), + Rules: []*detector.Rule{}, + VisualizationOptions: &detector.Visualization{ + ShowDataMarkers: true, + Time: &detector.Time{ + Type: "absolute", + Start: common.AsPointer[int64](100000), + End: common.AsPointer[int64](1000000), + }, + }, + DetectorOrigin: "Standard", + }, + }, + { + name: "Defines added fields", + data: map[string]any{ + "teams": []any{"team-02", "team-01"}, + "tags": []any{"tag-02", "tag-01"}, + "authorized_writer_teams": []any{"team-01"}, + "authorized_writer_users": []any{"user-01"}, + "viz_options": []any{ + map[string]any{"label": "label-01", "color": "pink"}, + }, + }, + expect: &detector.Detector{ + AuthorizedWriters: &detector.AuthorizedWriters{ + Teams: []string{"team-01"}, + Users: []string{"user-01"}, + }, + TimeZone: "UTC", + MaxDelay: common.AsPointer[int32](0), + MinDelay: common.AsPointer[int32](0), + Rules: []*detector.Rule{}, + Teams: []string{"team-02", "team-01"}, + Tags: []string{"tag-02", "tag-01"}, + VisualizationOptions: &detector.Visualization{ + ShowDataMarkers: true, + Time: &detector.Time{ + Type: "relative", + Range: common.AsPointer[int64](3600000), + }, + PublishLabelOptions: []*detector.PublishLabelOptions{ + {Label: "label-01", PaletteIndex: common.AsPointer[int32](14)}, + }, + }, + DetectorOrigin: "Standard", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + dt, err := decodeTerraform( + schema.TestResourceDataRaw(t, newSchema(), tc.data), + ) + assert.Equal(t, tc.expect, dt, "Must match the expected value") + if tc.errVal != "" { + assert.EqualError(t, err, tc.errVal, "Must match the expected value") + } else { + assert.NoError(t, err, "Must not report an error") + } + }) + } +} + +func TestEncodeTerraform(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + input *detector.Detector + errVal string + }{ + { + name: "empty detector", + input: &detector.Detector{}, + errVal: "", + }, + { + name: "time range", + input: &detector.Detector{ + VisualizationOptions: &detector.Visualization{ + Time: &detector.Time{ + Type: "relative", + Range: common.AsPointer[int64](1000), + }, + }, + }, + }, + { + name: "completely populated detector", + input: &detector.Detector{ + Id: "01", + Name: "my detector", + Description: "description", + TimeZone: "UTC", + ProgramText: `detect(when(data('*').count() < 1)).publish('no data sent')`, + Teams: []string{"team-01"}, + Tags: []string{"tag-01"}, + MinDelay: common.AsPointer[int32](1000), + MaxDelay: common.AsPointer[int32](1000), + AuthorizedWriters: &detector.AuthorizedWriters{ + Users: []string{"user-01"}, + Teams: []string{"team-01"}, + }, + VisualizationOptions: &detector.Visualization{ + DisableSampling: true, + ShowDataMarkers: true, + ShowEventLines: false, + Time: &detector.Time{ + Type: "absolute", + Start: common.AsPointer[int64](100), + End: common.AsPointer[int64](200), + }, + PublishLabelOptions: []*detector.PublishLabelOptions{ + {Label: "label-01", PaletteIndex: common.AsPointer[int32](12)}, + }, + }, + Rules: []*detector.Rule{ + { + Description: "Default team alert", + DetectLabel: "label-01", + Notifications: []*notification.Notification{ + {Type: "Team", Value: ¬ification.TeamNotification{Type: "Team", Team: "team-01"}}, + }, + }, + }, + }, + }, + } { + err := encodeTerraform(tc.input, schema.TestResourceDataRaw( + t, + newSchema(), + map[string]any{}, + )) + + if tc.errVal != "" { + assert.EqualError(t, err, tc.errVal, "Must match the expected error message") + } else { + assert.NoError(t, err, "Must not error ") + } + } +} diff --git a/internal/definition/detector/v0_state.go b/internal/definition/detector/v0_state.go new file mode 100644 index 00000000..902c81fb --- /dev/null +++ b/internal/definition/detector/v0_state.go @@ -0,0 +1,37 @@ +// Copyright Splunk, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package detector + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + "github.com/splunk-terraform/terraform-provider-signalfx/internal/common" + tfext "github.com/splunk-terraform/terraform-provider-signalfx/internal/tfextension" +) + +func v0state() *schema.Resource { + return &schema.Resource{ + Schema: map[string]*schema.Schema{ + "time_range": {Type: schema.TypeString, Optional: true}, + }, + } +} + +func v0stateMigration(ctx context.Context, state map[string]any, _ any) (map[string]any, error) { + tflog.Debug(ctx, "Upgrading detector state", tfext.NewLogFields().JSON("state", state)) + + if tr, ok := state["time_range"].(string); ok { + millis, err := common.FromTimeRangeToMilliseconds(tr) + if err != nil { + return nil, err + } + // Convert from millis back to seconds + state["time_range"] = millis / 1000 + } + + return state, nil +} diff --git a/internal/definition/detector/v0_state_test.go b/internal/definition/detector/v0_state_test.go new file mode 100644 index 00000000..692a1736 --- /dev/null +++ b/internal/definition/detector/v0_state_test.go @@ -0,0 +1,76 @@ +// Copyright Splunk, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package detector + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/stretchr/testify/assert" +) + +func TestStateV0(t *testing.T) { + t.Parallel() + + assert.Equal(t, + map[string]*schema.Schema{ + "time_range": { + Type: schema.TypeString, + Optional: true, + }, + }, + v0state().Schema, + "Must match the expected value", + ) +} + +func TestStateMigrationV0(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + state map[string]any + expect map[string]any + errVal string + }{ + { + name: "empty state", + state: map[string]any{}, + expect: map[string]any{}, + errVal: "", + }, + { + name: "invalid time range set", + state: map[string]any{ + "time_range": "friday", + }, + expect: nil, + errVal: "invalid timerange \"friday\": no negative prefix", + }, + { + name: "valid timerange set", + state: map[string]any{ + "time_range": "-10w2d", + }, + expect: map[string]any{ + "time_range": 6220800, + }, + errVal: "", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + actual, err := v0stateMigration(context.Background(), tc.state, nil) + + assert.Equal(t, tc.expect, actual, "Must match the expected state") + if tc.errVal != "" { + assert.EqualError(t, err, tc.errVal, "Must match the expected error") + } else { + assert.NoError(t, err, "Must not report an error") + } + }) + } +} diff --git a/internal/tfextension/encoding.go b/internal/tfextension/encoding.go index f8ec45c9..b83666eb 100644 --- a/internal/tfextension/encoding.go +++ b/internal/tfextension/encoding.go @@ -20,3 +20,11 @@ type ( // This is to be used when interfacing with other packages. EncodeTerraformFunc[T any] func(t *T, rd *schema.ResourceData) error ) + +func NopDecodeTerraform[T any](*schema.ResourceData) (*T, error) { + return nil, nil +} + +func NopEncodeTerraform[T any](*T, *schema.ResourceData) error { + return nil +} diff --git a/internal/tfextension/encoding_test.go b/internal/tfextension/encoding_test.go index 4ba7f309..2fa54754 100644 --- a/internal/tfextension/encoding_test.go +++ b/internal/tfextension/encoding_test.go @@ -2,3 +2,26 @@ // SPDX-License-Identifier: MPL-2.0 package tfext + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/stretchr/testify/assert" +) + +func TestNopDecodeTerraform(t *testing.T) { + t.Parallel() + + v, err := NopDecodeTerraform[int](&schema.ResourceData{}) + assert.IsType(t, (*int)(nil), v) + assert.Nil(t, v, "Must returned a nil value") + assert.NoError(t, err, "Must not return an error") +} + +func TestNopEncodeTerraform(t *testing.T) { + t.Parallel() + + err := NopEncodeTerraform[int](new(int), &schema.ResourceData{}) + assert.NoError(t, err, "Must not error") +}