diff --git a/internal/common/pointer.go b/internal/common/pointer.go index 631b66cd..c21a6839 100644 --- a/internal/common/pointer.go +++ b/internal/common/pointer.go @@ -6,3 +6,10 @@ package common func AsPointer[T any](t T) *T { return &t } + +func AsPointerOnCondition[T any](t T, cond func(val T) bool) *T { + if !cond(t) { + return nil + } + return AsPointer[T](t) +} diff --git a/internal/common/pointer_test.go b/internal/common/pointer_test.go index cd27e690..d838502b 100644 --- a/internal/common/pointer_test.go +++ b/internal/common/pointer_test.go @@ -16,3 +16,35 @@ func TestAsPointer(t *testing.T) { assert.IsType(t, (*int)(nil), AsPointer(val), "Must match the expected type") assert.Equal(t, &val, AsPointer(val), "Must match the expected value") } + +func TestAsPointerOnCondition(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + val int + cond func(int) bool + expect *int + }{ + { + name: "condition true", + val: 10, + cond: func(i int) bool { + return i > 0 + }, + expect: AsPointer(10), + }, + { + name: "condition false", + val: 7, + cond: func(i int) bool { return i%2 == 0 }, + expect: nil, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tc.expect, AsPointerOnCondition(tc.val, tc.cond)) + }) + } +} diff --git a/internal/definition/orgtoken/schema.go b/internal/definition/orgtoken/schema.go new file mode 100644 index 00000000..e1d0c97d --- /dev/null +++ b/internal/definition/orgtoken/schema.go @@ -0,0 +1,257 @@ +// Copyright Splunk, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package orgtoken + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/signalfx/signalfx-go/orgtoken" + "go.uber.org/multierr" + + "github.com/splunk-terraform/terraform-provider-signalfx/internal/check" + "github.com/splunk-terraform/terraform-provider-signalfx/internal/common" +) + +func newSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the token", + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "Decription of the token", + }, + "auth_scopes": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Computed: true, + Description: "Authentication scope, ex: INGEST, API, RUM ... (Optional)", + }, + "disabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Flag that controls enabling the token. If set to `true`, the token is disabled, and you can't use it for authentication. Defaults to `false`", + }, + "secret": { + Type: schema.TypeString, + Computed: true, + Sensitive: true, + Description: "The value of the token used for API actions.", + }, + "notifications": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateDiagFunc: check.Notification(), + }, + Description: "List of strings specifying where notifications will be sent when an incident occurs. See https://developers.signalfx.com/v2/docs/detector-model#notifications-models for more info", + }, + "host_or_usage_limits": { + Type: schema.TypeSet, + Optional: true, + MaxItems: 1, + ConflictsWith: []string{"dpm_limits"}, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "host_notification_threshold": { + Type: schema.TypeInt, + Optional: true, + Default: -1, + Description: "Notification threshold for hosts", + }, + "host_limit": { + Type: schema.TypeInt, + Optional: true, + Default: -1, + Description: "Max number of hosts that can use this token", + }, + "container_notification_threshold": { + Type: schema.TypeInt, + Optional: true, + Default: -1, + Description: "Notification threshold for containers", + }, + "container_limit": { + Type: schema.TypeInt, + Optional: true, + Default: -1, + Description: "Max number of containers that can use this token", + }, + "custom_metrics_notification_threshold": { + Type: schema.TypeInt, + Optional: true, + Default: -1, + Description: "Notification threshold for custom metrics", + }, + "custom_metrics_limit": { + Type: schema.TypeInt, + Optional: true, + Default: -1, + Description: "Max number of custom metrics that can be sent with this token", + }, + "high_res_metrics_notification_threshold": { + Type: schema.TypeInt, + Optional: true, + Default: -1, + Description: "Notification threshold for high-res metrics", + }, + "high_res_metrics_limit": { + Type: schema.TypeInt, + Optional: true, + Default: -1, + Description: "Max number of high-res metrics that can be sent with this token", + }, + }, + }, + }, + "dpm_limits": { + Type: schema.TypeSet, + Optional: true, + MaxItems: 1, + ConflictsWith: []string{"host_or_usage_limits"}, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "dpm_notification_threshold": { + Type: schema.TypeInt, + Optional: true, + Default: -1, + Description: "DPM level at which Splunk Observability Cloud sends the notification for this token. If you don't specify a notification, Splunk Observability Cloud sends the generic notification.", + }, + "dpm_limit": { + Type: schema.TypeInt, + Required: true, + Description: "The datapoints per minute (dpm) limit for this token. If you exceed this limit, Splunk Observability Cloud sends out an alert.", + }, + }, + }, + }, + "expires_at": { + Type: schema.TypeInt, + Computed: true, + Description: "The calculated time in Unix milliseconds of when the token will be deactivated.", + }, + } +} + +func encodeTerraform(data *schema.ResourceData) (*orgtoken.Token, error) { + token := &orgtoken.Token{ + Name: data.Get("name").(string), + Description: data.Get("description").(string), + Disabled: data.Get("disabled").(bool), + Limits: &orgtoken.Limit{}, + Secret: data.Get("secret").(string), + } + + if values, ok := data.GetOk("auth_scopes"); ok { + for _, scope := range values.([]any) { + token.AuthScopes = append(token.AuthScopes, scope.(string)) + } + } + + if values, ok := data.GetOk("notifications"); ok { + notifys, err := common.NewNotificationList(values.([]any)) + if err != nil { + return nil, err + } + token.Notifications = notifys + } + + if v, ok := data.GetOk("host_or_usage_limits"); ok { + limits := v.(*schema.Set).List()[0].(map[string]any) + token.Limits.CategoryQuota = &orgtoken.UsageLimits{ + HostThreshold: common.AsPointerOnCondition(int64(limits["host_limit"].(int)), unsetThreshold), + ContainerThreshold: common.AsPointerOnCondition(int64(limits["container_limit"].(int)), unsetThreshold), + CustomMetricThreshold: common.AsPointerOnCondition(int64(limits["custom_metrics_limit"].(int)), unsetThreshold), + HighResMetricThreshold: common.AsPointerOnCondition(int64(limits["high_res_metrics_limit"].(int)), unsetThreshold), + } + token.Limits.CategoryNotificationThreshold = &orgtoken.UsageLimits{ + HostThreshold: common.AsPointerOnCondition(int64(limits["host_notification_threshold"].(int)), unsetThreshold), + ContainerThreshold: common.AsPointerOnCondition(int64(limits["container_notification_threshold"].(int)), unsetThreshold), + CustomMetricThreshold: common.AsPointerOnCondition(int64(limits["custom_metrics_notification_threshold"].(int)), unsetThreshold), + HighResMetricThreshold: common.AsPointerOnCondition(int64(limits["high_res_metrics_notification_threshold"].(int)), unsetThreshold), + } + } + + if values, ok := data.GetOk("dpm_limits"); ok { + limits := values.(*schema.Set).List()[0].(map[string]any) + //nolint:gosec // Value is restricted to exceeding int32 max + token.Limits.DpmQuota = common.AsPointer(int32(limits["dpm_limit"].(int))) + if threshold, ok := limits["dpm_notification_threshold"]; ok { + //nolint:gosec // Value is restricted to exceeding int32 max + token.Limits.DpmNotificationThreshold = common.AsPointer(int32(threshold.(int))) + } + } + + return token, nil +} + +func decodeTerraform(token *orgtoken.Token, data *schema.ResourceData) error { + notifys, err := common.NewNotificationStringList(token.Notifications) + if err != nil { + return fmt.Errorf("notifications: %w", err) + } + + data.SetId(token.Name) + + errs := multierr.Combine( + data.Set("name", token.Name), + data.Set("description", token.Description), + data.Set("disabled", token.Disabled), + data.Set("auth_scopes", token.AuthScopes), + data.Set("notifications", notifys), + data.Set("secret", token.Secret), + data.Set("expires_at", token.Expiry), + ) + + if limits := token.Limits; limits != nil { + switch { + case limits.CategoryQuota != nil: + values := map[string]any{} + for field, val := range map[string]*int64{ + "host_limit": limits.CategoryQuota.HostThreshold, + "container_limit": limits.CategoryQuota.ContainerThreshold, + "custom_metrics_limit": limits.CategoryQuota.CustomMetricThreshold, + "high_res_metrics_limit": limits.CategoryQuota.HighResMetricThreshold, + } { + if val != nil && *val != -1 { + values[field] = val + } + } + if limits.CategoryNotificationThreshold != nil { + for field, val := range map[string]*int64{ + "host_notification_threshold": limits.CategoryNotificationThreshold.HostThreshold, + "container_notification_threshold": limits.CategoryNotificationThreshold.ContainerThreshold, + "custom_metrics_notification_threshold": limits.CategoryNotificationThreshold.CustomMetricThreshold, + "high_res_metrics_notification_threshold": limits.CategoryNotificationThreshold.HighResMetricThreshold, + } { + if val != nil && *val != -1 { + values[field] = val + } + } + } + if len(values) > 0 { + errs = multierr.Append(errs, data.Set("host_or_usage_limits", []map[string]any{values})) + } + case limits.DpmQuota != nil: + values := map[string]any{ + "dpm_limit": *limits.DpmQuota, + } + if limits.DpmNotificationThreshold != nil { + values["dpm_notification_threshold"] = *limits.DpmNotificationThreshold + } + errs = multierr.Append(errs, data.Set("dpm_limits", []map[string]any{values})) + } + } + + return errs +} + +func unsetThreshold(v int64) bool { return v != -1 } diff --git a/internal/definition/orgtoken/schema_test.go b/internal/definition/orgtoken/schema_test.go new file mode 100644 index 00000000..28c58e09 --- /dev/null +++ b/internal/definition/orgtoken/schema_test.go @@ -0,0 +1,191 @@ +// Copyright Splunk, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package orgtoken + +import ( + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/signalfx/signalfx-go/notification" + "github.com/signalfx/signalfx-go/orgtoken" + "github.com/stretchr/testify/assert" + + "github.com/splunk-terraform/terraform-provider-signalfx/internal/common" +) + +func TestSchemaEncode(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + values map[string]any + expect *orgtoken.Token + errVal string + }{ + { + name: "no values provided", + values: map[string]any{}, + expect: &orgtoken.Token{ + Limits: &orgtoken.Limit{}, + }, + errVal: "", + }, + { + name: "dpm limits set", + values: map[string]any{ + "name": "my awesome token", + "description": "This is a test token", + "secret": "derp", + "disabled": false, + "expires_at": int(time.Unix(0, 10000).UnixMilli()), + "dpm_limits": []any{ + map[string]any{"dpm_limit": 1000, "dpm_notification_threshold": 2000}, + }, + "notifications": []any{"Email,example@com"}, + }, + expect: &orgtoken.Token{ + Name: "my awesome token", + Description: "This is a test token", + Secret: "derp", + Disabled: false, + Expiry: time.Unix(0, 10000).UnixMilli(), + Limits: &orgtoken.Limit{ + DpmQuota: common.AsPointer[int32](1000), + DpmNotificationThreshold: common.AsPointer[int32](2000), + }, + Notifications: []*notification.Notification{ + {Type: "Email", Value: ¬ification.EmailNotification{Type: "Email", Email: "example@com"}}, + }, + }, + }, + { + name: "mts limits set", + values: map[string]any{ + "name": "my awesome token", + "description": "this is a test token", + "secret": "aabb", + "disabled": true, + "expires_at": int(time.Unix(0, 100).UnixMilli()), + "auth_scopes": []any{"power"}, + "host_or_usage_limits": []any{ + map[string]any{ + "host_notification_threshold": 100, + "host_limit": 1000, + "container_notification_threshold": 1000, + "container_limit": 10, + "custom_metrics_notification_threshold": 1, + "custom_metrics_limit": 1, + "high_res_metrics_notification_threshold": 1, + "high_res_metrics_limit": 1, + }, + }, + }, + expect: &orgtoken.Token{ + Name: "my awesome token", + Description: "this is a test token", + Secret: "aabb", + Disabled: true, + Expiry: time.Unix(0, 100).UnixMilli(), + AuthScopes: []string{"power"}, + Limits: &orgtoken.Limit{ + CategoryQuota: &orgtoken.UsageLimits{ + HostThreshold: common.AsPointer[int64](1000), + ContainerThreshold: common.AsPointer[int64](10), + CustomMetricThreshold: common.AsPointer[int64](1), + HighResMetricThreshold: common.AsPointer[int64](1), + }, + CategoryNotificationThreshold: &orgtoken.UsageLimits{ + HostThreshold: common.AsPointer[int64](100), + ContainerThreshold: common.AsPointer[int64](1000), + CustomMetricThreshold: common.AsPointer[int64](1), + HighResMetricThreshold: common.AsPointer[int64](1), + }, + }, + }, + errVal: "", + }, + { + name: "invalid notification", + values: map[string]any{ + "notifications": []any{0}, + }, + expect: nil, + errVal: "invalid notification string \"0\", not enough commas", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + data := schema.TestResourceDataRaw(t, newSchema(), tc.values) + + token, err := encodeTerraform(data) + assert.Equal(t, tc.expect, token, "Must match the expected token") + if tc.errVal != "" { + assert.EqualError(t, err, tc.errVal, "Must match the expected value") + } else { + assert.NoError(t, err, "Must not error encode") + } + }) + } +} + +func TestSchemaDecode(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + token *orgtoken.Token + errVal string + }{ + { + name: "empty token", + token: &orgtoken.Token{}, + errVal: "", + }, + { + name: "broken notifications", + token: &orgtoken.Token{Notifications: []*notification.Notification{{Type: "broken"}}}, + errVal: "notifications: unknown type provided", + }, + { + name: "dpm based token", + token: &orgtoken.Token{ + Name: "my awesome token", + Limits: &orgtoken.Limit{ + DpmQuota: common.AsPointer[int32](200), + DpmNotificationThreshold: common.AsPointer[int32](100), + }, + }, + errVal: "", + }, + { + name: "mts based token", + token: &orgtoken.Token{ + Name: "my awesome token", + Limits: &orgtoken.Limit{ + CategoryQuota: &orgtoken.UsageLimits{ + HostThreshold: common.AsPointer[int64](1), + }, + CategoryNotificationThreshold: &orgtoken.UsageLimits{ + HostThreshold: common.AsPointer[int64](1), + }, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + data := schema.TestResourceDataRaw(t, newSchema(), map[string]any{}) + + err := decodeTerraform(tc.token, data) + if tc.errVal != "" { + assert.EqualError(t, err, tc.errVal, "Must match the expected error") + } else { + assert.NoError(t, err, "Must not error") + } + }) + } +}